From bf16531fee69bcd6ceb7f1678114daf04d2c8623 Mon Sep 17 00:00:00 2001 From: Floke Date: Wed, 7 May 2025 06:00:42 +0000 Subject: [PATCH] v1.7.1 Re-Import der neu erstellten Version Re-Import der neu erstellten Version --- brancheneinstufung.py | 17604 ++++++++++++++++++++++++++-------------- 1 file changed, 11332 insertions(+), 6272 deletions(-) diff --git a/brancheneinstufung.py b/brancheneinstufung.py index 86c439b7..fffb2862 100644 --- a/brancheneinstufung.py +++ b/brancheneinstufung.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Automatisiertes Unternehmensbewertungs-Skript - Refactoring v1.7.0 +Automatisiertes Unternehmensbewertungs-Skript - Refactoring v1.7.1 Basierend auf v1.6.x - Umstrukturierung in modulare Klassen und flexibles UI. Dieses Skript dient der automatisierten Anreicherung, Validierung und Standardisierung @@ -8,13 +8,12 @@ von Unternehmensdaten, primär aus einem Google Sheet, ergänzt durch Web Scrapi Wikipedia, OpenAI (ChatGPT) und SerpAPI (Google Search, LinkedIn). Autor: [Ihr Name/Pseudonym] -Version: v1.7.0 +Version: v1.7.1 Hinweis zur Struktur: -Dieser Code wird in logischen Blöcken übermittelt. In einer realen Projektstruktur -würden diese Blöcke oft separaten .py-Dateien entsprechen (z.B. config.py, utils.py, ...). -Fügen Sie die Blöcke nacheinander in eine einzige Datei ein, achten Sie sorgfältig -auf die Einrückung, insbesondere innerhalb von Klassen und Funktionen. +Dieser Code wird in 4 logischen Bloecken uebermittelt. Fuegen Sie die Bloecke +nacheinander in diese einzige Datei ein, achten Sie sorgfaeltig auf die +Einrueckung. Jeder Block muss auf oberster Ebene eingefuegt werden (keine Einrueckung). """ # ============================================================================== @@ -31,39 +30,61 @@ import threading import traceback import logging import argparse +import random # Fuer Jitter im Retry Decorator from datetime import datetime -from urllib.parse import urlparse, urlencode, unquote # unquote wird später benötigt +from urllib.parse import urlparse, urlencode, unquote # Externe Bibliotheken import gspread +# Stellen Sie sicher, dass gspread >= 5.0.0 installiert ist. import wikipedia -import requests -import openai -from bs4 import BeautifulSoup +# Stellen Sie sicher, dass wikipedia-api nicht gleichzeitig installiert ist (Konflikt). +import requests # Fuer HTTP-Anfragen +# Stellen Sie sicher, dass requests >= 2.0.0 installiert ist +from bs4 import BeautifulSoup # Fuer HTML-Parsing +# Stellen Sie sicher, dass lxml oder html5lib installiert ist (empfohlen statt html.parser) +# z.B. pip install lxml from oauth2client.service_account import ServiceAccountCredentials # gspread dependency -from difflib import SequenceMatcher -import unicodedata +from difflib import SequenceMatcher # Fuer String-Aehnlichkeit +import unicodedata # Fuer Text-Normalisierung + +# Bibliotheken fuer Datenanalyse und ML import pandas as pd import numpy as np from sklearn.model_selection import train_test_split, GridSearchCV from sklearn.impute import SimpleImputer from sklearn.tree import DecisionTreeClassifier, export_text from sklearn.metrics import accuracy_score, classification_report, confusion_matrix -import concurrent.futures +import concurrent.futures # Fuer parallele Verarbeitung # Spezifische externe Tools -import gender_guesser.detector as gender # Für Geschlechtserkennung -# Optional: tiktoken für Token-Zählung (Modus 8) +try: + import gender_guesser.detector as gender # Fuer Geschlechtserkennung + # Initialisieren Sie den Detector einmal global (wird im Helper benutzt) + gender_detector = gender.Detector() + print("gender_guesser.Detector initialisiert.") +except ImportError: + gender = None + gender_detector = None + print("gender_guesser Bibliothek nicht gefunden. Geschlechtserkennung deaktiviert.") +except Exception as e: + gender = None + gender_detector = None + print(f"Fehler bei Initialisierung von gender_guesser: {e}. Geschlechtserkennung deaktiviert.") + + +# Optional: tiktoken fuer Token-Zaehlung (Modus 8) try: import tiktoken - print("tiktoken importiert.") # Debugging-Ausgabe + print("tiktoken importiert.") # Debugging-Ausgabe (geht nur an Konsole vor Logger Setup) except ImportError: tiktoken = None - print("tiktoken nicht gefunden. Token-Zählung wird geschätzt.") # Debugging-Ausgabe + print("tiktoken nicht gefunden. Token-Zaehlung wird geschaetzt.") # Debugging-Ausgabe + # ============================================================================== # 2. GLOBALE KONSTANTEN UND KONFIGURATION -# (Entspricht logisch etwa 'config.py') +# (Logisch 'config.py') # ============================================================================== # --- Dateipfade --- @@ -77,180 +98,166 @@ LOG_DIR = "Log" # --- ML Modell Artefakte --- MODEL_FILE = "technician_decision_tree_model.pkl" IMPUTER_FILE = "median_imputer.pkl" -PATTERNS_FILE_TXT = "technician_patterns.txt" -PATTERNS_FILE_JSON = "technician_patterns.json" # Optional +PATTERNS_FILE_TXT = "technician_patterns.txt" # Alt (Optional beibehalten) +PATTERNS_FILE_JSON = "technician_patterns.json" # Neu (Empfohlen) # --- Globale Konfiguration Klasse --- class Config: """Zentrale Konfigurationseinstellungen.""" VERSION = "v1.7.0" - LANG = "de" # Sprache für Wikipedia etc. - # ACHTUNG: SHEET_URL ist hier ein Platzhalter. Ersetzen Sie ihn durch Ihre tatsächliche URL. - SHEET_URL = "https://docs.google.com/spreadsheets/d/1u_gHr9JUfmV1-iviRzbSe3575QEp7KLhK5jFV_gJcgo" # <<< ERSETZEN SIE DIES! - MAX_RETRIES = 5 # Anzahl der Wiederholungen bei API/Netzwerk-Fehlern - RETRY_DELAY = 10 # Basiswartezeit (Sekunden) für Retries (exponentieller Backoff wird im Decorator angewendet) - SIMILARITY_THRESHOLD = 0.65 # Schwelle für Namensähnlichkeit bei Wikipedia Validierung + LANG = "de" # Sprache fuer Wikipedia etc. + # ACHTUNG: SHEET_URL ist hier ein Platzhalter. Ersetzen Sie ihn durch Ihre tatsaechliche URL. + SHEET_URL = "https://docs.google.com/spreadsheets/d/IHR_TATSÄCHLICHE_SHEET_ID/edit" # <<< ERSETZEN SIE DIES! + MAX_RETRIES = 5 # Anzahl der Versuche (nicht Wiederholungen nach dem ersten Fehler) fuer wiederholbare Fehler + RETRY_DELAY = 10 # Basiswartezeit (Sekunden) fuer Retries (exponentieller Backoff wird im Decorator angewendet) + REQUEST_TIMEOUT = 20 # Timeout (Sekunden) fuer externe HTTP/API Anfragen (Requests) + SIMILARITY_THRESHOLD = 0.65 # Schwelle fuer Namensaaehnlichkeit bei Wikipedia Validierung DEBUG = True # Detailliertes Logging aktivieren/deaktivieren - WIKIPEDIA_SEARCH_RESULTS = 5 # Anzahl Ergebnisse bei Wikipedia Suche über Bibliothek - HTML_PARSER = "html.parser" # Parser für BeautifulSoup - TOKEN_MODEL = "gpt-3.5-turbo" # OpenAI Modell für Token-Zählung/Chat + WIKIPEDIA_SEARCH_RESULTS = 5 # Anzahl Ergebnisse bei Wikipedia Suche ueber Bibliothek + HTML_PARSER = "html.parser" # Parser fuer BeautifulSoup ('lxml' ist schneller, erfordert aber Installation) + TOKEN_MODEL = "gpt-3.5-turbo" # OpenAI Modell fuer Token-Zaehlung/Chat (Standard fuer die meisten Calls) + USER_AGENT = 'Mozilla/5.0 (compatible; UnternehmenSkript/1.0; +https://www.example.com/bot)' # User-Agent fuer Web Scraping/Requests (Beispiel URL anpassen) - # --- Konfiguration für Batching & Parallelisierung --- - # Diese Werte steuern die Größe der Verarbeitungsbatches und die Parallelität. - # Passen Sie sie an die Leistung Ihres Systems und die API-Limits an. - PROCESSING_BATCH_SIZE = 20 # Anzahl Zeilen pro Verarbeitungs-Batch (für _process_single_row in Batches) - OPENAI_BATCH_SIZE_LIMIT = 4 # Max. Texte pro OpenAI Call für Zusammenfassung (nur für batch_summarize) - MAX_SCRAPING_WORKERS = 10 # Threads für paralleles Website-Scraping - UPDATE_BATCH_ROW_LIMIT = 50 # Zeilen sammeln für gebündelte Sheet Updates (effizienter) - MAX_BRANCH_WORKERS = 10 # Threads für parallele Branchenbewertung - OPENAI_CONCURRENCY_LIMIT = 3 # Max. gleichzeitige OpenAI Calls (Semaphore) - PROCESSING_BRANCH_BATCH_SIZE = 20 # Batch-Größe für Branch-Evaluierung + + # --- Konfiguration fuer Batching & Parallelisierung --- + # Passen Sie diese Werte an die Leistung Ihres Systems und die API-Limits an. + PROCESSING_BATCH_SIZE = 20 # Anzahl Zeilen pro Verarbeitungs-Batch (fuer _process_single_row in Batches) + OPENAI_BATCH_SIZE_LIMIT = 4 # Max. Texte pro OpenAI Call fuer Zusammenfassung (nur fuer summarize_batch_openai) + MAX_SCRAPING_WORKERS = 10 # Threads fuer paralleles Website-Scraping + UPDATE_BATCH_ROW_LIMIT = 50 # Zeilen sammeln fuer gebuendelte Sheet Updates (effizienter) + MAX_BRANCH_WORKERS = 10 # Threads fuer parallele Branchenbewertung + OPENAI_CONCURRENCY_LIMIT = 3 # Max. gleichzeitige OpenAI Calls (Semaphore fuer Branch Evaluation) + PROCESSING_BRANCH_BATCH_SIZE = 20 # Batch-Groesse fuer Branch-Evaluierung SERPAPI_DELAY = 1.5 # Pause zwischen einzelnen SerpAPI-Aufrufen (Sekunden) - # --- API Schlüssel Speicherung --- + + # --- API Schluessel Speicherung (werden in main() geladen) --- API_KEYS = {} @classmethod def load_api_keys(cls): - """Lädt API-Schlüssel aus den definierten Dateien.""" - # Der Logger ist hier noch nicht vollständig konfiguriert, verwenden Sie print oder debug_print + """Laedt API-Schluessel aus den definierten Dateien.""" + # Der Logger ist hier noch nicht vollstaendig konfiguriert, verwenden Sie print # logging.info wird nach Konfiguration des File Handlers korrekt funktionieren - print("Lade API-Schlüssel...") + print("Lade API-Schluessel...") cls.API_KEYS['openai'] = cls._load_key_from_file(API_KEY_FILE) cls.API_KEYS['serpapi'] = cls._load_key_from_file(SERP_API_KEY_FILE) cls.API_KEYS['genderize'] = cls._load_key_from_file(GENDERIZE_API_KEY_FILE) if cls.API_KEYS.get('openai'): + # Setze den OpenAI API Key global fuer die Bibliothek openai.api_key = cls.API_KEYS['openai'] print("OpenAI API Key erfolgreich geladen.") else: - print("WARNUNG: OpenAI API Key konnte nicht geladen werden (Datei fehlt oder ist leer?).") + print("WARNUNG: OpenAI API Key konnte nicht geladen werden (Datei fehlt oder ist leer?). OpenAI-Funktionen sind deaktiviert.") if not cls.API_KEYS.get('serpapi'): - print("WARNUNG: SerpAPI Key konnte nicht geladen werden (Datei fehlt oder ist leer?). Bestimmte Funktionen sind deaktiviert.") + print("WARNUNG: SerpAPI Key konnte nicht geladen werden (Datei fehlt oder ist leer?). Bestimmte Suchfunktionen sind deaktiviert.") if not cls.API_KEYS.get('genderize'): - print("WARNUNG: Genderize API Key konnte nicht geladen werden (Datei fehlt oder ist leer?). Bestimmte Funktionen sind deaktiviert.") + print("WARNUNG: Genderize API Key konnte nicht geladen werden (Datei fehlt oder ist leer?). Geschlechtserkennung ist eingeschraenkt.") @staticmethod def _load_key_from_file(filepath): - """Hilfsfunktion zum Laden eines Schlüssels aus einer Datei.""" + """Hilfsfunktion zum Laden eines Schluessels aus einer Datei.""" try: + # Verwenden Sie "r" fuer Textmodus und geben Sie das Encoding an with open(filepath, "r", encoding="utf-8") as f: key = f.read().strip() if key: - # print(f"Schlüssel aus '{filepath}' erfolgreich geladen.") # Zu viel Lärm im Debug + # print(f"Schluessel aus '{filepath}' erfolgreich geladen.") # Zu viel Laerm im Debug return key else: + # Logge auf Warning, wenn die Datei leer ist print(f"WARNUNG: Datei '{filepath}' ist leer.") return None except FileNotFoundError: - # Info, da das Fehlen eines Keys nicht immer ein Fehler sein muss - print(f"INFO: API-Schlüsseldatei '{filepath}' nicht gefunden.") + # Logge auf Info, da das Fehlen eines Keys nicht immer ein Fehler sein muss + print(f"INFO: API-Schluesseldatei '{filepath}' nicht gefunden.") return None except Exception as e: - print(f"FEHLER beim Lesen der Schlüsseldatei '{filepath}': {e}") + # Logge auf Error, wenn beim Lesen ein anderer Fehler auftritt + print(f"FEHLER beim Lesen der Schluesseldatei '{filepath}': {e}") return None + # --- Globale Spalten-Mapping (WICHTIG: MUSS ZU IHREM SHEET PASSEN!) --- # Index ist 0-basiert, Spaltenbuchstaben sind 1-basiert (A=1, AW=49) -# Dies sollte die Mapping-Definition aus Ihrem Code (Teil 1) sein, vervollständigt. +# Dies sollte die Mapping-Definition aus Ihrem Code (Teil 1) sein, vervollstaendigt. +# UEBERPRUEFEN SIE DIESES MAPPING SORGFÄLTIG GEGEN IHR SHEET! COLUMN_MAP = { - "ReEval Flag": 0, # A - Markierungsspalte für manuelle Re-Evaluation + "ReEval Flag": 0, # A - Markierungsspalte fuer manuelle Re-Evaluation "CRM Name": 1, # B - Unternehmensname aus CRM "CRM Kurzform": 2, # C - Manuell gepflegte Kurzform - "CRM Website": 3, # D - Website URL aus CRM (kann durch Skript ergänzt werden) + "CRM Website": 3, # D - Website URL aus CRM (kann durch Skript ergaenzt werden) "CRM Ort": 4, # E - Ort aus CRM "CRM Beschreibung": 5, # F - Beschreibung aus CRM "CRM Branche": 6, # G - Branche aus CRM "CRM Beschreibung Branche extern": 7, # H - Externe Branchenbeschreibung (falls vorhanden) - "CRM Anzahl Techniker": 8, # I - Bekannte Anzahl Servicetechniker aus CRM (Zielvariable für ML) + "CRM Anzahl Techniker": 8, # I - Bekannte Anzahl Servicetechniker aus CRM (Zielvariable fuer ML) "CRM Umsatz": 9, # J - Umsatz aus CRM "CRM Anzahl Mitarbeiter": 10, # K - Anzahl Mitarbeiter aus CRM - "CRM Vorschlag Wiki URL": 11, # L - Vorschlag für Wiki URL (kann manuell gepflegt werden) + "CRM Vorschlag Wiki URL": 11, # L - Vorschlag fuer Wiki URL (kann manuell gepflegt werden) "Wiki URL": 12, # M - Gefundene oder validierte Wikipedia URL "Wiki Absatz": 13, # N - Erster Absatz des Wikipedia Artikels "Wiki Branche": 14, # O - Branche aus Wikipedia Infobox "Wiki Umsatz": 15, # P - Umsatz aus Wikipedia Infobox "Wiki Mitarbeiter": 16, # Q - Mitarbeiterzahl aus Wikipedia Infobox "Wiki Kategorien": 17, # R - Wikipedia Kategorien - "Chat Wiki Konsistenzprüfung": 18, # S - ChatGPT Check: Passt Wiki Artikel zum Unternehmen? ('OK', 'X', '?') - "Chat Begründung Wiki Inkonsistenz": 19, # T - Begründung, wenn S='X' - "Chat Vorschlag Wiki Artikel": 20, # U - ChatGPT Vorschlag für alternativen Wiki Artikel (falls S='X') - "Begründung bei Abweichung": 21, # V - Nicht mehr primär genutzt - "Chat Vorschlag Branche": 22, # W - ChatGPT Vorschlag für Branche (Zielschema) + "Chat Wiki Konsistenzpruefung": 18, # S - ChatGPT Check: Passt Wiki Artikel zum Unternehmen? ('OK', 'X', '?') + "Chat Begruendung Wiki Inkonsistenz": 19, # T - Begruendung, wenn S='X' + "Chat Vorschlag Wiki Artikel": 20, # U - ChatGPT Vorschlag fuer alternativen Wiki Artikel (falls S='X') + "Begruendung bei Abweichung": 21, # V - Nicht mehr primaer genutzt (Begruendung CRM vs Wiki URL) + "Chat Vorschlag Branche": 22, # W - ChatGPT Vorschlag fuer Branche (Zielschema) "Chat Konsistenz Branche": 23, # X - Vergleich W vs. G ('ok', 'X', 'fallback_...') - "Chat Begründung Abweichung Branche": 24, # Y - Begründung für W - "Chat Prüfung FSM Relevanz": 25, # Z - ChatGPT Check: Ist das Unternehmen für FSM relevant? - "Chat Begründung für FSM Relevanz": 26, # AA - Begründung für Z - "Chat Schätzung Anzahl Mitarbeiter": 27, # AB - ChatGPT Schätzung Mitarbeiter - "Chat Konsistenzprüfung Mitarbeiterzahl": 28, # AC - Vergleich AB vs. K/Q - "Chat Begründung Abweichung Mitarbeiterzahl": 29, # AD - Begründung für AB/AC - "Chat Einschätzung Anzahl Servicetechniker": 30, # AE - ChatGPT Schätzung Servicetechniker - "Chat Begründung Abweichung Anzahl Servicetechniker": 31, # AF - Begründung für AE - "Chat Schätzung Umsatz": 32, # AG - ChatGPT Schätzung Umsatz - "Chat Begründung Abweichung Umsatz": 33, # AH - Begründung für AG + "Chat Begruendung Abweichung Branche": 24, # Y - Begruendung fuer W + "Chat Pruefung FSM Relevanz": 25, # Z - ChatGPT Check: Ist das Unternehmen fuer FSM relevant? + "Chat Begruendung fuer FSM Relevanz": 26, # AA - Begruendung fuer Z + "Chat Schaetzung Anzahl Mitarbeiter": 27, # AB - ChatGPT Schaetzung Mitarbeiter + "Chat Konsistenzpruefung Mitarbeiterzahl": 28, # AC - Vergleich AB vs. K/Q + "Chat Begruendung Abweichung Mitarbeiterzahl": 29, # AD - Begruendung fuer AB/AC + "Chat Einschaetzung Anzahl Servicetechniker": 30, # AE - ChatGPT Schaetzung Servicetechniker + "Chat Begruendung Abweichung Anzahl Servicetechniker": 31, # AF - Begruendung fuer AE + "Chat Schaetzung Umsatz": 32, # AG - ChatGPT Schaetzung Umsatz + "Chat Begruendung Abweichung Umsatz": 33, # AH - Begruendung fuer AG "Linked Serviceleiter gefunden": 34, # AI - Anzahl gefundener Kontakte (Serviceleiter) "Linked It-Leiter gefunden": 35, # AJ - Anzahl gefundener Kontakte (IT-Leiter) "Linked Management gefunden": 36, # AK - Anzahl gefundener Kontakte (Management) "Linked Disponent gefunden": 37, # AL - Anzahl gefundener Kontakte (Disponent) "Contact Search Timestamp": 38, # AM - Timestamp der letzten LinkedIn Suche - "Wikipedia Timestamp": 39, # AN - Timestamp der letzten erfolgreichen Wiki Extraktion (M-R befüllt) - "Timestamp letzte Prüfung": 40, # AO - Timestamp der letzten ChatGPT Evaluationen (W-Y, Z-AD, AE-AH befüllt) + "Wikipedia Timestamp": 39, # AN - Timestamp der letzten erfolgreichen Wiki Extraktion (M-R befuellt) + "Timestamp letzte Pruefung": 40, # AO - Timestamp der letzten ChatGPT Evaluationen (W-Y, Z-AD, AE-AH, AG-AH befuellt) "Version": 41, # AP - Skriptversion, die die Zeile zuletzt bearbeitet hat - "Tokens": 42, # AQ - Anzahl Tokens des letzten OpenAI Calls für diese Zeile (ggf. aggregiert) + "Tokens": 42, # AQ - Anzahl Tokens des letzten OpenAI Calls fuer diese Zeile (ggf. aggregiert) "Website Rohtext": 43, # AR - Roh extrahierter Text von der Website "Website Zusammenfassung": 44, # AS - ChatGPT Zusammenfassung von AR - "Website Scrape Timestamp": 45, # AT - Timestamp des letzten erfolgreichen Website Scrapings (AR, AS befüllt) - "Geschätzter Techniker Bucket": 46, # AU - Ergebnis des ML-Modells (Bucket) + "Website Scrape Timestamp": 45, # AT - Timestamp des letzten erfolgreichen Website Scrapings (AR, AS befuellt) + "Geschaetzter Techniker Bucket": 46, # AU - Ergebnis des ML-Modells (Bucket) "Finaler Umsatz (Wiki>CRM)": 47,# AV - Konsolidierter Umsatz (Wiki > CRM) "Finaler Mitarbeiter (Wiki>CRM)": 48, # AW - Konsolidierte Mitarbeiterzahl (Wiki > CRM) - "Wiki Verif. Timestamp": 49, # AX - Timestamp der letzten Wiki-Verifikation (S-U befüllt) + "Wiki Verif. Timestamp": 49, # AX - Timestamp der letzten Wiki-Verifikation (S-U befuellt) "SerpAPI Wiki Search Timestamp": 50 # AY - Timestamp der letzten SerpAPI-Suche nach fehlender Wiki-URL (Modus find_wiki_serp) } -# Bestätigen Sie, dass dies Ihre tatsächlichen Spalten sind! +# Bestaetigen Sie, dass dies Ihre tatsaechlichen Spalten sind! -# --- Globale Variablen für Branch Mapping (geladen von load_target_schema) --- -BRANCH_MAPPING = {} # Wird derzeit nicht verwendet, aber beibehalten -TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar." +# --- Globale Variablen fuer Branch Mapping (werden von load_target_schema() befuellt) --- +# BRANCH_MAPPING wird derzeit nicht verwendet, kann aber beibehalten werden. +BRANCH_MAPPING = {} +TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar." # String-Repraesentation des Schemas fuer Prompts ALLOWED_TARGET_BRANCHES = [] # Liste der erlaubten Kurzformen + # ============================================================================== -# 3. GLOBALE HELPER FUNCTIONS & DECORATORS -# (Entspricht logisch etwa 'utils.py') +# Ende Basis-Setup & Globale Helfer Block # ============================================================================== -# Logger Setup (Wird in main() finalisiert) -# Erhalten Sie eine Logger-Instanz für dieses Modul. Der Root-Logger wird in main() konfiguriert. -logger = logging.getLogger(__name__) +# ============================================================================== +# GLOBALE HELPER FUNCTIONS (PART 1: Retry Decorator) +# ============================================================================== -# Zusätzliche Imports, die von globalen Helfern benötigt werden (einige sind bereits am Anfang) -import random # Für Jitter im Backoff -import time # Für sleep -# logging ist bereits importiert -import requests # Für requests.exceptions (RequestException, HTTPError) -import gspread # Für gspread.exceptions (APIError, SpreadsheetNotFound) -import openai # Für openai.error (OpenAIError, AuthenticationError, InvalidRequestError etc.) -import wikipedia # Für wikipedia.exceptions (WikipediaException, PageError, DisambiguationError etc.) -# traceback ist bereits importiert -# re ist bereits importiert -# csv ist bereits importiert -# json ist bereits importiert -# pickle ist bereits importiert -# datetime ist bereits importiert -# urllib.parse (unquote) ist bereits importiert -# difflib (SequenceMatcher) ist bereits importiert -# unicodedata ist bereits importiert -# pandas, numpy sind bereits importiert -# concurrent.futures, threading sind bereits importiert -# gender_guesser ist bereits importiert -# tiktoken ist bereits importiert - - -# Logger für den Retry Decorator selbst +# Logger fuer den Retry Decorator selbst (Nutzt den globalen Root Logger) decorator_logger = logging.getLogger(__name__ + ".Retry") - # --- Retry Decorator --- # KORRIGIERTE Version (Behandelt SpreadsheetNotFound und 404/400/401/403 HTTPError explizit) def retry_on_failure(func): @@ -260,43 +267,43 @@ def retry_on_failure(func): """ def wrapper(*args, **kwargs): func_name = func.__name__ - # Versuche, das 'self' Argument für Methoden zu extrahieren, falls vorhanden + # Versuche, das 'self' Argument fuer Methoden zu extrahieren, falls vorhanden self_arg = args[0] if args and hasattr(args[0], func_name) and isinstance(args[0], object) else None - # Konstruiere einen aussagekräftigeren Funktionsnamen für die Logs + # Konstruiere einen aussagekraeftigeren Funktionsnamen fuer die Logs effective_func_name = f"{self_arg.__class__.__name__}.{func_name}" if self_arg else func_name # Basiswartezeit und maximale Anzahl Versuche aus Config holen max_retries_config = getattr(Config, 'MAX_RETRIES', 3) # Anzahl der Versuche (nicht Wiederholungen nach dem ersten Fehler) base_delay = getattr(Config, 'RETRY_DELAY', 5) - # Wenn max_retries_config 0 oder weniger ist, einfach einmal ausführen + # Wenn max_retries_config 0 oder weniger ist, einfach einmal ausfuehren if max_retries_config <= 0: try: return func(*args, **kwargs) except Exception as e: # Fehler loggen und weitergeben, wenn keine Retries konfiguriert sind decorator_logger.error(f"FEHLER bei '{effective_func_name}' (keine Retries konfiguriert). {type(e).__name__} - {str(e)[:150]}...") - # Log traceback für unerwartete Fehler (nicht die spezifischen API/Netzwerkfehler) + # Log traceback fuer unerwartete Fehler (nicht die spezifischen API/Netzwerkfehler) if not isinstance(e, (requests.exceptions.RequestException, gspread.exceptions.APIError, openai.error.OpenAIError, wikipedia.exceptions.WikipediaException)): decorator_logger.exception("Details zum Fehler:") raise e # Re-raise the exception # --- Retry logic for max_retries_config > 0 --- - # Die Schleife läuft max_retries_config mal. + # Die Schleife laeuft max_retries_config mal. for attempt in range(max_retries_config): try: - # Logge jeden Versuch, außer den ersten (optional, um Log-Lärm zu reduzieren) + # Logge jeden Versuch, ausser den ersten (optional, um Log-Laerm zu reduzieren) if attempt > 0: - decorator_logger.warning(f"Wiederhole Versuch {attempt + 1}/{max_retries_config} für '{effective_func_name}'...") + decorator_logger.warning(f"Wiederhole Versuch {attempt + 1}/{max_retries_config} fuer '{effective_func_name}'...") return func(*args, **kwargs) # Call the original function # Spezifische Exceptions, die ein Retry nicht rechtfertigen (permanente Fehler) except (gspread.exceptions.SpreadsheetNotFound, openai.error.AuthenticationError, ValueError) as e: # Diese Fehler deuten auf ein permanentes Problem hin (falsche URL, falscher Key, falsche Eingabe) - decorator_logger.critical(f"❌ ENDGÜLTIGER FEHLER bei '{effective_func_name}': Permanentes Problem erkannt. {type(e).__name__} - {str(e)[:150]}...") - decorator_logger.exception("Details:") # Log traceback für permanente Fehler + decorator_logger.critical(f"❌ ENDGUELTIGER FEHLER bei '{effective_func_name}': Permanentes Problem erkannt. {type(e).__name__} - {str(e)[:150]}...") + decorator_logger.exception("Details:") # Log traceback fuer permanente Fehler raise e # Leiten Sie diese Exception sofort weiter # Fangen Sie Requests HTTP Errors (wie 404) @@ -307,7 +314,7 @@ def retry_on_failure(func): non_retryable_status_codes = [404, 400, 401, 403] # Not Found, Bad Request, Unauthorized, Forbidden if status_code in non_retryable_status_codes: - decorator_logger.critical(f"❌ ENDGÜLTIGER FEHLER bei '{effective_func_name}': HTTP Fehler {status_code} erhalten ({e.response.reason}). Nicht wiederholbar. {str(e)[:150]}...") + decorator_logger.critical(f"❌ ENDGUELTIGER FEHLER bei '{effective_func_name}': HTTP Fehler {status_code} erhalten ({e.response.reason}). Nicht wiederholbar. {str(e)[:100]}...") # Kuerzer im Log decorator_logger.exception("Details:") # Log traceback raise e # Leiten Sie diese nicht-wiederholbare Exception sofort weiter # Ansonsten behandle HTTP Errors wie andere RequestExceptions (weiter unten) @@ -339,7 +346,7 @@ def retry_on_failure(func): time.sleep(wait_time) # Warte vor dem nächsten Versuch else: # Letzter Versuch fehlgeschlagen - decorator_logger.error(f"❌ ENDGÜLTIGER FEHLER bei '{effective_func_name}' nach {max_retries_config} Versuchen.") + decorator_logger.error(f"❌ ENDGUELTIGER FEHLER bei '{effective_func_name}' nach {max_retries_config} Versuchen.") raise e # Leite die ursprüngliche Exception weiter except Exception as e: @@ -357,676 +364,984 @@ def retry_on_failure(func): return wrapper # Gibt die Wrapper-Funktion zurück -# --- Token Count Funktion --- -# Übernommen aus Ihrem Code (Teil 5), leicht angepasst für Logger. -# Der retry_on_failure Decorator ist hier nicht sinnvoll, da es eine lokale Berechnung ist. -def token_count(text, model=None): - """Zählt Tokens via tiktoken oder schätzt über Leerzeichen.""" - # Verwenden Sie logger, da das Logging jetzt konfiguriert ist - if not text or not isinstance(text, str): return 0 - - current_model = model if model else getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo') - - if tiktoken: - try: - # Cache encoding object per model - if not hasattr(token_count, 'enc_cache'): - token_count.enc_cache = {} - if current_model not in token_count.enc_cache: - token_count.enc_cache[current_model] = tiktoken.encoding_for_model(current_model) - enc = token_count.enc_cache[current_model] - return len(enc.encode(text)) - except Exception as e: - logger.debug(f"Fehler beim Token-Counting mit tiktoken für Modell '{current_model}': {e} - Fallback zur Schätzung.") - # Fallback zur Schätzung - return len(str(text).split()) # Sicherstellen, dass text ein String ist - else: - # Fallback Schätzung - return len(str(text).split()) # Sicherstellen, dass text ein String ist - - -# --- Logging Helpers --- -# Übernommen aus Ihrem Code (Teil 3), leicht angepasst für Standard-Logger. -# LOG_FILE ist global definiert und wird in main() gesetzt -LOG_FILE = None - -def create_log_filename(mode): - """Erstellt einen zeitgestempelten Logdateinamen im LOG_DIR.""" - # Verwenden Sie logger, da das Logging jetzt konfiguriert ist (print am Anfang von main) - log_dir_path = LOG_DIR # Nutzt die globale Konstante - - if not os.path.exists(log_dir_path): - try: - os.makedirs(log_dir_path, exist_ok=True) # exist_ok=True verhindert Fehler, wenn Dir existiert - logger.info(f"Log-Verzeichnis '{log_dir_path}' erstellt.") - except Exception as e: - logger.error(f"FEHLER: Konnte Log-Verzeichnis '{log_dir_path}' nicht erstellen: {e}") - # Versuche, die Datei im aktuellen Verzeichnis zu erstellen, wenn LOG_DIR fehlschlägt - log_dir_path = "." # Fallback Verzeichnis - logger.warning(f"Versuche, Logdatei im aktuellen Verzeichnis '{log_dir_path}' zu erstellen.") - - try: - now = datetime.now().strftime("%d-%m-%Y_%H-%M") - # Sicherstellen, dass Config.VERSION verfügbar ist, Fallback falls nicht - ver_short = getattr(Config, 'VERSION', 'unknown').replace(".", "") - filename = f"{now}_{ver_short}_Modus{mode}.txt" - return os.path.join(log_dir_path, filename) - except Exception as e_fallback: - logger.error(f"FEHLER: Konnte Logdateinamen auch im Fallback-Verzeichnis '{log_dir_path}' nicht erstellen: {e_fallback}") - return None # Signalisiert Fehler - - -# debug_print ist nicht mehr notwendig, da wir das Standard-Logging nutzen. -# Alle bisherigen Aufrufe von debug_print werden durch logger.debug, logger.info, logger.warning, logger.error, logger.critical ersetzt. +# ============================================================================== +# Ende Retry Decorator Block +# ============================================================================== +# ============================================================================== +# GLOBALE HELPER FUNCTIONS (PART 2: Text, String & URL Utilities) +# ============================================================================== # --- Text Normalisierung & Reinigung --- -# Übernommen aus Ihrem Code (Teil 3) +# Basierend auf Code aus Teil 3. +# Nutzt globale Helfer: re, unicodedata. def simple_normalize_url(url): """Normalisiert URL zu domain.tld oder k.A. (ohne www, ohne Pfad).""" + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist if not url or not isinstance(url, str): return "k.A." url = url.strip() - if not url or url.lower() == 'k.A.': return "k.A." # Prüfe auf Kleinbuchstaben "k.A." - # Falls kein Schema vorhanden, hinzufügen (HTTPS bevorzugen) - if not url.lower().startswith(("http://", "https://")): url = "https://" + url + # Pruefe auf Kleinbuchstaben "k.A." und leere Strings nach dem Strippen + if not url or url.lower() == 'k.a.': return "k.A." + # Falls kein Schema vorhanden ist, hinzufuegen (HTTPS bevorzugen) + if not url.lower().startswith(("http://", "https://")): + url = "https://" + url # Fuege HTTPS als Standard hinzu try: + # Parse die URL in ihre Komponenten parsed = urlparse(url) + # Extrahieren Sie den Netzwerkteil (Domain und Port) domain_part = parsed.netloc - if not domain_part: return "k.A." # Wenn netloc leer - domain_part = domain_part.split(":", 1)[0] # Port entfernen - if '@' in domain_part: domain_part = domain_part.split('@', 1)[1] # User/Passwort entfernen - # Wandle Punycode (IDN) in Unicode um - try: domain_part = domain_part.encode('ascii').decode('idna') - except UnicodeDecodeError: pass # Behalte Original, wenn IDNA fehlschlägt - domain_part = domain_part.lower() # Kleinschreibung - # Optional: "www." entfernen - if domain_part.startswith("www."): domain_part = domain_part[4:] - # Einfache Prüfung auf mindestens einen Punkt (Basic TLD check) - # Prüfen Sie auch auf leere domain_part nach Bearbeitung - return domain_part if domain_part and '.' in domain_part and domain_part.split('.')[-1].isalpha() else "k.A." + # Wenn der Netzwerkteil leer ist, kann die URL nicht normalisiert werden + if not domain_part: + logger.debug(f"URL '{url[:100]}...' konnte nicht sinnvoll geparst werden (leerer netloc).") + return "k.A." + # Port entfernen, falls vorhanden (z.B. :8080) + domain_part = domain_part.split(":", 1)[0] + # Entferne User/Passwort-Teile, falls vorhanden (user:pass@domain) + if '@' in domain_part: + domain_part = domain_part.split('@', 1)[1] + + # Wandle Punycode (IDN) in Unicode um fuer Lesbarkeit (z.B. xn--...) + try: + # Versuchen Sie die IDNA-Dekodierung + domain_part = domain_part.encode('ascii').decode('idna') + except UnicodeDecodeError: + # Logge eine Warnung, wenn die Dekodierung fehlschlaegt, aber behalte den Original-Domain-Teil + # logger.warning(f"Fehler bei IDNA-Dekodierung fuer Domain '{domain_part}' aus URL '{url[:100]}...'. Behalte Original.") # Zu viel Laerm im Debug + pass # Behalte den urspruenglichen domain_part, wenn Dekodierung fehlschlaegt + + # Konvertiere den Domain-Teil zu Kleinbuchstaben + domain_part = domain_part.lower() + + # Optional: "www." am Anfang entfernen (optional, kann an Praeferenz angepasst werden) + if domain_part.startswith("www."): + # Stellen Sie sicher, dass die Domain nach dem Entfernen nicht leer ist (z.B. nur "www.") + domain_part = domain_part[4:] + + # Einfache Pruefung auf mindestens einen Punkt (Basic TLD check) + # Stellen Sie auch sicher, dass der Domain-Teil nach Bearbeitung nicht leer ist. + # Eine einfache Pruefung auf das Vorhandensein eines Punktes und alphabetische Zeichen am Ende der TLD. + if domain_part and '.' in domain_part: + # Pruefen Sie, ob der Teil nach dem letzten Punkt (die TLD) aus Buchstaben besteht und mindestens 2 Zeichen lang ist. + parts = domain_part.split('.') + if len(parts) > 1 and parts[-1].isalpha() and len(parts[-1]) >= 2: + # Rueckgabe des normalisierten Domain-Teils + return domain_part + else: + # Wenn die TLD-Pruefung fehlschlaegt + logger.debug(f"URL '{url[:100]}...' normalisiert zu '{domain_part}', aber TLD-Pruefung schlug fehl.") + return "k.A." + else: + # Wenn kein Punkt im Domain-Teil ist oder er leer ist + logger.debug(f"URL '{url[:100]}...' normalisiert zu '{domain_part}', enthaelt keinen Punkt oder ist leer.") + return "k.A." + except Exception as e: - logger.error(f"Fehler bei URL-Normalisierung für '{url[:100]}...': {e}") - return "k.A." + # Fange unerwartete Fehler beim Parsen oder Bearbeiten der URL ab + logger.error(f"Fehler bei URL-Normalisierung fuer '{url[:100]}...': {e}") + # Rueckgabe eines Fehlerwerts + return "k.A. (Fehler Normalisierung)" + def normalize_string(s): """Normalisiert Umlaute und Sonderzeichen nach einer definierten Liste.""" + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist if not s or not isinstance(s, str): return "" - # Ersetzungen wie in Teil 3 - replacements = { 'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue', 'ß': 'ss', 'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Å': 'A', 'Æ': 'AE', 'à': 'a', 'á': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'æ': 'ae', 'Ç': 'C', 'ç': 'c', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e', 'Ì': 'I', 'Í': 'I', 'Î': 'I', 'Ï': 'I', 'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i', 'Ñ': 'N', 'ñ': 'n', 'Ò': 'O', 'Ó': 'O', 'Ô': 'O', 'Õ': 'O', 'Ø': 'O', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', 'ø': 'o', 'Œ': 'OE', 'œ': 'oe', 'Š': 'S', 'š': 's', 'Ž': 'Z', 'ž': 'z', 'Ý': 'Y', 'ý': 'y', 'ÿ': 'y', 'Đ': 'D', 'đ': 'd', 'č': 'c', 'Č': 'C', 'ć': 'c', 'Ć': 'C', 'ł': 'l', 'Ł': 'L', 'ğ': 'g', 'Ğ': 'G', 'ş': 's', 'Ş': 'S', 'ă': 'a', 'Ă': 'A', 'ı': 'i', 'İ': 'I', 'ň': 'n', 'Ň': 'N', 'ř': 'r', 'Ř': 'R', 'ő': 'o', 'Ő': 'O', 'ű': 'u', 'Ű': 'U', 'ț': 't', 'Ț': 'T', 'ș': 's', 'Ș': 'S' } - # unicodedata Normalisierung zuerst - try: s = unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode('ascii') - except: pass - # Manuelle Ersetzungen - for src, target in replacements.items(): s = s.replace(src, target) + # Ersetzungen fuer gaengige deutsche Umlaute und Sonderzeichen + replacements = { 'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue', 'ß': 'ss', 'ä': 'ae', 'ö': 'oe', 'ü': 'ue', + 'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Å': 'A', 'Æ': 'AE', 'à': 'a', 'á': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'æ': 'ae', + 'Ç': 'C', 'ç': 'c', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e', + 'Ì': 'I', 'Í': 'I', 'Î': 'I', 'Ï': 'I', 'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i', 'Ñ': 'N', 'ñ': 'n', + 'Ò': 'O', 'Ó': 'O', 'Ô': 'O', 'Õ': 'O', 'Ø': 'O', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', 'ø': 'o', 'Œ': 'OE', 'œ': 'oe', + 'Š': 'S', 'š': 's', 'Ž': 'Z', 'ž': 'z', 'Ý': 'Y', 'ý': 'y', 'ÿ': 'y', 'Đ': 'D', 'đ': 'd', + 'č': 'c', 'Č': 'C', 'ć': 'c', 'Ć': 'C', 'ł': 'l', 'Ł': 'L', 'ğ': 'g', 'Ğ': 'G', 'ş': 's', 'Ş': 'S', + 'ă': 'a', 'Ă': 'A', 'ı': 'i', 'İ': 'I', 'ň': 'n', 'Ň': 'N', 'ř': 'r', 'Ř': 'R', 'ő': 'o', 'Ő': 'O', 'ű': 'u', 'Ű': 'U', 'ț': 't', 'Ț': 'T', 'ș': 's', 'Ș': 'S' + } + # Versuche unicodedata Normalisierung zuerst, um Akzente etc. zu entfernen, die nicht in der Liste sind + try: + # 'NFKD' zerlegt Zeichen in ihre Basisform + kombinierende Zeichen (z.B. 'ä' -> 'a', '¨'). + # .encode('ascii', 'ignore') entfernt dann die kombinierenden Zeichen. + s = unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode('ascii') + except Exception as e: + # Logge Fehler bei der unicodedata Normalisierung, aber fahre mit manuellen Ersetzungen fort + logger.debug(f"Fehler bei unicodedata Normalisierung fuer '{str(s)[:50]}...': {e}") + pass # Faert mit dem urspruenglichen String fort, wenn unicodedata fehlschlaegt + + # Dann manuelle Ersetzungen fuer spezifische Faelle (wie Umlaute -> Ae etc.) + for src, target in replacements.items(): + s = s.replace(src, target) + return s + def clean_text(text): - """Bereinigt Text (Unicode, Referenzen, Whitespace, etc.).""" - if text is None: return "k.A." + """ + Bereinigt Text (Unicode, Referenzen, Whitespace, etc.) von Wikipedia, Websites etc. + Entfernt gaengige unerwuenschte Muster wie [1], [Bearbeiten]. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + if text is None: return "k.A." # Behandle None explizit try: - text = str(text) - if not text.strip(): return "k.A." + text = str(text) # Sicherstellen, dass es ein String ist + if not text.strip(): return "k.A." # Leere oder nur Whitespace-Strings + + # Normalisiert Whitespace, Ligaturen etc. (NFC ist oft ein guter Kompromiss) text = unicodedata.normalize("NFC", text) - text = re.sub(r'\[\d+\]', '', text) # [1], [2] - text = re.sub(r'\[\s*Bearbeiten\s*\|\s*Quelltext bearbeiten\s*\]', '', text, flags=re.IGNORECASE) # [Bearbeiten | Quelltext bearbeiten] - text = re.sub(r'\s+', ' ', text).strip() # Multiple Whitespace zu Single Space + + # Entfernt Referenz-Tags wie [1], [2] + text = re.sub(r'\[\d+\]', '', text) + # Entfernt gaengige Wikipedia-Bearbeitungslinks + text = re.sub(r'\[\s*Bearbeiten\s*\|\s*Quelltext bearbeiten\s*\]', '', text, flags=re.IGNORECASE) + # Entfernt Koordinaten-Spans, die manchmal im Text auftauchen koennen + text = re.sub(r'\[koordinaten\]', '', text, flags=re.IGNORECASE) + + + # Ersetzt multiple Leerzeichen/Tabs/Newlines durch ein einzelnes Leerzeichen + text = re.sub(r'\s+', ' ', text).strip() + + # Wenn nach Bereinigung leer, gib k.A. zurueck return text if text else "k.A." except Exception as e: - logger.error(f"Fehler bei clean_text für Input '{str(text)[:50]}...': {e}") - return "k.A." + # Fehlermeldung beim Bereinigen + logger.error(f"Fehler bei clean_text fuer Input '{str(text)[:50]}...': {e}") + return "k.A. (Fehler Bereinigung)" + def normalize_company_name(name): - """Entfernt gängige Rechtsformzusätze etc. für Vergleiche.""" + """ + Entfernt gaengige Rechtsformzusaetze etc. fuer Vergleiche. + Nutzt clean_text und normalize_string. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist if not name: return "" + # Vorab bereinigen und normalisieren (Umlaute etc.) name = clean_text(name) - forms = [ r'gmbh', r'ges\.?\s*m\.?\s*b\.?\s*h\.?', r'gesellschaft mit beschränkter haftung', r'ug', r'u\.g\.', r'unternehmergesellschaft', r'haftungsbeschränkt', r'ag', r'a\.g\.', r'aktiengesellschaft', r'ohg', r'o\.h\.g\.', r'offene handelsgesellschaft', r'kg', r'k\.g\.', r'kommanditgesellschaft', r'gmbh\s*&\s*co\.?\s*kg', r'ges\.?\s*m\.?\s*b\.?\s*h\.?\s*&\s*co\.?\s*k\.g\.?', r'ag\s*&\s*co\.?\s*kg', r'a\.g\.?\s*&\s*co\.?\s*k\.g\.?', r'e\.k\.', r'e\.kfm\.', r'e\.kfr\.', r'eingetragene[rn]? kauffrau', r'eingetragene[rn]? kaufmann', r'ltd\.?', 'limited', r'ltd\s*&\s*co\.?\s*kg', r's\.?a\.?r\.?l\.?', 'sàrl', 'sagl', r's\.?a\.?', 'société anonyme', 'sociedad anónima', r's\.?p\.?a\.?', 'società per azioni', r'b\.?v\.?', 'besloten vennootschap', r'n\.?v\.?', 'naamloze vennootschap', r'plc\.?', 'public limited company', 'inc', 'incorporated', r'corp\.?', 'corporation', 'llc', 'limited liability company', r'kgaa', r'kommanditgesellschaft auf aktien', 'se', 'societas europaea', r'e\.?g\.?', 'eingetragene genossenschaft', 'genossenschaft', 'genmbh', r'e\.?v\.?', 'eingetragener verein', 'verein', 'stiftung', 'ggmbh', r'gemeinnützige gmbh', r'gemeinnützige[rn]? gmbh', 'gug', 'partg', 'partnerschaftsgesellschaft', 'partgmbb', 'og', r'o\.g\.', 'offene gesellschaft', r'e\.u\.', 'eingetragenes unternehmen', r'ges\.?n\.?b\.?r\.?', r'gesellschaft nach bürgerlichem recht', 'kollektivgesellschaft', 'einzelfirma', 'gruppe', 'holding', 'international', 'systeme', 'technik', 'logistik', 'solutions', 'services', 'management', 'consulting', 'produktion', 'vertrieb', 'entwicklung', 'maschinenbau', 'anlagenbau' + name = normalize_string(name) + + # Liste von Rechtsformen und generischen Zusaetzen (case-insensitive) + # Verwenden Sie raw strings r'' fuer die Regex-Patterns + forms = [ + r'gmbh', r'ges\.?\s*m\.?\s*b\.?\s*h\.?', r'gesellschaft mit beschraenkter haftung', + r'ug', r'u\.g\.', r'unternehmergesellschaft', r'haftungsbeschraenkt', + r'ag', r'a\.g\.', r'aktiengesellschaft', + r'ohg', r'o\.h\.g\.', r'offene handelsgesellschaft', + r'kg', r'k\.g\.', r'kommanditgesellschaft', + r'gmbh\s*&\s*co\.?\s*kg', r'ges\.?\s*m\.?\s*b\.?\s*h\.?\s*&\s*co\.?\s*k\.g\.?', + r'ag\s*&\s*co\.?\s*kg', r'a\.g\.?\s*&\s*co\.?\s*k\.g\.?', + r'e\.k\.?', r'e\.kfm\.?', r'e\.kfr\.?', r'eingetragene[rn]? kauffrau', r'eingetragene[rn]? kaufmann', + r'ltd\.?', r'limited', + r'ltd\s*&\s*co\.?\s*kg', + r's\.?a\.?r\.?l\.?', r'sarl', r'sagl', # CH/LU/etc. SARL, It. S.a.g.l. + r's\.?a\.?', r'societe anonyme', r'sociedad anonima', # Franz/Span. SA + r's\.?p\.?a\.?', r'societa per azioni', # It. SpA + r'b\.?v\.?', r'besloten vennootschap', # NL BV + r'n\.?v\.?', r'naamloze vennootschap', # NL NV + r'plc\.?', r'public limited company', # UK Plc + r'inc\.?', r'incorporated', # US Inc + r'corp\.?', r'corporation', # US Corp + r'llc\.?', r'limited liability company', # US LLC + r'kgaa', r'kommanditgesellschaft auf aktien', # DE KGaA + r'se', r'societas europaea', # SE (Europa) + r'e\.?g\.?', r'eingetragene genossenschaft', r'genossenschaft', r'genmbh', # DE eG + r'e\.?v\.?', r'eingetragener verein', r'verein', # DE eV + r'stiftung', r'ggmbh', r'gemeinnuetzige gmbh', r'gemeinnuetzige[rn]? gmbh', r'gug', # DE Stiftungen, gemeinnuetzige + r'partg\.?', r'partnerschaftsgesellschaft', r'partgmbb', # DE PartG + r'og', r'o\.g\.', r'offene gesellschaft', # AT OG + r'e\.u\.', r'eingetragenes unternehmen', # AT EU + r'ges\.?n\.?b\.?r\.?', r'gesellschaft nach buergerlichem recht', # DE GbR + r'kollektivgesellschaft', r'einzelfirma', # CH + # Zusaetzliche generische Begriffe am Ende (koennen auch Firmenbestandteile sein, daher Vorsicht) + r'gruppe', r'holding', r'international', r'systeme', r'technik', r'logistik', + r'solutions', r'services', r'management', r'consulting', r'produktion', + r'vertrieb', r'entwicklung', r'maschinenbau', r'anlagenbau', + r'engineering', r'technologie' # Weitere gaengige Begriffe ] - # Pattern für ganze Wörter (case-insensitive) - # Fügen Sie \b hinzu, um sicherzustellen, dass ganze Wörter gematcht werden (z.B. nicht "ag" in "manage") - # Bereinigen Sie die Formen vor dem Join (z.B. re.escape für Sonderzeichen in den Formen) + # Pattern fuer ganze Woerter (case-insensitive), escaped um Regex-Sonderzeichen zu behandeln forms_escaped = [re.escape(form) for form in forms] - pattern = r'\b(?:' + '|'.join(forms_escaped) + r')\b' # ?: für non-capturing group + # \b fuer Wortgrenzen, (?:...) fuer non-capturing group + pattern = r'\b(?:' + '|'.join(forms_escaped) + r')\b' normalized = re.sub(pattern, '', name, flags=re.IGNORECASE) - # Interpunktion entfernen/ersetzen (außer evtl. &) + # Interpunktion entfernen/ersetzen (ausser evtl. &) + # Entferne Punkt, Komma, Semikolon, Doppelpunkt normalized = re.sub(r'[.,;:]', '', normalized) - normalized = re.sub(r'[\-–/]', ' ', normalized) # Bindestriche etc. durch Leerzeichen ersetzen - normalized = re.sub(r'\s+', ' ', normalized).strip() # Multiple Leerzeichen reduzieren + # Ersetze Bindestriche, Gedankenstriche, Schraegstriche durch ein einzelnes Leerzeichen + normalized = re.sub(r'[\-–/]', ' ', normalized) + # Reduziere multiple Leerzeichen auf ein einzelnes und trimme Enden + normalized = re.sub(r'\s+', ' ', normalized).strip() return normalized.lower() + def fuzzy_similarity(str1, str2): - """Berechnet Ähnlichkeit zwischen 0 und 1 (case-insensitive).""" + """Berechnet Aehnlichkeit zwischen 0 und 1 (case-insensitive).""" + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist if not str1 or not str2: return 0.0 # Sicherstellen, dass beide Inputs Strings sind return SequenceMatcher(None, str(str1).lower(), str(str2).lower()).ratio() +# ============================================================================== +# Ende Text, String & URL Utilities Block +# ============================================================================== + +# ============================================================================== +# GLOBALE HELPER FUNCTIONS (PART 3: Numeric Extraction Utilities) +# ============================================================================== # --- Numerische Extraktion --- -# Übernommen aus Ihrem Code (Teil 4 & Teil 2), leicht angepasst für Logger und Konsistenz. +# Basierend auf Code aus Teil 4 & Teil 2. +# Extrahiert und normalisiert Zahlenwerte aus Strings. +# Nutzt globale Helfer: clean_text, re. def extract_numeric_value(raw_value, is_umsatz=False): """ Extrahiert und normalisiert Zahlenwerte (Umsatz in Mio, Mitarbeiter). Berücksichtigt Tausendertrenner (Punkt, Apostroph), Dezimaltrenner (Komma), Einheiten (Tsd, Mio, Mrd) - und gängige Präfixe/Suffixe. Gibt "k.A." zurück, wenn nicht extrahierbar oder <= 0. + und gaengige Praefixe/Suffixe. Gibt "k.A." zurueck, wenn nicht extrahierbar oder <= 0. """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist if not raw_value: return "k.A." raw_value_str = str(raw_value).strip() + # Pruefe auf bekannte "keine Angabe" Strings oder 0 als Text if not raw_value_str or raw_value_str.lower() in ['k.a.', 'n/a', '-']: - return "k.A." # 0 ist hier wie k.A. + return "k.A." # 0 als Text ist hier wie k.A. - # Bereinigungsschritte wie in clean_text und vorheriger Implementierung + + # Bereinigungsschritte aehnlich wie in clean_text und vorheriger Implementierung processed_value = clean_text(raw_value_str) - if processed_value == "k.A.": return "k.A." + if processed_value == "k.A." or processed_value.lower() in ['k.a.', 'n/a', '-']: + return "k.A." # Pruefe erneut nach clean_text - # logger.debug(f"extract_numeric_value: Verarbeite Wert: '{raw_value_str}' -> '{processed_value}' (is_umsatz={is_umsatz})") # Zu viel Lärm - # Entferne gängige Präfixe/Suffixe und Spannen-Trennzeichen - processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|über|under|mehr als|weniger als|bis zu)\s+', '', processed_value) - processed_value = re.sub(r'[€$£¥]', '', processed_value).strip() + # logger.debug(f"extract_numeric_value: Verarbeite Wert: '{raw_value_str}' -> '{processed_value}' (is_umsatz={is_umsatz})") # Zu viel Laerm im Debug + + # Entferne gaengige Praefixe/Suffixe und Spannen-Trennzeichen (z.B. "ca. ", "ueber ", "100-200", "€") + processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|ueber|unter|mehr als|weniger als|bis zu)\s+', '', processed_value) # Beruecksichtige Umlaute (ue) + processed_value = re.sub(r'[€$£¥]', '', processed_value).strip() # Entferne Waehrungssymbole processed_value = re.split(r'\s*(-|–|bis)\s*', processed_value, 1)[0].strip() # Nimm nur den ersten Teil bei Spannen - # Entferne Tausendertrenner (Punkt, Apostroph) und ersetze Komma durch Punkt für Dezimal + + # Entferne Tausendertrenner (Punkt, Apostroph) und ersetze Komma durch Punkt fuer Dezimal + # Reihenfolge ist wichtig: Erst Punkte/Apostrophe entfernen, dann Komma durch Punkt ersetzen processed_value_no_thousands = processed_value.replace('.', '').replace("'", "") processed_value_final = processed_value_no_thousands.replace(',', '.') + # Finde die erste Sequenz von Ziffern und Punkten (die Zahl) match = re.search(r'([\d.]+)', processed_value_final) if not match: + # Wenn nach der Bereinigung keine Ziffern oder Punkte gefunden werden logger.debug(f"extract_numeric_value: Keine numerischen Zeichen gefunden nach Bereinigung von: '{raw_value_str}'") return "k.A." num_str = match.group(1) try: - if not num_str or num_str == '.' or num_str.endswith('.'): # Zusätzliche Prüfungen - raise ValueError("Leerer oder ungültiger Zahlenstring gefunden") + # Zusaetzliche Pruefungen auf ungueltige Zahlenstrings (z.B. nur '.', '..', '.1.') + if not num_str or num_str == '.' or num_str.count('.') > 1: + # Wenn der String leer ist, nur ein Punkt, oder mehr als ein Dezimalpunkt hat + raise ValueError("Leerer oder ungueltiger Zahlenstring gefunden nach Regex Match") + # Konvertiere den extrahierten String zu einem Float num = float(num_str) except ValueError as e: + # Wenn die Konvertierung zu Float fehlschlaegt logger.debug(f"Fehler bei Float-Umwandlung des extrahierten Strings '{num_str}' (aus '{raw_value_str}'): {e}") return "k.A." # --- Einheiten-Skalierung basierend auf ORIGINALSTRING --- + # Pruefe den originalen Wert (kleingeschrieben) auf Einheiten-Keywords original_lower = raw_value_str.lower() multiplier = 1.0 + # Pruefe auf Mrd, Mio, Tsd Keywords und setze den entsprechenden Multiplikator if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower): multiplier = 1000000000.0 - # logger.debug(" -> Einheit: Mrd gefunden") - elif re.search(r'\bmio\s*\b|\bmillionen\s*\b|\bmill.\s*\b', original_lower): + # logger.debug(" -> Einheit: Mrd gefunden") # Zu viel Laerm im Debug + elif re.search(r'\bmio\s*\b|\bmillionen\s*\b|\bmill[.]?\s*\b', original_lower): # Beruecksichtige "mill." multiplier = 1000000.0 # logger.debug(" -> Einheit: Mio gefunden") elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): multiplier = 1000.0 # logger.debug(" -> Einheit: Tsd gefunden") + # Wende den Multiplikator auf die Zahl an num = num * multiplier # Konvertiere zu Zielformat und runde ggf. - # Rückgabe als String, wie im Sheet erwartet + # Rueckgabe als String, wie im Sheet erwartet + # Stellen Sie sicher, dass nur positive Werte zurueckgegeben werden (<= 0 ist wie k.A.) if is_umsatz: # Umsatz wird in Millionen € gespeichert (gerundet auf ganze Mio) - # Rückgabe als String + # Rueckgabe als String umsatz_mio = round(num / 1000000.0) - return str(int(umsatz_mio)) if umsatz_mio > 0 else "k.A." # Nur positive Ergebnisse + return str(int(umsatz_mio)) if umsatz_mio > 0 else "k.A." # Nur positive Ergebnisse > 0 else: # Mitarbeiterzahl wird als ganze Zahl gespeichert (gerundet) - # Rückgabe als String + # Rueckgabe als String mitarbeiter_int = round(num) - return str(int(mitarbeiter_int)) if mitarbeiter_int > 0 else "k.A." # Nur positive Ergebnisse + return str(int(mitarbeiter_int)) if mitarbeiter_int > 0 else "k.A." # Nur positive Ergebnisse > 0 -# --- Numerische Extraktion für FILTERLOGIK (gibt 0 statt k.A. zurück) --- -# Übernommen aus Ihrem Code (Teil 2), leicht angepasst für Logger und Konsistenz mit extract_numeric_value. +# --- Numerische Extraktion fuer FILTERLOGIK (gibt 0 statt k.A. zurueck) --- +# Basierend auf Code aus Teil 2. +# Extrahiert und normalisiert Zahlenwerte fuer Vergleichslogik. +# Nutzt globale Helfer: clean_text, re. def get_numeric_filter_value(value_str, is_umsatz=False): """ - Extrahiert und normalisiert Zahlenwerte für die Filterlogik (Umsatz in Mio, Mitarbeiter int). - Gibt 0.0 (für Umsatz) oder 0 (für Mitarbeiter) zurück, wenn der Wert leer, k.A., nicht numerisch ist, oder 0 ergibt. - Beachtet Einheiten (Tsd, Mio, Mrd) für Umsatz. + Extrahiert und normalisiert Zahlenwerte fuer die Filterlogik (Umsatz in Mio, Mitarbeiter int). + Gibt 0.0 (fuer Umsatz) oder 0 (fuer Mitarbeiter) zurueck, wenn der Wert leer, k.A., nicht numerisch ist, oder 0 ergibt. + Beachtet Einheiten (Tsd, Mio, Mrd) fuer Umsatz. """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist if value_str is None or pd.isna(value_str) or str(value_str).strip() == '': - return 0.0 if is_umsatz else 0 # Leer oder k.A. -> 0 + # Gibt 0 (int/float) zurueck, nicht "k.A." fuer Filterlogik + return 0.0 if is_umsatz else 0 raw_value_str = str(value_str).strip() + # Pruefe auf bekannte "keine Angabe" Strings if raw_value_str.lower() in ['k.a.', 'n/a', '-']: return 0.0 if is_umsatz else 0 + try: processed_value = clean_text(raw_value_str) - if processed_value == "k.A.": return 0.0 if is_umsatz else 0 + if processed_value == "k.A." or processed_value.lower() in ['k.a.', 'n/a', '-']: + return 0.0 if is_umsatz else 0 # Pruefe erneut nach clean_text - processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|über|under|mehr als|weniger als|bis zu)\s+', '', processed_value) + + # Entferne gaengige Praefixe/Suffixe und Spannen-Trennzeichen + processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|ueber|unter|mehr als|weniger als|bis zu)\s+', '', processed_value) # Beruecksichtige Umlaute (ue) processed_value = re.sub(r'[€$£¥]', '', processed_value).strip() - processed_value = re.split(r'\s*(-|–|bis)\s*', processed_value, 1)[0].strip() + processed_value = re.split(r'\s*(-|–|bis)\s*', processed_value, 1)[0].strip() # Nimm nur den ersten Teil bei Spannen + + # Entferne Tausendertrenner (Punkt, Apostroph) und ersetze Komma durch Punkt fuer Dezimal processed_value_no_thousands = processed_value.replace('.', '').replace("'", "") processed_value_final = processed_value_no_thousands.replace(',', '.') + + # Finde die erste Sequenz von Ziffern und Punkten (die Zahl) match = re.search(r'([\d.]+)', processed_value_final) if not match: + # Wenn nach der Bereinigung keine Ziffern oder Punkte gefunden werden + # logger.debug(f"get_numeric_filter_value: Keine numerischen Zeichen gefunden nach Bereinigung von: '{raw_value_str}'") # Zu viel Laerm im Debug return 0.0 if is_umsatz else 0 num_str = match.group(1) - if not num_str or num_str == '.' or num_str.endswith('.'): return 0.0 if is_umsatz else 0 + try: + # Zusaetzliche Pruefungen auf ungueltige Zahlenstrings + if not num_str or num_str == '.' or num_str.count('.') > 1: + raise ValueError("Leerer oder ungueltiger Zahlenstring gefunden nach Regex Match") + # Konvertiere zu Float + num = float(num_str) + except ValueError as e: + # Wenn die Konvertierung zu Float fehlschlaegt + # logger.debug(f"Fehler bei Float-Umwandlung des extrahierten Strings '{num_str}' (aus '{raw_value_str}'): {e}") # Zu viel Laerm im Debug + return 0.0 if is_umsatz else 0 - num = float(num_str) # --- Einheiten-Skalierung basierend auf ORIGINALSTRING --- - original_lower = raw_value_str.lower() - multiplier = 1.0 + # Ziel: Den Wert in die Einheit des Schwellenwerts konvertieren (Mio fuer Umsatz, Integer fuer MA). + original_lower = raw_value_str.lower(); multiplier = 1.0 - if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower): - multiplier = 1000000000.0 - elif re.search(r'\bmio\s*\b|\bmillionen\s*\b|\bmill.\s*\b', original_lower): - multiplier = 1000000.0 - elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): - multiplier = 1000.0 + if is_umsatz: # Umsatz (Schwellenwert in Mio) + if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower): + num = num * 1000.0 # Konvertiere von Mrd zu Mio + elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): + num = num / 1000.0 # Konvertiere von Tsd zu Mio + # Wenn "Mio" oder keine Einheit, nehme num direkt (wird als Mio interpretiert) - num = num * multiplier + else: # Mitarbeiterzahl (Schwellenwert ist Integer) + if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower): + num = num * 1000000000.0 # Konvertiere von Mrd zu Integer + elif re.search(r'\bmio\s*\b|\bmillionen\s*\b|\bmill[.]?\s*\b', original_lower): # Beruecksichtige "mill." + num = num * 1000000.0 # Konvertiere von Mio zu Integer + elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): + num = num * 1000.0 # Konvertiere von Tsd zu Integer + # Wenn keine Einheit, nehme num direkt (wird als Integer interpretiert) - # Das Ergebnis muss 0 oder positiv sein für die Filterlogik - result_num = num if num > 0 else 0 # Werte <= 0 zählen nicht + # Das Ergebnis muss 0 oder positiv sein fuer die Filterlogik + result_num = num if num > 0 else 0 # Werte <= 0 zaehlen nicht if is_umsatz: - # Rückgabe als Wert in Millionen (Float) + # Rueckgabe als Wert in Millionen (Float) return result_num / 1000000.0 else: # Mitarbeiterzahl - # Rückgabe als ganze Zahl + # Rueckgabe als ganze Zahl return round(result_num) except Exception as e: - logger.debug(f"Fehler in get_numeric_filter_value für Wert '{raw_value_str[:50]}...': {e}") + # Fange unerwartete Fehler ab und logge sie + logger.debug(f"Fehler in get_numeric_filter_value fuer Wert '{raw_value_str[:50]}...': {e}") + # Rueckgabe 0 bei Fehler fuer Filterlogik return 0.0 if is_umsatz else 0 +# ============================================================================== +# Ende Numerische Extraktion Utilities Block +# ============================================================================== + +# ============================================================================== +# GLOBALE HELPER FUNCTIONS (PART 4: Gender & Email Utilities) +# ============================================================================== + # --- Gender und Email Helpers --- -# Übernommen aus Ihrem Code (Teil 4), leicht angepasst für Logger. +# Basierend auf Code aus Teil 4. +# Nutzt globale Helfer: gender_guesser, Config.API_KEYS, requests, retry_on_failure, +# simple_normalize_url, normalize_string, re. + # Annahme: gender_guesser ist installiert -# Initialisieren Sie den Detector einmal global +# Initialisieren Sie den Detector einmal global, um Ressourcen zu sparen try: + import gender_guesser.detector as gender gender_detector = gender.Detector() logger.debug("gender_guesser.Detector initialisiert.") except ImportError: + gender = None # Setzen Sie den Namen auf None, falls der Import fehlschlaegt gender_detector = None logger.warning("gender_guesser Bibliothek nicht gefunden. Geschlechtserkennung deaktiviert.") except Exception as e: + gender = None # Setzen Sie den Namen auf None, falls der Import fehlschlaegt gender_detector = None logger.error(f"Fehler bei Initialisierung von gender_guesser: {e}. Geschlechtserkennung deaktiviert.") def get_gender(firstname): """Ermittelt Geschlecht via gender-guesser und Fallback Genderize API.""" + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist if not firstname or not isinstance(firstname, str): return "unknown" - firstname_clean = firstname.strip().split(" ")[0] # Nur den ersten Teil des Vornamens + # Nehmen Sie nur den ersten Teil des Vornamens und bereinigen Sie ihn + firstname_clean = str(firstname).strip().split(" ")[0] if not firstname_clean: return "unknown" # 1. Versuch: gender-guesser (nutzt globale Instanz) result_gg = "unknown" - if gender_detector: + if gender_detector: # Pruefe, ob der Detector initialisiert wurde try: + # get_gender ist case-insensitive per Default result_gg = gender_detector.get_gender(firstname_clean) - # logger.debug(f"GenderGuesser für '{firstname_clean}': {result_gg}") # Zu viel Lärm + # logger.debug(f"GenderGuesser fuer '{firstname_clean}': {result_gg}") # Zu viel Laerm except Exception as e_gg: - logger.warning(f"Fehler bei gender-guesser für '{firstname_clean}': {e_gg}") + logger.warning(f"Fehler bei gender-guesser fuer '{firstname_clean}': {e_gg}") result_gg = "unknown" # Fallback bei Fehler # 2. Fallback: Genderize API (nur wenn gender-guesser unsicher ist) + # Wenn gender-guesser ein unsicheres Ergebnis liefert ("andy", "unknown", "mostly_...") if result_gg in ["andy", "unknown", "mostly_male", "mostly_female"]: genderize_key = Config.API_KEYS.get('genderize') + # Nur versuchen, wenn der API Key verfuegbar ist if not genderize_key: - # logger.debug("Genderize API-Schlüssel nicht verfügbar, Fallback nicht möglich.") # Zu viel Lärm - return result_gg if result_gg.startswith("mostly_") else "unknown" # Gib bestenfalls mostly zurück + # logger.debug("Genderize API-Schluessel nicht verfuegbar, Fallback nicht moeglich.") # Zu viel Laerm + # Geben Sie das Ergebnis von gender-guesser zurueck, wenn es "mostly_" war, sonst "unknown" + return result_gg if result_gg.startswith("mostly_") else "unknown" # API Call nutzt den retry_on_failure Decorator + # Definiere die Funktion hier, da sie spezifisch fuer diesen Anwendungsfall ist @retry_on_failure def call_genderize(name, api_key): params = {"name": name, "apikey": api_key, "country_id": "DE"} # DE als Standardland - # logger.debug(f"Genderize API-Anfrage für '{name}'...") # Zu viel Lärm - response = requests.get("https://api.genderize.io", params=params, timeout=5) # Kurzer Timeout - response.raise_for_status() # Wirft HTTPError für schlechte Antworten + # logger.debug(f"Genderize API-Anfrage fuer '{name}'...") # Zu viel Laerm im Debug + # Fuehrt die GET-Anfrage aus. Der retry_on_failure Decorator behandelt RequestsExceptions. + response = requests.get("https://api.genderize.io", params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) # Nutze Config Timeout + # Wirft HTTPError fuer schlechte Antworten (4xx oder 5xx), wird vom Decorator gefangen + response.raise_for_status() + # Gibt die JSON-Antwort zurueck data = response.json() - # logger.debug(f" -> Genderize Antwort: {data}") # Zu viel Lärm + # logger.debug(f" -> Genderize Antwort fuer '{name}': {data}") # Zu viel Laerm im Debug return data try: + # Rufen Sie die API-Wrapper-Funktion auf genderize_data = call_genderize(firstname_clean, genderize_key) + # Extrahieren Sie die Daten aus der API-Antwort api_gender = genderize_data.get("gender") probability = genderize_data.get("probability", 0) - count = genderize_data.get("count", 0) # Anzahl der Datenpunkte für diesen Namen + count = genderize_data.get("count", 0) # Anzahl der Datenpunkte fuer diesen Namen - # Nur bei ausreichender Sicherheit und wenn Genderize ein Ergebnis liefert - # Prüfen Sie auch die Anzahl der Datenpunkte (count > 0) - if api_gender and probability is not None and probability > 0.7 and count > 0: - logger.debug(f" -> Übernehme Genderize Ergebnis '{api_gender}' (Prob: {probability}, Count: {count}) für '{firstname_clean}'") - return api_gender + # Nur bei ausreichender Sicherheit (z.B. Wahrscheinlichkeit > 0.7) und wenn Genderize ein Ergebnis liefert + # und Datenpunkte vorhanden sind (count > 0). + if api_gender and probability is not None and probability > 0.7 and count is not None and count > 0: + # Loggen Sie die Uebernahme des API-Ergebnisses + logger.debug(f" -> Uebernehme Genderize Ergebnis '{api_gender}' (Prob: {probability}, Count: {count}) fuer '{firstname_clean}'") + return api_gender # Geben Sie das API-Ergebnis zurueck else: - # logger.debug(f" -> Genderize unsicher/kein Ergebnis. Nutze Fallback: '{result_gg}'") # Zu viel Lärm + # Wenn Genderize unsicher ist oder kein valides Ergebnis liefert + # logger.debug(f" -> Genderize unsicher/kein Ergebnis fuer '{firstname_clean}'. Nutze Fallback: '{result_gg}'") # Zu viel Laerm + # Geben Sie das Ergebnis von gender-guesser zurueck, wenn es "mostly_" war, sonst "unknown" return result_gg if result_gg.startswith("mostly_") else "unknown" except Exception as e: # retry_on_failure wirft Exception im Fehlerfall erneut - logger.error(f"FEHLER bei der Genderize API-Anfrage für '{firstname_clean}': {e}") + # Wenn der API Call nach Retries fehlschlaegt, fangen wir die Exception hier. + # Der Fehler wird bereits vom retry_on_failure Decorator geloggt. + logger.error(f"FEHLER bei der Genderize API-Anfrage fuer '{firstname_clean}' nach Retries: {e}") + # Geben Sie das Ergebnis von gender-guesser als Fallback zurueck return result_gg if result_gg.startswith("mostly_") else "unknown" else: + # Wenn gender-guesser sicher war ("male", "female"), geben Sie das Ergebnis direkt zurueck return result_gg + def get_email_address(firstname, lastname, website): - """Generiert E-Mail: vorname.nachname@domain.tld.""" - if not all([firstname, lastname, website]) or not all(isinstance(x, str) for x in [firstname, lastname, website]): - return "" + """ + Generiert eine moegliche E-Mail-Adresse im Format vorname.nachname@domain.tld. + Normalisiert Namen und extrahiert die Domain aus der Website-URL. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + if not all([firstname, lastname, website]) or not all(isinstance(x, str) and x.strip() for x in [firstname, lastname, website]): # Pruefen Sie auf nicht-leere Strings + logger.debug("get_email_address skipped: Fehlende oder ungueltige Eingabe (Name, Website).") + return "" # Gebe leeren String bei fehlenden/ungueltigen Eingaben zurueck + # Extrahieren und normalisieren Sie die Domain aus der Website-URL (nutzt globale Helfer) domain = simple_normalize_url(website) - if domain == "k.A." or not '.' in domain: return "" + # Wenn die Domain nicht gueltig ist oder keinen Punkt enthaelt (was fuer eine E-Mail-Domain notwendig ist) + if domain == "k.A." or '.' not in domain: + logger.debug(f"get_email_address skipped: Ungueltige Domain extrahiert aus '{website}'.") + return "" # Gebe leeren String bei ungueltiger Domain zurueck - # Normalisiere Vor- und Nachname, Kleinbuchstaben, nur erlaubte Zeichen + + # Normalisiere Vor- und Nachname (nutzt globale Helfer), konvertiere zu Kleinbuchstaben normalized_first = normalize_string(firstname).lower() normalized_last = normalize_string(lastname).lower() - # Ersetze Leerzeichen und mehrere Bindestriche durch einen einzelnen + # Ersetze Leerzeichen und mehrere Bindestriche durch einen einzelnen Bindestrich normalized_first = re.sub(r'\s+', '-', normalized_first) normalized_last = re.sub(r'\s+', '-', normalized_last) - # Erlauben: alphanumerische Zeichen, Bindestrich - # Entfernen Sie alle Zeichen, die NICHT alphanumerisch oder Bindestrich sind + # Entferne alle Zeichen, die NICHT alphanumerisch ('\w') oder Bindestrich ('-') sind + # Dies stellt sicher, dass die generierte E-Mail-Adresse gueltige Zeichen enthaelt normalized_first = re.sub(r'[^\w\-]+', '', normalized_first) normalized_last = re.sub(r'[^\w\-]+', '', normalized_last) - # Entferne führende/endende Bindestriche, falls nach Bereinigung entstanden + # Entferne fuehrende oder endende Bindestriche, falls sie nach der Bereinigung entstanden sind normalized_first = normalized_first.strip('-') normalized_last = normalized_last.strip('-') - + # Stellen Sie sicher, dass sowohl Vor- als auch Nachname nicht leer sind nach der Bereinigung if normalized_first and normalized_last and domain: - return f"{normalized_first}.{normalized_last}@{domain}" + # Kombinieren Sie die bereinigten Teile zur E-Mail-Adresse + email_address = f"{normalized_first}.{normalized_last}@{domain}" + # logger.debug(f"Generierte E-Mail-Adresse: {email_address}") # Zu viel Laerm im Debug + return email_address else: - return "" + # Wenn Vorname oder Nachname nach der Bereinigung leer sind + logger.debug("get_email_address skipped: Vorname oder Nachname leer nach Bereinigung.") + return "" # Gebe leeren String zurueck + + +# ============================================================================== +# Ende Gender & Email Utilities Block +# ============================================================================== + +# ============================================================================== +# GLOBALE HELPER FUNCTIONS (PART 5: Schema Loading Utility) +# ============================================================================== # --- Schema Loading (Ziel-Branchenschema) --- -# Übernommen aus Ihrem Code (Teil 4), leicht angepasst für Logger. -# Annahmen: BRANCH_MAPPING, TARGET_SCHEMA_STRING, ALLOWED_TARGET_BRANCHES sind globale Variablen -BRANCH_MAPPING = {} # Wird derzeit nicht verwendet, aber beibehalten -TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar." +# Basierend auf Code aus Teil 4. +# Lädt die Liste der erlaubten Zielbranchen aus einer CSV-Datei. +# Nutzt globale Variablen: BRANCH_MAPPING, TARGET_SCHEMA_STRING, ALLOWED_TARGET_BRANCHES. +# Nutzt globale Helfer: csv, os, logger. + +# Globale Variablen für Branch Mapping (werden von load_target_schema() befüllt) +BRANCH_MAPPING = {} # Wird in dieser Version nicht primaer fuer Mapping genutzt, kann aber beibehalten werden +TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar." # String-Repraesentation des Schemas fuer Prompts ALLOWED_TARGET_BRANCHES = [] # Liste der erlaubten Kurzformen def load_target_schema(csv_filepath=BRANCH_MAPPING_FILE): - """Lädt Liste erlaubter Ziel-Branchen (Kurzformen) aus Spalte A der CSV.""" - global BRANCH_MAPPING, TARGET_SCHEMA_STRING, ALLOWED_TARGET_BRANCHES - # BRANCH_MAPPING wird in dieser Version nicht mehr primär verwendet, - # da wir strikt gegen ALLOWED_TARGET_BRANCHES (die Kurzformen) validieren. - BRANCH_MAPPING = {} # Zurücksetzen + """ + Laedt Liste erlaubter Ziel-Branchen (Kurzformen) aus Spalte A der CSV-Datei. + Befuellt die globalen Variablen ALLOWED_TARGET_BRANCHES und TARGET_SCHEMA_STRING. - allowed_branches_set = set() - logger.info(f"Lade Ziel-Schema (Kurzformen) aus '{csv_filepath}' Spalte A...") + Args: + csv_filepath (str, optional): Pfad zur CSV-Datei mit dem Branchenschema. + Defaults to the global BRANCH_MAPPING_FILE. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Zugriff auf die globalen Variablen + global BRANCH_MAPPING, TARGET_SCHEMA_STRING, ALLOWED_TARGET_BRANCHES + + # Setzen Sie BRANCH_MAPPING zurueck, da es in dieser Version nicht primaer genutzt wird + BRANCH_MAPPING = {} + + allowed_branches_set = set() # Nutzt ein Set, um Duplikate automatisch zu behandeln line_count = 0 + + logger.info(f"Lade Ziel-Schema (Kurzformen) aus '{csv_filepath}' Spalte A...") + try: - # Verwenden Sie 'utf-8-sig' für Dateien mit BOM + # Versuche, die Datei mit UTF-8-BOM-Signatur oder normalem UTF-8 zu oeffnen + # Verwenden Sie "r" fuer Textmodus und geben Sie das Encoding an with open(csv_filepath, "r", encoding="utf-8-sig") as f: + # Verwenden Sie den CSV-Reader, um Zeilen zu lesen reader = csv.reader(f) - # Versuche, die erste Zeile als Header zu überspringen + # Versuche, die erste Zeile als Header zu ueberspringen (Heuristik) try: header_row = next(reader) - # logger.debug(f"Überspringe Header-Zeile: {header_row}") # Zu viel Lärm + # logger.debug(f"Ueberspringe Header-Zeile: {header_row}") # Zu viel Laerm im Debug except StopIteration: + # Wenn die Datei leer ist logger.warning(f"Schema-Datei '{csv_filepath}' ist leer.") - ALLOWED_TARGET_BRANCHES = [] - TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar (Datei leer)." - return # Datei leer, nichts zu tun + ALLOWED_TARGET_BRANCHES = [] # Setze die Liste auf leer + TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar (Datei leer)." # Setze Fehler-String + return # Beende die Funktion, da nichts zu tun ist + # Iteriere ueber die verbleibenden Zeilen for row in reader: line_count += 1 - # logger.debug(f"Schema-Laden: Lese Zeile {line_count}: {row}") # Zu viel Lärm + # logger.debug(f"Schema-Laden: Lese Zeile {line_count}: {row}") # Zu viel Laerm im Debug + # Pruefe, ob die Zeile mindestens eine Spalte hat (Spalte A ist Index 0) if len(row) >= 1: - target = row[0].strip() - if target: # Nur nicht-leere Einträge hinzufügen + target = row[0].strip() # Hole den Wert aus Spalte A und entferne Whitespace + if target: # Fuege den Wert zum Set hinzu, wenn er nicht leer ist allowed_branches_set.add(target) - # logger.debug(f" -> '{target}' zum Set hinzugefügt.") # Zu viel Lärm + # logger.debug(f" -> '{target}' zum Set hinzugefuegt.") # Zu viel Laerm im Debug except FileNotFoundError: + # Wenn die Schema-Datei nicht gefunden wird logger.critical(f"FEHLER: Schema-Datei '{csv_filepath}' nicht gefunden.") - ALLOWED_TARGET_BRANCHES = [] - TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar (Datei nicht gefunden)." - return # Fehler, Abbruch der Ladefunktion + ALLOWED_TARGET_BRANCHES = [] # Setze die Liste auf leer + TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar (Datei nicht gefunden)." # Setze Fehler-String + return # Beende die Funktion, da die Datei fehlt except Exception as e: + # Fange andere unerwartete Fehler beim Lesen der Datei ab logger.critical(f"FEHLER beim Laden des Ziel-Schemas aus '{csv_filepath}' (Zeile {line_count if line_count > 0 else 'vor erster Zeile'}): {e}") - ALLOWED_TARGET_BRANCHES = [] - TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar (Fehler beim Lesen)." - return # Fehler, Abbruch der Ladefunktion + ALLOWED_TARGET_BRANCHES = [] # Setze die Liste auf leer + TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar (Fehler beim Lesen)." # Setze Fehler-String + return # Beende die Funktion, da ein Fehler aufgetreten ist + # Konvertiere das Set in eine sortierte Liste ALLOWED_TARGET_BRANCHES = sorted(list(allowed_branches_set), key=str.lower) logger.info(f"Ziel-Schema geladen. {len(ALLOWED_TARGET_BRANCHES)} eindeutige Zielbranchen gefunden.") + # Erstelle den Prompt-String fuer ChatGPT, wenn gueltige Branchen gefunden wurden if ALLOWED_TARGET_BRANCHES: - # logger.debug(f"Erste 10 geladene Zielbranchen: {ALLOWED_TARGET_BRANCHES[:10]}") # Zu viel Lärm - # Erstelle den String für den Prompt - schema_lines = ["Ziel-Branchenschema: Folgende Branchenbereiche sind gültig (Kurzformen):"] + # logger.debug(f"Erste 10 geladene Zielbranchen: {ALLOWED_TARGET_BRANCHES[:10]}") # Zu viel Laerm im Debug + schema_lines = ["Ziel-Branchenschema: Folgende Branchenbereiche sind gueltig (Kurzformen):"] + # Fuege jede erlaubte Branche als Listeneintrag hinzu schema_lines.extend(f"- {branch}" for branch in ALLOWED_TARGET_BRANCHES) - schema_lines.append("\nBitte ordne das Unternehmen ausschließlich in einen dieser Bereiche ein. Gib NUR den exakten Kurznamen der Branche zurück (keine Präfixe oder zusätzliche Erklärungen außer im 'Begründung'-Feld).") # Strengere Anweisung - schema_lines.append("Antworte ausschließlich im folgenden Format (keine Einleitung, kein Schlusssatz):") + # Fuege strenge Anweisungen fuer das Antwortformat hinzu + schema_lines.append("\nBitte ordne das Unternehmen ausschliesslich in einen dieser Bereiche ein. Gib NUR den exakten Kurznamen der Branche zurueck (keine Praefixe oder zusaetzliche Erklaerungen ausser im 'Begruendung'-Feld).") # Verwende Umlaute nicht, um Encoding-Probleme im Prompt zu vermeiden + schema_lines.append("Antworte ausschliesslich im folgenden Format (keine Einleitung, kein Schlusssatz):") schema_lines.append("Branche: ") - schema_lines.append("Übereinstimmung: ") - schema_lines.append("Begründung: ") + schema_lines.append("Uebereinstimmung: ") # Verwende Umlaute nicht + schema_lines.append("Begruendung: ") # Verwende Umlaute nicht + # Verbinde die Zeilen zum finalen Prompt-String TARGET_SCHEMA_STRING = "\n".join(schema_lines) - # logger.debug(f"Generierter TARGET_SCHEMA_STRING:\n{TARGET_SCHEMA_STRING}") # Zu viel Lärm + # logger.debug(f"Generierter TARGET_SCHEMA_STRING:\n{TARGET_SCHEMA_STRING}") # Zu viel Laerm im Debug else: - TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar (Keine gültigen Branchen in Datei gefunden)." - logger.warning("Keine gültigen Zielbranchen im Schema gefunden. Branchenbewertung ist nicht möglich.") + # Wenn keine gueltigen Branchen gefunden wurden + TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar (Keine gueltigen Branchen in Datei gefunden)." + logger.warning("Keine gueltigen Zielbranchen im Schema gefunden. Branchenbewertung ist nicht moeglich.") -# map_external_branch ist in dieser Struktur nicht mehr notwendig, -# da die Branchenevaluation über ChatGPT (evaluate_branche_chatgpt) +# map_external_branch ist in dieser Version nicht mehr notwendig, +# da die Branchenevaluation ueber ChatGPT (evaluate_branche_chatgpt) # direkt gegen ALLOWED_TARGET_BRANCHES validiert. -# --- OpenAI / CHATGPT FUNCTIONS --- -# Übernommen aus Ihrem Code (Teil 7), angepasst als globale Funktionen. +# ============================================================================== +# Ende Schema Loading Utility Block +# ============================================================================== -@retry_on_failure +# ============================================================================== +# GLOBALE HELPER FUNCTIONS (PART 6: OpenAI API Call Wrapper) +# ============================================================================== + +# --- OpenAI / CHATGPT FUNCTIONS --- +# Zentrale Funktion fuer OpenAI Chat API Aufrufe. +# Nutzt globale Helfer: Config.API_KEYS, openai, retry_on_failure, token_count (optional), logger, ValueError. +@retry_on_failure # Wende den Decorator auf diesen API Call an def call_openai_chat(prompt, temperature=0.3, model=None): - """Zentrale Funktion für OpenAI Chat API Aufrufe.""" + """ + Zentrale Funktion fuer OpenAI Chat API Aufrufe. + Wird von anderen globalen Helfern oder DataProcessor Methoden aufgerufen. + + Args: + prompt (str): Der Prompt-Text an die API. + temperature (float, optional): Die Temperatur fuer die Textgenerierung. Defaults to 0.3. + model (str, optional): Das zu verwendende OpenAI Modell. Defaults to Config.TOKEN_MODEL. + + Returns: + str: Der bereinigte Antwortstring von der API. + Wirft Exception bei API-Fehlern nach Retries. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Pruefen Sie, ob der API Key konfiguriert ist if not Config.API_KEYS.get('openai'): logger.error("Fehler: OpenAI API Key nicht konfiguriert.") - # Anstatt None zurückzugeben, werfen Sie eine Exception, damit retry_on_failure dies behandelt (oder nicht, je nach Config) + # Werfen Sie eine spezifische Exception, die vom retry_on_failure als permanent behandelt wird raise openai.error.AuthenticationError("OpenAI API Key nicht konfiguriert.") - if not prompt: - logger.error("Fehler: Leerer Prompt für OpenAI.") - # Werfen Sie einen Value Error Exception - raise ValueError("Leerer Prompt für OpenAI.") + # Pruefen Sie, ob der Prompt leer ist + if not prompt or not isinstance(prompt, str) or not prompt.strip(): + logger.error("Fehler: Leerer Prompt fuer OpenAI.") + # Werfen Sie eine Value Error Exception, die vom retry_on_failure behandelt wird + raise ValueError("Leerer Prompt fuer OpenAI.") + # Bestimmen Sie das zu verwendende Modell (CLI > Config > Default) current_model = model if model else getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo') try: - # Token zählen vor dem Senden (optional, gut für Debugging/Monitoring) - # try: prompt_tokens = token_count(prompt, model=current_model); logger.debug(f"Sende Prompt an OpenAI ({current_model}, geschätzt {prompt_tokens} Tokens)..."); - # except Exception as e_tc: logger.debug(f"Fehler beim Token-Zählen: {e_tc}"); # Logge Fehler beim Token-Zählen + # Optional: Token zaehlen vor dem Senden (gut fuer Debugging/Monitoring) + # try: + # # Schaetzen Sie die Token-Zahl des Prompts + # prompt_tokens = token_count(prompt, model=current_model) + # # Loggen Sie die geschaetzte Token-Zahl auf Debug-Level + # logger.debug(f"Sende Prompt an OpenAI ({current_model}, geschaetzt {prompt_tokens} Tokens)...") + # except Exception as e_tc: + # # Logge Fehler beim Token-Zaehlen, aber fahre fort + # logger.debug(f"Fehler beim Token-Counting fuer Prompt: {e_tc}"); - # Der OpenAI Call selbst kann Exceptions werfen (APIError, RateLimitError, InvalidRequestError etc.) - # Diese werden vom @retry_on_failure Decorator behandelt. + # Fuehren Sie den API Call durch. Dieser kann verschiedene Exceptions werfen (APIError, RateLimitError, InvalidRequestError etc.). + # Diese werden vom @retry_on_failure Decorator dieser Funktion behandelt. response = openai.ChatCompletion.create( model=current_model, messages=[{"role": "user", "content": prompt}], temperature=temperature + # Fuegen Sie hier ggf. weitere Parameter hinzu (max_tokens, top_p, frequency_penalty etc.) ) - # Überprüfen Sie die Antwort auf Fehler (z.B. leere choices Liste) - if not response or not response.choices: - logger.error("OpenAI Call erfolgreich, aber keine Choices in der Antwort erhalten.") - # Werfen Sie eine spezifische Exception + + # Ueberpruefen Sie die Struktur der Antwort, um sicherzustellen, dass Choices vorhanden sind + if not response or not hasattr(response, 'choices') or not response.choices: + # Wenn die API erfolgreich antwortet, aber keine Choices liefert (unerwartet) + logger.error(f"OpenAI Call erfolgreich, aber keine Choices in der Antwort erhalten. Response: {str(response)[:200]}...") + # Werfen Sie eine spezifische Exception, die vom Decorator behandelt wird raise openai.error.APIError("Keine Choices in OpenAI Antwort erhalten.") - # Extrahieren Sie den Inhalt der ersten (und einzigen) Antwort - result = response.choices[0].message.content.strip() + # Extrahieren Sie den Inhalt der ersten (und normalerweise einzigen) Antwort + # Sicherstellen, dass message und content Attribute existieren + result = response.choices[0].message.content.strip() if hasattr(response.choices[0], 'message') and hasattr(response.choices[0].message, 'content') else "" - # Token zählen für die Antwort (optional) - # try: completion_tokens = token_count(result, model=current_model); total_tokens = response.usage.total_tokens; logger.debug(f"OpenAI Antwort erhalten ({completion_tokens} Completion Tokens, {total_tokens} Gesamt)."); - # except Exception as e_tc: logger.debug(f"Fehler beim Token-Zählen der Antwort: {e_tc}"); # Logge Fehler beim Token-Zählen + # Wenn der extrahierte Inhalt leer ist + if not result: + logger.warning(f"OpenAI Call erfolgreich, erhielt aber leeren Inhalt in der Antwort. Prompt Anfang: {prompt[:100]}...") + # Sie koennen hier entscheiden, ob dies ein Fehler ist, der ein Retry rechtfertigt + # oder ob ein leerer String eine gueltige (wenn auch unerwuenschte) Antwort ist. + # Furs Erste werfen wir eine spezifische Exception, um es im Log zu sehen und ggf. zu wiederholen. + #raise openai.error.APIError("OpenAI Antwort hatte leeren Inhalt.") # Kann zu aggressiv sein - return result # Gibt den bereinigten Antwortstring zurück + # Lassen wir es einfach leer zurueckgeben, wenn die API einen leeren Inhalt liefert. + return "" # Gebe einen leeren String zurueck - # Die spezifischen OpenAI Exceptions werden vom retry_on_failure gefangen. + + # Optional: Token zaehlen fuer die Antwort + # try: + # # Schaetzen Sie die Token-Zahl der Antwort + # completion_tokens = token_count(result, model=current_model) + # # Versuchen Sie, die tatsaechliche Gesamt-Token-Zahl aus dem Usage-Objekt zu holen + # total_tokens = response.usage.total_tokens if hasattr(response, 'usage') else 'N/A' + # logger.debug(f"OpenAI Antwort erhalten ({completion_tokens} Completion Tokens, {total_tokens} Gesamt).") + # # TODO: Token-Zahl irgendwo sammeln und im Sheet speichern (z.B. im DataProcessor) + # except Exception as e_tc: + # logger.debug(f"Fehler beim Token-Zaehlen der Antwort oder Usage Info: {e_tc}"); + + + return result # Gibt den bereinigten Antwortstring zurueck + + # Die spezifischen OpenAI Exceptions werden vom retry_on_failure Decorator gefangen. # Nur andere unerwartete Exceptions kommen hier direkt an. except Exception as e: - # Loggen Sie den unerwarteten Fehler - logger.error(f"Allgemeiner Fehler während OpenAI-Aufruf: {type(e).__name__} - {e}") + # Fangen Sie alle anderen unerwarteten Exceptions ab (z. B. Programmierfehler). + # Diese werden bereits vom retry_on_failure Decorator als "UNERWARTETER FEHLER" geloggt + # und dort (standardmaessig) nicht wiederholt, sondern sofort weitergeleitet. # Werfen Sie die Exception erneut, damit der retry_on_failure Decorator sie fangen kann. - raise e + raise e # Leiten Sie die Exception weiter +# ============================================================================== +# Ende OpenAI API Call Wrapper Block +# ============================================================================== + +# ============================================================================== +# GLOBALE HELPER FUNCTIONS (PART 7: OpenAI Summary Helpers) +# ============================================================================== + +# --- OpenAI Summary Helpers --- +# Funktionen zur Zusammenfassung von Website-Inhalten mittels OpenAI. +# Nutzt globale Helfer: call_openai_chat, logger, token_count (optional), retry_on_failure. + def summarize_website_content(raw_text): - """Erstellt Zusammenfassung von Website-Rohtext via OpenAI.""" - if not raw_text or str(raw_text).strip() == "" or str(raw_text).strip().lower() in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]: - logger.debug("summarize_website_content skipped: No valid raw text.") - return "k.A." + """ + Erstellt eine Zusammenfassung eines Website-Rohtextes ueber OpenAI. - # Kürze den Rohtext, falls er sehr lang ist, um Token zu sparen/Limits zu vermeiden - # Die maximale Länge des Prompts ist das Limit minus der erwarteten Antwortlänge. - # Eine konservative Schätzung für den Text sind 3000 Zeichen. + Args: + raw_text (str): Der rohe Textinhalt der Website. + + Returns: + str: Die generierte Zusammenfassung oder ein Fehlerwert ("k.A.", etc.). + Wirft Exception bei API-Fehlern nach Retries (von call_openai_chat). + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Pruefe, ob gueltiger Rohtext vorhanden ist (nicht leer oder Standard-Fehlerwerte) + if not raw_text or str(raw_text).strip() == "" or str(raw_text).strip().lower() in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]: + logger.debug("summarize_website_content skipped: No valid raw text provided.") + return "k.A." # Gebe "k.A." zurueck, wenn kein gueltiger Rohtext vorliegt + + + # Kuerze den Rohtext, falls er sehr lang ist, um Token zu sparen/Limits zu vermeiden. + # Die maximale Laenge des Prompts ist das Limit minus der erwarteten Antwortlaenge. + # Eine konservative Schaetzung fuer den Eingabetext sind 3000 Zeichen. max_raw_length = 3000 if len(str(raw_text)) > max_raw_length: - logger.debug(f"Kürze Rohtext für Zusammenfassung von {len(str(raw_text))} auf {max_raw_length} Zeichen.") - raw_text = str(raw_text)[:max_raw_length] + logger.debug(f"Kuerze Rohtext fuer Zusammenfassung von {len(str(raw_text))} auf {max_raw_length} Zeichen.") + raw_text = str(raw_text)[:max_raw_length] # Kuerzen des Textes + + # Erstelle den Prompt fuer die Zusammenfassung prompt = ( "Du bist ein KI-Assistent, der Webinhalte analysiert.\n" - "Fasse den folgenden Text einer Unternehmenswebsite prägnant zusammen. " + "Fasse den folgenden Text einer Unternehmenswebsite praegnant zusammen. " "Konzentriere dich dabei auf:\n" - "- Haupttätigkeitsfeld des Unternehmens\n" + "- Haupttaetigkeitsfeld des Unternehmens\n" "- Wichtigste Produkte und/oder Dienstleistungen\n" "- Zielgruppe (falls erkennbar)\n\n" f"Website-Text:\n```\n{raw_text}\n```\n\n" - "Zusammenfassung (max. 100 Wörter):" + "Zusammenfassung (max. 100 Woerter):" ) - # Call_openai_chat nutzt den retry_on_failure Decorator. - # Wenn call_openai_chat nach Retries eine Exception wirft, wird diese hier nicht gefangen, - # sondern weitergereicht (z.B. an _process_single_row), was gut ist. + # Rufe die zentrale OpenAI Chat API Funktion auf. + # call_openai_chat nutzt den retry_on_failure Decorator und wirft bei endgueltigem Fehler eine Exception. + # Diese Exception wird hier NICHT gefangen, sondern weitergereicht (z.B. an _process_single_row). try: + # Standard Temperatur 0.2 fuer Zusammenfassungen summary = call_openai_chat(prompt, temperature=0.2) + # Wenn call_openai_chat erfolgreich ist, gibt es den String zurueck (auch wenn leer). + # Wenn der extrahierte Inhalt leer ist, geben wir "k.A." zurueck. return summary if summary and summary.strip() else "k.A. (Keine Zusammenfassung erhalten)" except Exception as e: - # Fehler beim OpenAI Call (wird vom retry_on_failure geloggt) - # Geben Sie einen Fehlerwert zurück - return f"k.A. (Fehler Zusammenfassung: {str(e)[:50]}...)" + # Wenn call_openai_chat nach Retries eine Exception wirft + # Der Fehler wird bereits vom retry_on_failure Decorator geloggt. + # Geben Sie einen Fehlerwert zurueck, der im Sheet gespeichert werden kann. + # Die Exception wird hier gefangen, um einen Rueckgabewert zu liefern, anstatt die Exception weiterzuleiten. + logger.error(f"FEHLER bei Website Zusammenfassung nach Retries: {e}") # Logge den Fehler + return f"k.A. (Fehler Zusammenfassung: {str(e)[:50]}...)" # Signalisiert Fehler -# Übernommen aus summarize_batch_openai in Teil 7/9, angepasst als globale Funktion. -@retry_on_failure # Anwenden des Decorators auf die Batch-Funktion +# --- Batch-Zusammenfassungsfunktion --- +# Fasst mehrere Texte in einem einzigen OpenAI API Call zusammen. +# Basierend auf summarize_batch_openai aus Teil 7/9. +# Nutzt globale Helfer: call_openai_chat, logger, token_count (optional), retry_on_failure. +@retry_on_failure # Wende den Decorator auf den gesamten Batch-API Call an def summarize_batch_openai(tasks_data): """ Fasst eine Liste von Rohtexten in einem einzigen OpenAI API Call zusammen. - Die Prüfung auf das Token-Limit wird jetzt primär der API überlassen. + Dies ist effizienter fuer die Verarbeitung mehrerer Zusammenfassungen. Args: - tasks_data (list): Eine Liste von Dictionaries, jedes enthält: + tasks_data (list): Eine Liste von Dictionaries, jedes enthaelt: {'row_num': int, 'raw_text': str} Returns: dict: Ein Dictionary, das Zeilennummern auf ihre Zusammenfassungen mappt. z.B. {2122: "Zusammenfassung A", 2123: "Zusammenfassung B"} Bei Fehlern oder fehlenden Zusammenfassungen wird ein Fehlerstring verwendet. - Wirft Exception bei API-Fehlern nach Retries. + Wirft Exception bei endgueltigen API-Fehlern nach Retries. """ - if not tasks_data: return {} + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + if not tasks_data: return {} # Gebe leeres Dictionary zurueck, wenn keine Tasks da sind - # Filtere Tasks, die gültigen Text haben. - # Achten Sie darauf, dass die Filterkriterien konsistent sind mit summarize_website_content. + # Filtere Tasks, die gueltigen Text haben (nicht leer oder Standard-Fehlerwerte). valid_tasks = [t for t in tasks_data if t.get("raw_text") and str(t["raw_text"]).strip().lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]] + # Wenn keine gueltigen Tasks vorhanden sind if not valid_tasks: - logger.debug("Keine gültigen Rohtexte für Batch-Zusammenfassung gefunden.") - # Geben Sie ein Ergebnisdict zurück, das dies für alle Zeilen widerspiegelt - return {t['row_num']: "k.A. (Kein gültiger Rohtext im Batch)" for t in tasks_data} + logger.debug("Keine gueltigen Rohtexte fuer Batch-Zusammenfassung gefunden.") + # Geben Sie ein Ergebnisdict zurueck, das dies fuer alle urspruenglichen Zeilen widerspiegelt + return {t['row_num']: "k.A. (Kein gueltiger Rohtext im Batch)" for t in tasks_data} - logger.debug(f"Starte Batch-Zusammenfassung für {len(valid_tasks)} gültige Texte (Zeilen: {[t['row_num'] for t in valid_tasks]})...") + + logger.debug(f"Starte Batch-Zusammenfassung fuer {len(valid_tasks)} gueltige Texte (Zeilen: {[t['row_num'] for t in valid_tasks]})...") # --- Aggregierten Prompt erstellen --- prompt_parts = [ "Du bist ein KI-Assistent, der Webinhalte analysiert.", - "Fasse für JEDEN der folgenden Texte einer Unternehmenswebsite prägnant zusammen. " + "Fasse fuer JEDEN der folgenden Texte einer Unternehmenswebsite praegnant zusammen. " # Umlaute vermeiden im Prompt "Konzentriere dich dabei auf:\n" - "- Haupttätigkeitsfeld des Unternehmens\n" + "- Haupttaetigkeitsfeld des Unternehmens\n" # Umlaute vermeiden "- Wichtigste Produkte und/oder Dienstleistungen\n" "- Zielgruppe (falls erkennbar)\n\n" - "Gib das Ergebnis für JEDEN Text im folgenden Format aus, auf einer neuen Zeile:\n" - "RESULTAT : \n\n" - "Halte jede Zusammenfassung kurz, max. 100 Wörter.\n\n", + "Gib das Ergebnis fuer JEDEN Text im folgenden Format aus, auf einer neuen Zeile:\n" # Umlaute vermeiden + "RESULTAT : \n\n" # Umlaute vermeiden + "Halte jede Zusammenfassung kurz, max. 100 Woerter.\n\n", # Umlaute vermeiden "--- Texte zur Zusammenfassung ---" ] text_block = "" - row_numbers_in_batch = [] # Zeilen, die tatsächlich im Prompt landen + row_numbers_in_batch = [] # Liste der Zeilennummern, die tatsaechlich in diesem API-Prompt enthalten sind - # Baue den Textblock zusammen. Kürze jeden einzelnen Text, um das Gesamtprompt-Limit nicht zu sprengen. - max_chars_per_single_text_in_batch = 1500 # Zeichenlimit für jeden Text innerhalb des Batch-Prompts + # Baue den Textblock zusammen. Kuerze jeden einzelnen Text, um das Gesamtprompt-Limit nicht zu sprengen. + max_chars_per_single_text_in_batch = 1500 # Zeichenlimit fuer jeden Text innerhalb des Batch-Prompts for task in valid_tasks: row_num = task['row_num'] raw_text = str(task['raw_text']) # Sicherstellen, dass es ein String ist - raw_text_short = raw_text[:max_chars_per_single_text_in_batch] # Kürzen für den Prompt + raw_text_short = raw_text[:max_chars_per_single_text_in_batch] # Kuerzen fuer den Prompt entry_text = f"\n--- TEXT Zeile {row_num} ---\n{raw_text_short}\n--- ENDE TEXT Zeile {row_num} ---\n" text_block += entry_text - row_numbers_in_batch.append(row_num) # Füge die Zeilennummer hinzu + row_numbers_in_batch.append(row_num) # Fuege die Zeilennummer zur Liste im Batch hinzu + + # Wenn nach der Filterung und Kuerzung keine Zeilen mehr uebrig sind (sollte oben abgefangen sein, aber zur Sicherheit) if not row_numbers_in_batch: - # Sollte nur passieren, wenn valid_tasks leer war, was oben abgefangen wird - logger.error("Logikfehler: Keine Zeilen in row_numbers_in_batch trotz valid_tasks.") - return {t['row_num']: "FEHLER (Batch-Erstellung)" for t in tasks_data} + logger.debug("Keine Zeilen uebrig fuer OpenAI Prompt nach Filterung/Kuerzung im Batch.") + return {t['row_num']: "k.A. (Kein Rohtext im Batch)" for t in tasks_data} prompt_parts.append(text_block) prompt_parts.append("\n--- Ende der Texte ---") - prompt_parts.append("\nBitte gib NUR die 'RESULTAT : ...' Zeilen zurück.") + prompt_parts.append("\nBitte gib NUR die 'RESULTAT : ...' Zeilen zurueck.") final_prompt = "\n".join(prompt_parts) - # Optional: Token zählen zur Info, aber nicht zur Blockade - # try: prompt_tokens = token_count(final_prompt, model=getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo')); logger.debug(f"Geschätzt Prompt-Tokens für Batch: {prompt_tokens}."); - # except Exception as e_tc: logger.debug(f"Fehler beim Token-Zählen: {e_tc}"); + # Optional: Token zaehlen zur Info, aber nicht zur Blockade + # try: prompt_tokens = token_count(final_prompt, model=getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo')); logger.debug(f"Geschaetzt Prompt-Tokens fuer Batch: {prompt_tokens}."); + # except Exception as e_tc: logger.debug(f"Fehler beim Token-Zaehlen: {e_tc}"); # --- OpenAI API Call --- - # call_openai_chat nutzt den retry_on_failure Decorator und wirft bei endgültigem Fehler eine Exception. - # Der retry_on_failure Decorator DIESER summarize_batch_openai Funktion fängt die Exception - # und führt die Retries für die GESAMTE Batch-Funktion durch. + # call_openai_chat nutzt den retry_on_failure Decorator und wirft bei endgueltigem Fehler eine Exception. + # Der retry_on_failure Decorator auf dieser summarize_batch_openai Funktion faengt die Exception + # von call_openai_chat und fuehrt die Retries fuer die GESAMTE Batch-Funktion durch. + chat_response = None try: chat_response = call_openai_chat(final_prompt, temperature=0.2) - # Wenn call_openai_chat erfolgreich ist, gibt es den String zurück. - # Exceptions werden nach Retries geworfen und vom äußeren retry_on_failure dieser Funktion gefangen. + # Wenn call_openai_chat erfolgreich ist, gibt es den String zurueck. + # Exceptions werden nach Retries geworfen und vom aeusseren retry_on_failure dieser Funktion gefangen. if not chat_response: - # Dieser Fall sollte nach der Änderung in call_openai_chat nicht mehr auftreten (würde Exception werfen) - logger.error("call_openai_chat gab unerwarteterweise None zurück für Batch-Zusammenfassung.") - # Werfen Sie eine spezifische Exception, damit der äußere Decorator sie fängt - raise openai.error.APIError("Keine Antwort von OpenAI erhalten für Batch-Zusammenfassung.") + # Dieser Fall sollte nach der Aenderung in call_openai_chat nicht mehr auftreten (wuerde Exception werfen) + logger.error("call_openai_chat gab unerwarteterweise None zurueck fuer Batch-Zusammenfassung.") + # Werfen Sie eine spezifische Exception, damit der aeussere Decorator sie faengt + raise openai.error.APIError("Keine Antwort von OpenAI erhalten fuer Batch-Zusammenfassung.") except Exception as e: - # Wenn call_openai_chat oder der äußere retry_on_failure eine Exception wirft + # Wenn call_openai_chat oder der aeussere retry_on_failure eine Exception wirft # Die Exception wird hier gefangen, bevor sie an den Aufrufer (DataProcessor Methode) weitergeleitet wird. - logger.error(f"Endgültiger FEHLER beim OpenAI-Batch-Aufruf für Zusammenfassung (innerhalb Batch Decorator): {e}") - # Geben Sie ein Dictionary zurück, das signalisiert, dass für alle Zeilen im Batch ein Fehler aufgetreten ist + logger.error(f"Endgueltiger FEHLER beim OpenAI-Batch-Aufruf fuer Zusammenfassung (innerhalb Batch Decorator): {e}") + # Geben Sie ein Dictionary zurueck, das signalisiert, dass fuer alle Zeilen im Batch ein Fehler aufgetreten ist return {row_num: f"FEHLER API: {str(e)[:100]}" for row_num in row_numbers_in_batch} # --- Antwort parsen --- - summaries = {} # Initialize with empty dict + summaries = {} # Initialisieren Sie das Ergebnis-Dictionary lines = chat_response.strip().split('\n') parsed_count = 0 for line in lines: - # Matcht "RESULTAT :" und den Rest der Zeile + # Matcht "RESULTAT :" und den Rest der Zeile match = re.match(r"RESULTAT (\d+): (.*)", line.strip()) if match: row_num = int(match.group(1)) summary_text = match.group(2).strip() - # Stellen Sie sicher, dass die Zeilennummer im ursprünglichen Batch war + # Stellen Sie sicher, dass die Zeilennummer im urspruenglichen Batch war if row_num in row_numbers_in_batch: summaries[row_num] = summary_text parsed_count += 1 - # else: logger.debug(f"Warnung: Antwort für unerwartete Zeilennummer {row_num} im Batch erhalten.") # Zu viel Lärm + # else: logger.debug(f"Warnung: Antwort fuer unerwartete Zeilennummer {row_num} im Batch erhalten.") # Zu viel Laerm logger.debug(f"Batch-Zusammenfassung: {parsed_count} von {len(row_numbers_in_batch)} Zeilen erfolgreich geparst.") - # Fügen Sie einen Fehlerwert für Zeilen hinzu, die nicht geparst werden konnten + # Fuegen Sie einen Fehlerwert fuer Zeilen hinzu, die nicht geparst werden konnten (z.B. falsches Antwortformat) if parsed_count < len(row_numbers_in_batch): logger.warning(f"Nicht alle Zeilen aus dem Batch ({len(row_numbers_in_batch)}) konnten in der OpenAI-Antwort ({len(lines)} Zeilen) geparst werden.") logger.debug(f"Unerwartete Antwortteile (erste 500 Zeichen): {chat_response[:500]}") @@ -1035,94 +1350,108 @@ def summarize_batch_openai(tasks_data): summaries[row_num] = "FEHLER: Antwort nicht geparst" - # Füge k.A. für Tasks hinzu, die ungültigen Rohtext hatten (aus valid_tasks gefiltert) - # Diese waren nie Teil des OpenAI Prompts + # Fuege k.A. fuer Tasks hinzu, die ungueltigen Rohtext hatten (aus valid_tasks gefiltert) + # Diese waren nie Teil des OpenAI Prompts und hatten daher kein Ergebnis original_row_nums = {t['row_num'] for t in tasks_data} for row_num in original_row_nums: if row_num not in summaries: - summaries[row_num] = "k.A. (Kein gültiger Rohtext im Batch)" + summaries[row_num] = "k.A. (Kein gueltiger Rohtext im Batch)" - return summaries # Rückgabe des Dictionarys mit Ergebnissen oder Fehlern + return summaries # Rueckgabe des Dictionarys mit Ergebnissen oder Fehlern -# Übernommen aus evaluate_branche_chatgpt in Teil 4/7, angepasst als globale Funktion. -# Nutzt globale ALLOWED_TARGET_BRANCHES und TARGET_SCHEMA_STRING. -@retry_on_failure # Anwenden des Decorators auf die Funktion, die call_openai_chat aufruft +# ============================================================================== +# Ende OpenAI Summary Helpers Block +# ============================================================================== + +# ============================================================================== +# GLOBALE HELPER FUNCTIONS (PART 8: OpenAI Branch Helper) +# ============================================================================== + +# --- OpenAI Branch Helper --- +# Funktion zur Branchenbewertung mittels OpenAI. +# Nutzt globale Helfer: ALLOWED_TARGET_BRANCHES, TARGET_SCHEMA_STRING, +# call_openai_chat, logger, re, retry_on_failure. +@retry_on_failure # Wende den Decorator auf diese Funktion an, da sie call_openai_chat aufruft def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kategorien, website_summary): """ Ordnet das Unternehmen basierend auf den angegebenen Informationen exakt einer Branche aus dem Ziel-Branchenschema (nur Kurzformen) zu. Validiert den ChatGPT-Vorschlag - strikt gegen die erlaubten Kurzformen und führt einen Fallback auf die (extrahierte) - CRM-Kurzform durch, falls der Vorschlag ungültig ist. + strikt gegen die erlaubten Kurzformen und fuehrt einen Fallback auf die (extrahierte) + CRM-Kurzform durch, falls der Vorschlag ungueltig ist. Args: - crm_branche (str): Branche laut CRM (kann noch Präfix enthalten). + crm_branche (str): Branche laut CRM (kann noch Praefix enthalten). beschreibung (str): Unternehmensbeschreibung (CRM). wiki_branche (str): Branche aus Wikipedia (falls vorhanden). wiki_kategorien (str): Wikipedia-Kategorien. website_summary (str): Zusammenfassung des Website-Inhalts. Returns: - dict: Enthält "branch" (die finale, gültige Kurzform oder Fehler), + dict: Enthaehlt "branch" (die finale, gueltige Kurzform oder Fehler), "consistency" ('ok', 'X', 'fallback_crm_valid', 'fallback_invalid', 'error_...'), - "justification" (Begründung von ChatGPT oder Fallback-Info). - Wirft Exception bei API-Fehlern (von call_openai_chat nach Retries). + "justification" (Begruendung von ChatGPT oder Fallback-Info). + Wirft Exception bei API-Fehlern nach Retries (von call_openai_chat). """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Zugriff auf globale Variablen (befuellt von load_target_schema im Block 6) global ALLOWED_TARGET_BRANCHES, TARGET_SCHEMA_STRING - # Grundlegende Prüfung: Ist das Schema überhaupt geladen? + # Grundlegende Pruefung: Ist das Schema ueberhaupt geladen? if not ALLOWED_TARGET_BRANCHES: logger.critical("FEHLER in evaluate_branche_chatgpt: Ziel-Branchenschema (ALLOWED_TARGET_BRANCHES) ist leer. Kann Branchen nicht validieren.") - # Geben Sie ein Fehlerergebnis zurück + # Geben Sie ein Fehlerergebnis zurueck return {"branch": "FEHLER - SCHEMA FEHLT", "consistency": "error_schema_missing", "justification": "Fehler: Ziel-Schema nicht geladen"} - # Erstelle Lookup für erlaubte Branches (case-insensitive) + # Erstelle Lookup fuer erlaubte Branches (case-insensitive) allowed_branches_lookup = {b.lower(): b for b in ALLOWED_TARGET_BRANCHES} - # --- Prompt für ChatGPT erstellen --- - # Beginne mit den Regeln und der Liste der gültigen Kurzformen + # --- Prompt fuer ChatGPT erstellen --- + # Beginne mit den Regeln und der Liste der gueltigen Kurzformen prompt_parts = [TARGET_SCHEMA_STRING] # Enthält bereits die Liste und Anweisungen prompt_parts.append("\nOrdne das Unternehmen anhand folgender Angaben exakt einer Branche des Ziel-Branchenschemas (Kurzformen) zu:") - # Füge nur vorhandene Informationen hinzu und kürze sie ggf. + # Fuege nur vorhandene Informationen hinzu und kuerze sie ggf. # Stellen Sie sicher, dass die Werte keine None-Typen sind - if crm_branche and str(crm_branche).strip() and str(crm_branche).strip() != "k.A.": prompt_parts.append(f"- CRM-Branche (Referenz): {str(crm_branche).strip()}") - if beschreibung and str(beschreibung).strip() and str(beschreibung).strip() != "k.A.": prompt_parts.append(f"- Beschreibung: {str(beschreibung).strip()[:500]}...") # Kürzen - if wiki_branche and str(wiki_branche).strip() and str(wiki_branche).strip() != "k.A.": prompt_parts.append(f"- Wikipedia-Branche: {str(wiki_branche).strip()[:300]}...") # Kürzen - if wiki_kategorien and str(wiki_kategorien).strip() and str(wiki_kategorien).strip() != "k.A.": prompt_parts.append(f"- Wikipedia-Kategorien: {str(wiki_kategorien).strip()[:500]}...") # Kürzen - if website_summary and str(website_summary).strip() and str(website_summary).strip() != "k.A.": prompt_parts.append(f"- Website-Zusammenfassung: {str(website_summary).strip()[:500]}...") # Kürzen + if crm_branche and str(crm_branche).strip() and str(crm_branche).strip().lower() != "k.a.": prompt_parts.append(f"- CRM-Branche (Referenz): {str(crm_branche).strip()}") + if beschreibung and str(beschreibung).strip() and str(beschreibung).strip().lower() != "k.a.": prompt_parts.append(f"- Beschreibung: {str(beschreibung).strip()[:500]}...") # Kuerzen + if wiki_branche and str(wiki_branche).strip() and str(wiki_branche).strip().lower() != "k.a.": prompt_parts.append(f"- Wikipedia-Branche: {str(wiki_branche).strip()[:300]}...") # Kuerzen + if wiki_kategorien and str(wiki_kategorien).strip() and str(wiki_kategorien).strip().lower() != "k.a.": prompt_parts.append(f"- Wikipedia-Kategorien: {str(wiki_kategorien).strip()[:500]}...") # Kuerzen + if website_summary and str(website_summary).strip() and str(website_summary).strip().lower() != "k.a." and not str(website_summary).strip().startswith("k.A. (Fehler"): # Pruefe auch auf Website Summary Fehlerwerte + prompt_parts.append(f"- Website-Zusammenfassung: {str(website_summary).strip()[:500]}...") # Kuerzen # Fallback, wenn zu wenige Infos da sind (mindestens 2 relevante Zeilen im Prompt neben dem Schema) # Der Prompt hat immer mindestens 1 Zeile (Schema) + 1 Zeile (Instruktion "Ordne zu..."). - # Prüfen wir, ob mindestens 2 Info-Zeilen hinzugefügt wurden. + # Pruefen wir, ob mindestens 2 Info-Zeilen hinzugefuegt wurden. if len(prompt_parts) < 3: # 1 (Schema) + 1 (Instruktion) + <2 (Infos) - logger.warning("Warnung in evaluate_branche_chatgpt: Zu wenige Informationen (<2 Quellen) für Branchenevaluierung.") - # Geben Sie ein Fehlerergebnis zurück, verwenden Sie die CRM-Branche als Fallback - return {"branch": crm_branche, "consistency": "error_no_info", "justification": "Fehler: Zu wenige Informationen für eine Einschätzung"} + logger.warning("Warnung in evaluate_branche_chatgpt: Zu wenige Informationen (<2 Quellen) fuer Branchenevaluierung.") + # Geben Sie ein Fehlerergebnis zurueck, verwenden Sie die CRM-Branche als Fallback + return {"branch": crm_branche, "consistency": "error_no_info", "justification": "Fehler: Zu wenige Informationen fuer eine Einschaetzung"} - # Prompt für das Antwortformat ist bereits in TARGET_SCHEMA_STRING enthalten. + # Prompt fuer das Antwortformat ist bereits in TARGET_SCHEMA_STRING enthalten. prompt = "\n".join(prompt_parts) - # logger.debug(f"Erstellter Prompt für Branchenevaluierung:\n---\n{prompt}\n---") # Zu viel Lärm + # logger.debug(f"Erstellter Prompt fuer Branchenevaluierung:\n---\n{prompt}\n---") # Zu viel Laerm im Debug # --- ChatGPT aufrufen --- - # call_openai_chat nutzt den retry_on_failure Decorator und wirft bei endgültigem Fehler eine Exception + # call_openai_chat nutzt den retry_on_failure Decorator und wirft bei endgueltigem Fehler eine Exception chat_response = None try: - chat_response = call_openai_chat(prompt, temperature=0.0) # Niedrige Temperatur für konsistente Zuordnung + chat_response = call_openai_chat(prompt, temperature=0.0) # Niedrige Temperatur fuer konsistente Zuordnung if not chat_response: - # Dieser Fall sollte nach der Änderung in call_openai_chat nicht mehr auftreten (würde Exception werfen) - logger.error("call_openai_chat gab unerwarteterweise None zurück für Branchenevaluation.") - raise openai.error.APIError("Keine Antwort von OpenAI erhalten für Branchenevaluation.") # Wirf eine Exception + # Dieser Fall sollte nach der Aenderung in call_openai_chat nicht mehr auftreten (wuerde Exception werfen) + logger.error("call_openai_chat gab unerwarteterweise None zurueck fuer Branchenevaluation.") + raise openai.error.APIError("Keine Antwort von OpenAI erhalten fuer Branchenevaluation.") # Wirf eine Exception except Exception as e: # Wenn call_openai_chat nach Retries eine Exception wirft - logger.error(f"Endgültiger FEHLER beim OpenAI-Aufruf für Branchenevaluation: {e}") - # Geben Sie ein Fehlerergebnis zurück, verwenden Sie die CRM-Branche als Fallback - # Hängen Sie die Fehlermeldung an die Begründung an. + # Der Fehler wird bereits vom retry_on_failure Decorator geloggt. + logger.error(f"Endgueltiger FEHLER beim OpenAI-Aufruf fuer Branchenevaluation: {e}") + # Geben Sie ein Fehlerergebnis zurueck, verwenden Sie die CRM-Branche als Fallback + # Haengen Sie die Fehlermeldung an die Begruendung an. return {"branch": crm_branche, "consistency": "error_api_failed", "justification": f"Fehler API: {str(e)[:100]}"} @@ -1133,26 +1462,28 @@ def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kateg suggested_branch = "" parsed_branch = False for line in lines: - line_lower = line.lower() + line_lower = line.lower(); line_stripped = line.strip() if line_lower.startswith("branche:"): - # Extrahiere die vorgeschlagene Branche, bereinige Leerzeichen und Anführungszeichen - suggested_branch = line.split(":", 1)[1].strip().strip('"\'') + # Extrahiere die vorgeschlagene Branche, bereinige Leerzeichen und Anfuehrungszeichen + suggested_branch = line_stripped.split(":", 1)[1].strip().strip('"\'') parsed_branch = True - elif line_lower.startswith("übereinstimmung:"): - # Wir überschreiben die Konsistenz später basierend auf unserer Logik, ignorieren Sie die KI-Antwort hier + elif line_lower.startswith("uebereinstimmung:") or line_lower.startswith("ubereinstimmung:"): # Beruecksichtige Umlaute und keine Umlaute + # Wir ueberschreiben die Konsistenz spaeter basierend auf unserer Logik, ignorieren Sie die KI-Antwort hier pass - elif line_lower.startswith("begründung:"): - # Erfasse die Begründung. Wenn es mehrere Begründungszeilen gibt, hänge sie an. - if result["justification"]: result["justification"] += " " + line.split(":", 1)[1].strip() - else: result["justification"] = line.split(":", 1)[1].strip() - # Behandle andere mögliche unerwartete Zeilen (optional) + elif line_lower.startswith("begruendung:") or line_lower.startswith("begruendung:"): # Beruecksichtige Umlaute und keine Umlaute + # Erfasse die Begruendung. Wenn es mehrere Begruendungszeilen gibt, haenge sie an. + justification_text = line_stripped.split(":", 1)[1].strip() + if result["justification"]: result["justification"] += " " + justification_text + else: result["justification"] = justification_text + # Behandle andere moegliche unerwartete Zeilen (optional) # elif line_lower.startswith(("resultat", "eintrag", "antwort")): # logger.warning(f"Unerwartete Zeile im Branchen-Prompt gefunden: {line[:100]}...") - if not parsed_branch or not suggested_branch: # Prüfe, ob Branch geparst wurde UND nicht leer ist - logger.error(f"Fehler in evaluate_branche_chatgpt: Konnte 'Branche:' nicht oder nur leer aus Antwort parsen: {chat_response[:500]}...") # Logge Anfang der Antwort - # Geben Sie ein Fehlerergebnis zurück, verwenden Sie die CRM-Branche als Fallback + # Pruefe, ob Branch geparst wurde UND nicht leer ist + if not parsed_branch or not suggested_branch or suggested_branch.lower() in ["k.a.", "n/a"]: # Fuege "k.a." zur Pruefung hinzu + logger.error(f"Fehler in evaluate_branche_chatgpt: Konnte 'Branche:' nicht oder nur leer/k.A. aus Antwort parsen: {chat_response[:500]}...") # Logge Anfang der Antwort + # Geben Sie ein Fehlerergebnis zurueck, verwenden Sie die CRM-Branche als Fallback return {"branch": crm_branche, "consistency": "error_parsing", "justification": f"Fehler Parsing: Antwortformat unerwartet."} @@ -1163,53 +1494,54 @@ def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kateg # 1. Ist der vorgeschlagene Branch EXAKT im Ziel-Schema enthalten? if suggested_branch_lower in allowed_branches_lookup: final_branch = allowed_branches_lookup[suggested_branch_lower] # Nimm die korrekte Schreibweise aus der Liste - logger.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist gültig ('{final_branch}').") - result["consistency"] = "pending_comparison" # Temporärer Status vor Vergleich mit CRM + logger.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist gueltig ('{final_branch}').") + result["consistency"] = "pending_comparison" # Temporaer Status vor Vergleich mit CRM else: - # --- Fallback-Logik, wenn Vorschlag ungültig ist --- - logger.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist NICHT im Ziel-Schema ({len(ALLOWED_TARGET_BRANCHES)} Einträge). Starte Fallback...") + # --- Fallback-Logik, wenn Vorschlag ungueltig ist --- + logger.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist NICHT im Ziel-Schema ({len(ALLOWED_TARGET_BRANCHES)} Eintraege). Starte Fallback...") # Versuche Kurzform aus CRM-Branche zu extrahieren crm_short_branch = "k.A." # Default - if crm_branche and ">" in str(crm_branche): - crm_short_branch = str(crm_branche).split(">", 1)[1].strip() - elif crm_branche and str(crm_branche).strip() and str(crm_branche).strip() != "k.A.": # Wenn CRM schon Kurzform sein könnte - crm_short_branch = str(crm_branche).strip() + # Stellen Sie sicher, dass crm_branche ein String ist + if crm_branche and isinstance(crm_branche, str) and ">" in crm_branche: + crm_short_branch = crm_branche.split(">", 1)[1].strip() + # Wenn CRM schon Kurzform sein koennte (nicht leer/k.A. und kein Praefix > enthalten) + elif crm_branche and isinstance(crm_branche, str) and crm_branche.strip() and crm_branche.strip().lower() != "k.a.": + crm_short_branch = crm_branche.strip() - logger.debug(f" Fallback: Prüfe extrahierte CRM-Kurzform: '{crm_short_branch}'") + logger.debug(f" Fallback: Pruefe extrahierte CRM-Kurzform: '{crm_short_branch}'") crm_short_branch_lower = crm_short_branch.lower() # 2. Ist die extrahierte CRM-Kurzform EXAKT im Ziel-Schema enthalten? if crm_short_branch != "k.A." and crm_short_branch_lower in allowed_branches_lookup: final_branch = allowed_branches_lookup[crm_short_branch_lower] # Nimm korrekte Schreibweise result["consistency"] = "fallback_crm_valid" # Setze Fallback-Status - # Kombiniere ChatGPT Begründung (falls vorhanden) mit Fallback-Info - fallback_reason = f"Fallback: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}'). Gültige CRM-Kurzform '{final_branch}' verwendet." - result["justification"] = f"{fallback_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})" - logger.info(f"Fallback auf gültige CRM-Kurzform erfolgreich: '{final_branch}'") + # Kombiniere ChatGPT Begruendung (falls vorhanden) mit Fallback-Info + fallback_reason = f"Fallback: Ungueltiger ChatGPT-Vorschlag ('{suggested_branch}'). Gueltige CRM-Kurzform '{final_branch}' verwendet." + result["justification"] = f"{fallback_reason} (ChatGPT Begruendung war: {result.get('justification', 'Keine')})" + logger.info(f"Fallback auf gueltige CRM-Kurzform erfolgreich: '{final_branch}'") else: - # 3. Wenn auch CRM-Kurzform ungültig - final_branch = suggested_branch # Behalte ungültigen Vorschlag + # 3. Wenn auch CRM-Kurzform ungueltig + final_branch = suggested_branch # Behalte ungueltigen Vorschlag result["consistency"] = "fallback_invalid" # Setze Fehler-Fallback-Status - error_reason = f"Fehler: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}') und keine gültige CRM-Kurzform ('{crm_short_branch}') als Fallback verfügbar." - result["justification"] = f"{error_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})" - logger.warning(f"Fallback fehlgeschlagen. Ungültiger Vorschlag: '{final_branch}', Ungültige CRM-Kurzform: '{crm_short_branch}'") + error_reason = f"Fehler: Ungueltiger ChatGPT-Vorschlag ('{suggested_branch}') und keine gueltige CRM-Kurzform ('{crm_short_branch}') als Fallback verfuegbar." + result["justification"] = f"{error_reason} (ChatGPT Begruendung war: {result.get('justification', 'Keine')})" + logger.warning(f"Fallback fehlgeschlagen. Ungueltiger Vorschlag: '{final_branch}', Ungueltige CRM-Kurzform: '{crm_short_branch}'") # Alternativ: Setze final_branch auf einen expliziten Fehlerwert, um es im Sheet hervorzuheben - # final_branch = "FEHLER - UNGÜLTIGE ZUWEISUNG" - + # final_branch = "FEHLER - UNGUELTIGE ZUWEISUNG" # Optional # Setze den finalen Branch im Ergebnis-Dictionary # Verwenden Sie einen Standard-Fehlerwert, falls final_branch aus irgendeinem Grund immer noch None ist result["branch"] = final_branch if final_branch else "FEHLER" - # --- Konsistenzprüfung (Finale Bewertung des final_branch vs. CRM-Kurzform) --- - # Extrahiere CRM-Kurzform für den Vergleich (erneut oder Variable von oben) + # --- Konsistenzpruefung (Finale Bewertung des final_branch vs. CRM-Kurzform) --- + # Extrahiere CRM-Kurzform fuer den Vergleich (erneut oder Variable von oben) crm_short_to_compare = "k.A." - if crm_branche and ">" in str(crm_branche): - crm_short_to_compare = str(crm_branche).split(">", 1)[1].strip() - elif crm_branche and str(crm_branche).strip() and str(crm_branche).strip() != "k.A.": - crm_short_to_compare = str(crm_branche).strip() + if crm_branche and isinstance(crm_branche, str) and ">" in crm_branche: + crm_short_to_compare = crm_branche.split(">", 1)[1].strip() + elif crm_branche and isinstance(crm_branche, str) and crm_branche.strip() and crm_branche.strip().lower() != "k.a.": + crm_short_to_compare = crm_branche.strip() # Vergleiche finalen Branch (falls nicht FEHLER) mit CRM-Kurzform (case-insensitive) @@ -1217,152 +1549,365 @@ def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kateg # Fallback-Status ('fallback_crm_valid', 'fallback_invalid') sollen erhalten bleiben. if result["consistency"] == "pending_comparison" and result["branch"] != "FEHLER": if result["branch"].lower() == crm_short_to_compare.lower(): - result["consistency"] = "ok" # Übereinstimmung mit CRM + result["consistency"] = "ok" # Uebereinstimmung mit CRM else: - result["consistency"] = "X" # Keine Übereinstimmung mit CRM + result["consistency"] = "X" # Keine Uebereinstimmung mit CRM - # Entferne den temporären Status, falls er noch da ist (sollte nicht passieren) + # Entferne den temporaeren Status, falls er noch da ist (sollte nicht passieren) if result["consistency"] == "pending_comparison": - logger.warning("Konsistenzprüfung blieb im Status 'pending_comparison', setze auf 'error_comparison_failed'.") + logger.warning("Konsistenzpruefung blieb im Status 'pending_comparison', setze auf 'error_comparison_failed'.") result["consistency"] = "error_comparison_failed" elif result["consistency"] is None: # Sollte nicht passieren logger.error("Konsistenz blieb unerwartet None, setze auf 'error_unknown_state'.") result["consistency"] = "error_unknown_state" - # Debug-Ausgabe des finalen Ergebnisses vor Rückgabe + # Debug-Ausgabe des finalen Ergebnisses vor Rueckgabe logger.debug(f"Finale Branch-Evaluation Ergebnis: Branch='{result.get('branch')}', Consistency='{result.get('consistency')}', Justification='{result.get('justification', '')[:100]}...'") - return result # Rückgabe des Ergebnis-Dictionarys + return result # Rueckgabe des Ergebnis-Dictionarys +# ============================================================================== +# Ende OpenAI Branch Helper Block +# ============================================================================== + + +# ============================================================================== +# GLOBALE HELPER FUNCTIONS (PART 9: SerpAPI Search Helpers) +# ============================================================================== + # --- SERP API / LINKEDIN FUNCTIONS --- -# Übernommen aus Ihrem Code (Teil 10), angepasst als globale Funktionen. +# Funktionen zur Suche ueber SerpAPI (Google Search). +# Nutzt globale Helfer: Config.API_KEYS, requests, retry_on_failure, +# simple_normalize_url, normalize_company_name, unquote, logger, re. -# serp_wikipedia_lookup ist bereits in Teil 1/18 enthalten (oder sollte es sein, da es direkt nach retry_on_failure kam) + +# serp_wikipedia_lookup ist bereits in Teil 1/18 enthalten (oder sollte es sein, da es direkt nach retry_on_failure kam). +# Es ist hier im globalen Sektion 3 Block nun korrekt platziert. +@retry_on_failure # Wende den Decorator an +def serp_wikipedia_lookup(company_name, website=None, min_score=0.4): + """ + Sucht ueber SerpAPI (Google) nach dem wahrscheinlichsten Wikipedia-Artikel fuer ein Unternehmen. + Verwendet flexible Query, sammelt Top-10-Kandidaten, bewertet nach Titelaehnlichkeit + und Keywords, bevorzugt deutsche/englische Artikel. + + Args: + company_name (str): Der Name des Unternehmens. + website (str, optional): Die Website des Unternehmens (fuer Kontext in Query). Defaults to None. + min_score (float, optional): Mindest-Score (Kombination aus Aehnlichkeit + und Boni) fuer einen gueltigen Treffer. Defaults to 0.4. + + Returns: + str: Die URL des relevantesten Wikipedia-Artikels oder None. + Wirft Exception bei API-Fehlern nach Retries. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + serp_key = Config.API_KEYS.get('serpapi') + if not serp_key: + logger.error("Fehler: SerpAPI Key nicht verfuegbar fuer Wikipedia Lookup.") + # Werfen Sie eine spezifische Exception, die vom retry_on_failure behandelt wird + raise ConnectionRefusedError("SerpAPI Key nicht konfiguriert.") + + if not company_name or str(company_name).strip() == "": + logger.warning("serp_wikipedia_lookup: Kein Firmenname angegeben.") + # Werfen Sie einen ValueError, der vom retry_on_failure behandelt wird + raise ValueError("Kein Firmenname fuer SerpAPI Wikipedia Lookup angegeben.") + + + # --- Flexible Query Konstruktion --- + # Ohne Anfuehrungszeichen fuer breitere Suche + query = f'{company_name} Wikipedia' + # Fuegen Sie die Domain als Kontext hinzu, wenn vorhanden und valide + if website and simple_normalize_url(website) != "k.A.": + # Fuegen Sie die Domain als zusaetzlichen Suchterm hinzu, um die Relevanz zu verbessern + query = f'{company_name} Wikipedia {simple_normalize_url(website)}' + logger.info(f"Starte SerpAPI Wikipedia-Suche fuer '{company_name}' mit Query: '{query[:100]}...'") # Logge gekuerzte Query + + params = { + "engine": "google", + "q": query, + "api_key": serp_key, + "hl": "de", # Host Language (Sprache der Benutzeroberflaeche) + "gl": "de", # Geo Location (Land) + "num": 10 # Top 10 Ergebnisse pruefen + } + api_url = "https://serpapi.com/search" + + try: + # Der Requests Call wird vom retry_on_failure Decorator behandelt. + # Timeout sollte aus Config kommen. + response = requests.get(api_url, params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) + response.raise_for_status() # Wirft HTTPError fuer 4xx/5xx Antworten. Wird vom Decorator gefangen. + data = response.json() + + candidates = [] # Liste von Dictionaries: {'url': str, 'title': str} + if "organic_results" in data: + logger.debug(f" -> Pruefe {len(data['organic_results'])} organische Ergebnisse...") + # Iteriere durch alle organischen Ergebnisse + for result in data["organic_results"]: + link = result.get("link") + # Filtere gueltige Wiki-Artikel-Links (bevorzuge de oder en Wikipedia) + if link and isinstance(link, str) and "wikipedia.org/wiki/" in link.lower() \ + and (link.lower().startswith("https://de.wikipedia.org") or link.lower().startswith("https://en.wikipedia.org")) \ + and not any(x in link.lower() for x in ['datei:', 'spezial:', 'portal:', 'hilfe:', 'diskussion:', 'template:']): # Filter fuer unerwuenschte Wiki-Seiten + try: + # Extrahiere den Artikel-Titel aus der URL + title_part = link.split('/wiki/', 1)[1] + # Handle eventuelle Anker (#) am Ende der URL + title_part = title_part.split('#')[0] + # Dekodiere URL-kodierte Zeichen und ersetze Unterstriche durch Leerzeichen + title = unquote(title_part).replace('_', ' ') + candidates.append({'url': link, 'title': title}) + # logger.debug(f" -> Kandidat gefunden: '{title}' ({link})") # Zu viel Laerm im Debug + except Exception as e_title_extract: + # Logge Fehler bei der Titel-Extraktion, aber fahre mit naechstem Kandidaten fort + logger.debug(f" -> Fehler beim Extrahieren des Titels aus Link {link[:100]}...: {e_title_extract}") + continue # Pruefe naechsten Kandidaten + + # Wenn keine Kandidaten gefunden wurden + if not candidates: + logger.warning(f" -> SerpAPI: Keine de/en Wikipedia-Kandidaten-URLs in Ergebnissen fuer '{company_name}' gefunden.") + return None # Signalisiert, dass kein passender Artikel gefunden wurde + + # Bewerte Kandidaten nach Relevanz + best_match_url = None + highest_score = -1.0 + # Normalisiere den Suchnamen des Unternehmens (nutzt globale Helfer) + normalized_search_name = normalize_company_name(company_name) + + logger.debug(f" -> Bewerte {len(candidates)} Kandidaten...") + # Iteriere durch die gesammelten Kandidaten + for cand in candidates: + url = cand['url'] + title = cand['title'] + try: + # Normalisiere den Titel des Kandidaten (nutzt globale Helfer) + normalized_title = normalize_company_name(title) + title_lower = title.lower() # Fuer Keyword-Suche + except Exception as e_norm: + # Logge Fehler bei der Normalisierung des Titels, aber ueberspringe diesen Kandidaten + logger.warning(f"Fehler beim Normalisieren des Titels '{title[:100]}...': {e_norm}. Ueberspringe Kandidatenbewertung.") + continue # Ueberspringe diesen Kandidaten + + # 1. Basisscore: Titelaehnlichkeit (Normalisierte Namen verwenden) + similarity = SequenceMatcher(None, normalized_title, normalized_search_name).ratio() + score = similarity + # logger.debug(f" -> Kandidat '{title[:100]}...': Basis-Aehnlichkeit={similarity:.2f}") # Zu viel Laerm im Debug + + # 2. Bonus fuer Keywords im Titel (z.B. "(Unternehmen)", Rechtsformen) + bonus = 0.0 + if "(unternehmen)" in title_lower: + bonus += 0.2 # Starker Bonus fuer eindeutige Kennzeichnung + # logger.debug(" -> Bonus +0.2 fuer '(unternehmen)'") # Zu viel Laerm im Debug + # Pruefe auf gaengige Rechtsformen am Ende des Titels + elif re.search(r'\b(?:gmbh|ag|kg|ltd|inc|corp|s\.?a\.?|se|group|holding)\b$', title_lower): # Regex fuer Wortgrenze am Ende + bonus += 0.1 # Kleinerer Bonus + # logger.debug(" -> Bonus +0.1 fuer Rechtsform/Gruppen-Keyword") # Zu viel Laerm im Debug + + + # 3. Bonus fuer Sprache (Deutsch bevorzugt) + if url.lower().startswith("https://de.wikipedia.org"): + bonus += 0.05 + # logger.debug(" -> Bonus +0.05 fuer de.wikipedia.org") # Zu viel Laerm im Debug + + # Gesamtscore ist Basis-Aehnlichkeit plus Boni + total_score = score + bonus + # logger.debug(f" -> Gesamtscore fuer '{title[:100]}...': {total_score:.3f} (Aehnlichkeit={similarity:.2f}, Bonus={bonus:.2f})") # Zu viel Laerm im Debug + + + # Aktualisiere besten Treffer, wenn der aktuelle Kandidat einen hoeheren Score hat + # UND der Gesamtscore ueber dem definierten Mindestscore liegt. + if total_score > highest_score and total_score >= min_score: + highest_score = total_score + best_match_url = url # Speichere die URL des besten Treffers + logger.debug(f" ====> Neuer bester Kandidat: {best_match_url[:100]}... (Score: {highest_score:.3f}) ====") + + + # Wenn nach Pruefung aller Kandidaten ein bester Treffer gefunden wurde (Score >= min_score) + if best_match_url: + logger.info(f" -> SerpAPI: Bester relevanter Wikipedia-Link ausgewaehlt: {best_match_url[:100]}... (Score: {highest_score:.3f})") + return best_match_url # Gebe die gefundene URL zurueck + else: + # Wenn keiner der Kandidaten den Mindestscore erreicht hat + logger.warning(f" -> SerpAPI: Keiner der {len(candidates)} Kandidaten erreichte den Mindestscore ({min_score}) fuer '{company_name}'.") + return None # Signalisiert, dass kein passender Artikel gefunden wurde + + except Exception as e: # retry_on_failure wirft Exception im Fehlerfall erneut + # Fangen Sie alle anderen unerwarteten Exceptions ab. + # Der Fehler wird bereits vom retry_on_failure Decorator geloggt. + logger.error(f"FEHLER bei der SerpAPI Wikipedia Suche fuer '{company_name}': {e}") + # Werfen Sie die Exception erneut, damit der retry_on_failure Decorator sie handhaben kann (z.B. loggen beim endgueltigen Scheitern). + raise e # Leite die Exception weiter @retry_on_failure def serp_website_lookup(company_name): """ - Ermittelt die offizielle Website eines Unternehmens über SerpAPI (Google Suche). - Gibt die normalisierte URL zurück oder "k.A.". + Ermittelt die offizielle Website eines Unternehmens ueber SerpAPI (Google Suche). + Gibt die normalisierte URL zurueck oder "k.A.". + + Args: + company_name (str): Der Name des Unternehmens. + + Returns: + str: Die normalisierte Website URL oder "k.A." bei Fehler/nicht gefunden. + Wirft Exception bei API-Fehlern nach Retries. """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist serp_key = Config.API_KEYS.get('serpapi') if not serp_key: - logger.error("Fehler: SerpAPI Key nicht verfügbar für Website Lookup.") - # Werfen Sie eine Exception, damit retry_on_failure dies behandelt (oder nicht, je nach Config) + logger.error("Fehler: SerpAPI Key nicht verfuegbar fuer Website Lookup.") + # Werfen Sie eine spezifische Exception, die vom retry_on_failure behandelt wird raise ConnectionRefusedError("SerpAPI Key nicht konfiguriert.") if not company_name or str(company_name).strip() == "": logger.warning("serp_website_lookup: Kein Firmenname angegeben.") # Werfen Sie einen ValueError - raise ValueError("Kein Firmenname für SerpAPI Website Lookup angegeben.") + raise ValueError("Kein Firmenname fuer SerpAPI Website Lookup angegeben.") + + + # Blacklist unerwuenschter Domains (kann in Config verschoben werden) + # Diese Domains sind in der Regel keine offiziellen Unternehmenswebsites. + blacklist = ["bloomberg.com", "northdata.de", "finanzen.net", "handelsblatt.com", "wikipedia.org", "linkedin.com", "xing.com", "youtube.com", "facebook.com", "twitter.com", "instagram.com", "glassdoor.com", "kununu.com"] + + # Query anpassen fuer bessere Ergebnisse (suche nach der "offiziellen Website") + query = f'{company_name} offizielle Website' + # Fuegen Sie optional den Ort hinzu, wenn verfuegbar (muesste als Argument uebergeben werden) + # if city and city != "k.A.": query = f'{company_name} offizielle Website {city}' + logger.info(f"Starte SerpAPI Website-Suche fuer '{company_name}' mit Query: '{query[:100]}...'") # Logge gekuerzte Query - # Blacklist unerwünschter Domains (kann in Config verschoben werden) - blacklist = ["bloomberg.com", "northdata.de", "finanzen.net", "handelsblatt.com", "wikipedia.org", "linkedin.com", "xing.com", "youtube.com", "facebook.com", "twitter.com", "instagram.com"] - query = f'{company_name} offizielle Website' # Präzisere Query params = { "engine": "google", "q": query, "api_key": serp_key, - "hl": "de", # Host Language (Sprache der Benutzeroberfläche) + "hl": "de", # Host Language (Sprache der Benutzeroberflaeche) "gl": "de", # Geo Location (Land) "safe": "active" # SafeSearch aktivieren } api_url = "https://serpapi.com/search" try: - # Der Requests Call wird vom retry_on_failure Decorator behandelt - response = requests.get(api_url, params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) # Konfigurierbarer Timeout - response.raise_for_status() # Wirft HTTPError für schlechte Antworten + # Der Requests Call wird vom retry_on_failure Decorator behandelt. + # Timeout sollte aus Config kommen. + response = requests.get(api_url, params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) + response.raise_for_status() # Wirft HTTPError fuer 4xx/5xx Antworten. Wird vom Decorator gefangen. data = response.json() - # 1. Knowledge Graph prüfen (oft die offizielle Seite) + # 1. Knowledge Graph pruefen (oft die offizielle Seite) + # Dies ist oft der zuverlaessigste Treffer if "knowledge_graph" in data and "website" in data["knowledge_graph"]: kg_url = data["knowledge_graph"].get("website") - if kg_url: - # Prüfen Blacklist VOR Normalisierung + if kg_url and isinstance(kg_url, str): # Stelle sicher, dass kg_url ein String ist + # Pruefen Blacklist VOR Normalisierung if any(bad_domain in kg_url.lower() for bad_domain in blacklist): - logger.debug(f" -> SerpAPI Website Lookup: KG URL '{kg_url}' auf Blacklist. Übersprungen.") + logger.debug(f" -> SerpAPI Website Lookup: KG URL '{kg_url[:100]}...' auf Blacklist. Uebersprungen.") # Gekuerzt loggen else: - normalized_url = simple_normalize_url(kg_url) # Nutzt globale Funktion + # Normalisiere die URL (nutzt globale Helfer) + normalized_url = simple_normalize_url(kg_url) + # Wenn die Normalisierung erfolgreich war (kein "k.A.") if normalized_url != "k.A.": - logger.info(f"SERP Lookup: Website '{normalized_url}' aus Knowledge Graph für '{company_name}' gefunden.") - return normalized_url # Erfolgreich gefunden und zurückgegeben + logger.info(f"SERP Lookup: Website '{normalized_url}' aus Knowledge Graph fuer '{company_name}' gefunden.") + return normalized_url # Erfolgreich gefunden und zurueckgegeben - # 2. Organische Ergebnisse prüfen + # 2. Organische Ergebnisse pruefen if "organic_results" in data: - # Iteriere durch die ersten Ergebnisse - for result in data["organic_results"][:5]: # Prüfe nur die Top 5 organischen Ergebnisse + # Iteriere durch die ersten Ergebnisse (pruefe nur die Top N Ergebnisse) + # N ist hier fest auf 5 gesetzt, kann aber angepasst werden. + for result in data["organic_results"][:5]: url = result.get("link", "") title = result.get("title", "") # Titel kann Kontext geben snippet = result.get("snippet", "") # Snippet kann Kontext geben - # Filtere: Muss gültige URL sein, darf nicht auf Blacklist sein, muss http/https starten - if url and url.lower().startswith(("http://", "https://")) and not any(bad_domain in url.lower() for bad_domain in blacklist): + # Filtere: Muss gueltige URL sein, darf nicht auf Blacklist sein, muss http/https starten + if url and isinstance(url, str) and url.lower().startswith(("http://", "https://")) and not any(bad_domain in url.lower() for bad_domain in blacklist): - normalized_url = simple_normalize_url(url) # Nutzt globale Funktion + # Normalisiere die URL (nutzt globale Helfer) + normalized_url = simple_normalize_url(url) + # Wenn die Normalisierung erfolgreich war (kein "k.A.") if normalized_url != "k.A.": - # Zusätzliche Plausibilitätsprüfung: Enthält die Domain Teile des Firmennamens? - # Oder ist der Firmenname im Titel/Snippet? + # Zusaetzliche Plausibilitaetspruefung: Ist die Domain oder der Firmenname + # im Titel oder Snippet des Suchergebnisses relevant fuer das Unternehmen? # normalize_company_name nutzt globale Funktion normalized_company = normalize_company_name(company_name) - domain_part_normalized = normalized_url.replace('www.', '').split('.')[0] # Erster Teil der Domain + # Extrahieren Sie den ersten Teil der Domain (vor dem ersten Punkt) + domain_part_normalized = normalized_url.replace('www.', '').split('.')[0] title_lower = title.lower() snippet_lower = snippet.lower() - # Prüfe, ob der normalisierte Domain-Teil im normalisierten Firmennamen enthalten ist + # Pruefe, ob der normalisierte Domain-Teil im normalisierten Firmennamen enthalten ist domain_name_match = domain_part_normalized in normalized_company - # Prüfe, ob der normalisierte Firmenname im Titel oder Snippet vorkommt - name_in_result_text = normalized_company in title_lower or normalized_company in snippet_lower - # Definieren Sie Kriterien für einen guten Treffer im organischen Ergebnis + # Pruefe, ob der normalisierte Firmenname (oder Teile davon) im Titel oder Snippet vorkommt + # Kann auch auf Kurzform pruefen + name_in_result_text = normalized_company in title_lower or normalized_company in snippet_lower # Oder eine praezisere Fuzzy-Suche + + # Definieren Sie Kriterien fuer einen guten Treffer im organischen Ergebnis + # Eine gute URL sollte entweder einen Domain/Name-Match haben ODER der Firmenname sollte prominent im Ergebnis-Text vorkommen. if domain_name_match or name_in_result_text: - logger.info(f"SERP Lookup: Website '{normalized_url}' aus Organic Results für '{company_name}' gefunden (Domain/Name Match).") - return normalized_url # Erfolgreich gefunden und zurückgegeben + logger.info(f"SERP Lookup: Website '{normalized_url}' aus Organic Results fuer '{company_name}' gefunden (Domain/Name Match oder Text-Match).") + return normalized_url # Erfolgreich gefunden und zurueckgegeben else: - # Loggen Sie, warum die URL übersprungen wurde (nur auf Debug) - logger.debug(f" -> SerpAPI Website Lookup: URL '{normalized_url}' übersprungen (Domain/Name Match fehlgeschlagen). Domain='{domain_part_normalized}', Name='{normalized_company}'.") - # Fahren Sie fort, um den nächsten organischen Treffer zu prüfen + # Loggen Sie, warum die URL uebersprungen wurde (nur auf Debug) + # logger.debug(f" -> SerpAPI Website Lookup: URL '{normalized_url}' uebersprungen (Domain/Name Match oder Text-Match fehlgeschlagen). Domain='{domain_part_normalized}', Name='{normalized_company}'.") # Zu viel Laerm + pass # Fahren Sie fort, um den naechsten organischen Treffer zu pruefen - # Wenn die Schleife durchläuft und keine passende URL gefunden wurde - logger.info(f"SERP Lookup: Keine passende Website für '{company_name}' gefunden nach Prüfung KG und Top Organic Results.") + # Wenn die Schleife durchlaeuft und kein passender Treffer gefunden wurde (weder KG noch Organic) + logger.info(f"SERP Lookup: Keine passende Website fuer '{company_name}' gefunden nach Pruefung KG und Top Organic Results.") return "k.A." # Signalisiert, dass keine passende URL gefunden wurde except Exception as e: # retry_on_failure wirft Exception im Fehlerfall erneut - # Loggen Sie den Fehler (wird vom retry_on_failure geloggt) - logger.error(f"FEHLER bei der SerpAPI Website Suche für '{company_name}': {e}") - # Geben Sie einen Fehlerwert zurück oder "k.A." - return "k.A. (Fehler Suche)" # Signalisiert Fehler bei der Suche + # Fangen Sie alle anderen unerwarteten Exceptions ab. + # Der Fehler wird bereits vom retry_on_failure Decorator geloggt. + logger.error(f"FEHLER bei der SerpAPI Website Suche fuer '{company_name}': {e}") + # Geben Sie einen Fehlerwert zurueck oder "k.A." + return f"k.A. (Fehler Suche: {str(e)[:100]}...)" # Signalisiert Fehler bei der Suche @retry_on_failure def search_linkedin_contacts(company_name, website, position_query, crm_kurzform, num_results=10): """ - Sucht LinkedIn Kontakte für ein Unternehmen und eine Position via SerpAPI (Google). - Gibt eine Liste von Kontakt-Dictionaries zurück. + Sucht LinkedIn Kontakte fuer ein Unternehmen und eine Position ueber SerpAPI (Google). + Gibt eine Liste von Kontakt-Dictionaries zurueck. + + Args: + company_name (str): Der volle Unternehmensname. + website (str): Die Website des Unternehmens (fuer Email-Generierung). + position_query (str): Der Suchbegriff fuer die Position (z.B. "Serviceleiter"). + crm_kurzform (str): Die Kurzform des Firmennamens (wichtig fuer Suchgenauigkeit). + num_results (int, optional): Anzahl der Suchergebnisse pro Query. Defaults to 10. + + Returns: + list: Eine Liste von Dictionaries, jedes repraesentiert einen gefundenen Kontakt. + Leere Liste bei Fehler oder nicht gefunden. + Wirft Exception bei API-Fehlern nach Retries. """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist serp_key = Config.API_KEYS.get('serpapi') if not serp_key: - logger.error("Fehler: SerpAPI Key nicht verfügbar für LinkedIn Suche.") + logger.error("Fehler: SerpAPI Key nicht verfuegbar fuer LinkedIn Suche.") raise ConnectionRefusedError("SerpAPI Key nicht konfiguriert.") - if not all([company_name, position_query, crm_kurzform]) or not all(isinstance(x, str) for x in [company_name, position_query, crm_kurzform]): - logger.warning(f"search_linkedin_contacts: Fehlende oder ungültige Eingabedaten (Name, Position, Kurzform).") - raise ValueError("Fehlende oder ungültige Eingabedaten für LinkedIn Suche.") + # Grundlegende Pruefung der Eingaben + if not all([company_name, position_query, crm_kurzform]) or not all(isinstance(x, str) and x.strip() for x in [company_name, position_query, crm_kurzform]): + logger.warning(f"search_linkedin_contacts: Fehlende oder ungueltige Eingabedaten (Name, Position, Kurzform sind Pflicht).") + # Werfen Sie einen ValueError + raise ValueError("Fehlende oder ungueltige Eingabedaten fuer LinkedIn Suche.") - # Query anpassen für bessere Ergebnisse + # Query anpassen fuer bessere Ergebnisse # Suche nach "[Position]" UND "[Firmenkurzform]" auf der LinkedIn /in/ Seite - # crm_kurzform ist oft im Titel oder der Beschreibung - query = f'site:linkedin.com/in/ "{position_query}" "{crm_kurzform}"' - # Optional: Fügen Sie den vollen Firmennamen hinzu, kann aber die Ergebnisse einschränken - # query = f'site:linkedin.com/in/ "{position_query}" "{crm_kurzform}" "{company_name}"' + # Die Firmenkurzform ist oft im Titel oder der Beschreibung des Profils + query = f'site:linkedin.com/in/ "{position_query.strip()}" "{crm_kurzform.strip()}"' + # Optional: Fuegen Sie den vollen Firmennamen hinzu als zusaetzlichen Begriff, + # kann aber die Ergebnisse stark einschraenken, wenn der volle Name lang ist oder variiert. + # query = f'site:linkedin.com/in/ "{position_query.strip()}" "{crm_kurzform.strip()}" "{company_name.strip()}"' + logger.info(f"Starte SerpAPI LinkedIn-Suche fuer '{crm_kurzform}' (Position: '{position_query}') mit Query: '{query[:100]}...'") # Logge gekuerzte Query + params = { "engine": "google", @@ -1370,129 +1915,313 @@ def search_linkedin_contacts(company_name, website, position_query, crm_kurzform "api_key": serp_key, "hl": "de", # Host Language "gl": "de", # Geo Location - "num": num_results # Anzahl der Ergebnisse pro SerpAPI Call + "num": num_results # Anzahl der Ergebnisse pro SerpAPI Call (max 100, aber oft weniger geliefert) } api_url = "https://serpapi.com/search" found_contacts = [] # Liste zur Sammlung der gefundenen Kontakte try: - # Der Requests Call wird vom retry_on_failure Decorator behandelt - response = requests.get(api_url, params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) # Konfigurierbarer Timeout - response.raise_for_status() # Wirft HTTPError für schlechte Antworten + # Der Requests Call wird vom retry_on_failure Decorator behandelt. + # Timeout sollte aus Config kommen. + response = requests.get(api_url, params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) + response.raise_for_status() # Wirft HTTPError fuer 4xx/5xx Antworten. Wird vom Decorator gefangen. data = response.json() if "organic_results" in data: # Gehe durch die organischen Suchergebnisse for result in data["organic_results"]: - title = result.get("title", "") - linkedin_url = result.get("link", "") + title = result.get("title", "") # Titel des Suchergebnisses (oft Name - Position bei Firma) + linkedin_url = result.get("link", "") # Die URL des LinkedIn Profils snippet = result.get("snippet", "") # Snippet kann Position oder Firma enthalten - # Filtere: Muss eine LinkedIn Profil-URL sein und die Kurzform muss im Titel vorkommen - # oder eine hohe Namensähnlichkeit aufweisen - if not linkedin_url or "linkedin.com/in/" not in linkedin_url or "/sales/" in linkedin_url: - #logger.debug(f" -> LinkedIn Treffer übersprungen (kein Profil-URL): {linkedin_url}") # Zu viel Lärm + # Filtere: Muss eine LinkedIn Profil-URL sein und darf kein "sales" Profil sein + if not linkedin_url or not isinstance(linkedin_url, str) or "linkedin.com/in/" not in linkedin_url.lower() or "/sales/" in linkedin_url.lower(): + #logger.debug(f" -> LinkedIn Treffer uebersprungen (kein Profil-URL oder Sales): {linkedin_url[:100]}...") # Gekuerzt loggen continue - # Prüfe, ob die Firmenkurzform im Titel oder Snippet vorkommt - # Oder ob der Titel eine hohe Ähnlichkeit mit "[Name] - [Position] bei [Kurzform]" hat + # Zusaetzliche Relevanzpruefung: + # Die Firmenkurzform ODER der gesuchte Positionsterm MUSS im Titel oder Snippet vorkommen. title_lower = title.lower() snippet_lower = snippet.lower() crm_kurzform_lower = crm_kurzform.lower() position_query_lower = position_query.lower() - kurzform_in_text = crm_kurzform_lower in title_lower or crm_kurzform_lower in snippet_lower + # Pruefe, ob Kurzform ODER Position im Titel oder Snippet vorkommen + is_relevant_result = (crm_kurzform_lower in title_lower or crm_kurzform_lower in snippet_lower) or \ + (position_query_lower in title_lower or position_query_lower in snippet_lower) - # Vereinfachte Namens-/Positionsextraktion aus dem Titel + # Wenn das Suchergebnis nicht relevant erscheint, ueberspringe es + if not is_relevant_result: + #logger.debug(f" -> LinkedIn Treffer uebersprungen (nicht relevant fuer '{crm_kurzform}'/{position_query}): '{title[:100]}...'") # Gekuerzt loggen + continue + + + # --- Extrahiere Name und Position aus dem Titel (Heuristik) --- name_part = "" - pos_part = position_query # Fallback - - # Versuche gängige Trennzeichen im Titel (z.B. Name - Position | Firma) - separators = ["–", "-", "|", " at ", " bei "] - title_cleaned = title.replace("...", "").strip() + pos_part = position_query # Fallback fuer die Position + # Versuche gaengige Trennzeichen im Titel (z.B. Name - Position | Firma) + separators = [" – ", " - ", " | ", " at ", " bei "] # Laengere Trenner zuerst + title_cleaned = title.replace("...", "").strip() # Bereinige gaengige Zeichen found_sep = False for sep in separators: if sep in title_cleaned: parts = title_cleaned.split(sep, 1) - name_part = parts[0].strip() - # Versuche, LinkedIn/Profil etc. aus Namen zu entfernen - name_part = re.sub(r'[\s|\-]*LinkedIn[\s|\-]*Profile.*$', '', name_part, flags=re.IGNORECASE).strip() - name_part = re.sub(r'[\s|\-]*LinkedIn$', '', name_part, flags=re.IGNORECASE).strip() + name_part = parts[0].strip() # Teil vor dem ersten Trenner ist wahrscheinlich der Name - - # Positionsteil ist alles nach dem ersten Trenner + # Der Teil nach dem ersten Trenner enthaelt wahrscheinlich Position und Firma potential_pos_company = parts[1].strip() - # Versuche, Firmennamen-Teile (Kurzform) und LinkedIn-Suffixe zu entfernen - pos_company_cleaned = re.sub(r'[\s|\-]*LinkedIn[\s|\-]*Profile.*$', '', potential_pos_company, flags=re.IGNORECASE).strip() - pos_company_cleaned = re.sub(r'[\s|\-]*LinkedIn$', '', pos_company_cleaned, flags=re.IGNORECASE).strip() + # Versuche, LinkedIn-Suffixe zu entfernen (z.B. " | LinkedIn") + potential_pos_company = re.sub(r'[\s|\-]*LinkedIn[\s|\-]*Profile.*$', '', potential_pos_company, flags=re.IGNORECASE).strip() + potential_pos_company = re.sub(r'[\s|\-]*LinkedIn$', '', potential_pos_company, flags=re.IGNORECASE).strip() - # Entferne die Firmenkurzform, wenn sie im Positionsteil vorkommt - if crm_kurzform_lower in pos_company_cleaned.lower(): - # Ersetze nur die erste gefundene Instanz der Kurzform (ganzes Wort) - pos_company_cleaned = re.sub(r'\b' + re.escape(crm_kurzform_lower) + r'\b', '', pos_company_cleaned, flags=re.IGNORECASE).strip() + + # Versuche, die Firmenkurzform aus dem Position/Firma-Teil zu entfernen (ganzes Wort) + if crm_kurzform_lower in potential_pos_company.lower(): + # Ersetze nur die erste gefundene Instanz der Kurzform + pos_company_cleaned = re.sub(r'\b' + re.escape(crm_kurzform_lower) + r'\b', '', potential_pos_company, flags=re.IGNORECASE).strip() pos_company_cleaned = re.sub(r'\s+', ' ', pos_company_cleaned).strip() # Leerzeichen reduzieren nach Entfernung + else: + pos_company_cleaned = potential_pos_company # Behalte den Teil, wenn Kurzform nicht gefunden - pos_part = pos_company_cleaned if pos_company_cleaned else position_query + + pos_part = pos_company_cleaned if pos_company_cleaned else position_query # Nimm den bereinigten Teil oder den Suchbegriff found_sep = True - break + break # Hoere beim ersten gefundenen Trennzeichen auf - if not found_sep: # Kein Trennzeichen gefunden, versuche andere Muster - # Muster: "[Name] [Position_Query] - LinkedIn" + + # Wenn kein Trennzeichen gefunden wurde, versuche andere einfache Muster oder nimm den ganzen Titel als Name + if not found_sep: + # Muster: "[Name] [Position_Query]" oder "[Name] - LinkedIn" etc. + # Versuche, den gesuchten Positionsterm aus dem Titel zu entfernen, um den Namen zu isolieren if position_query_lower in title_lower: - # Split am Position_Query, nimm den Teil davor als Namen name_before_pos = title_lower.split(position_query_lower, 1)[0].strip() - name_part = title_cleaned[:len(name_before_pos)].strip() # Nimm Originaltext bis zur Position + name_part = title_cleaned[:len(name_before_pos)].strip() # Nimm Originaltext bis zum Beginn des Positionsterms + pos_part = position_query # Position ist der Suchbegriff - # Teile Namen in Vor- und Nachname (einfache Annahme) + else: + # Wenn der Positionsterm nicht gefunden wurde und kein Trenner da war, + # nimm den gesamten Titel vor " - LinkedIn" als Name (weniger zuverlaessig) + name_part = re.sub(r'[\s|\-]*LinkedIn[\s|\-]*Profile.*$', '', title_cleaned, flags=re.IGNORECASE).strip() + name_part = re.sub(r'[\s|\-]*LinkedIn$', '', name_part, flags=re.IGNORECASE).strip() + pos_part = position_query # Position bleibt der Suchbegriff + + + # Teile den extrahierten Namen in Vor- und Nachname (einfache Annahme: erstes Wort = Vorname) firstname = "" lastname = "" name_parts = name_part.split() if len(name_parts) > 1: firstname = name_parts[0] - lastname = " ".join(name_parts[1:]) + lastname = " ".join(name_parts[1:]) # Der Rest ist Nachname elif len(name_parts) == 1: firstname = name_parts[0] # Nur Vorname gefunden? - - if not firstname or not name_part: # Wenn Name nicht extrahiert werden konnte, überspringe - # self.logger.debug(f"LinkedIn Treffer übersprungen: Name konnte nicht extrahiert werden aus Titel '{title}'") # Zu viel Lärm + # Wenn der Name leer ist (z.B. nach Entfernung von Rechtsformen), ueberspringe diesen Kontakt + if not firstname or not name_part.strip(): + #logger.debug(f"LinkedIn Treffer uebersprungen: Name konnte nicht extrahiert werden aus Titel '{title[:100]}...'.") # Zu viel Laerm im Debug continue - # Zusätzliche Plausibilitätsprüfung: Position Query muss im Titel oder Snippet vorkommen ODER Kurzform muss im Titel/Snippet sein - position_in_text = position_query_lower in title_lower or position_query_lower in snippet_lower - # Akzeptiere den Kontakt, wenn (Position oder Kurzform in Text) UND Name extrahiert wurde - if position_in_text or kurzform_in_text: - contact_data = { - "Firmenname": company_name, # Originalname für Kontext - "CRM Kurzform": crm_kurzform, - "Website": website, # Website der Firma - "Vorname": firstname, - "Nachname": lastname, - "Position": pos_part, # Extrahierte oder Fallback Position - "LinkedInURL": linkedin_url + # Wenn wir bis hierher gekommen sind, scheint es ein gueltiger Kontakt zu sein. + contact_data = { + # Fuer die spaetere Verarbeitung (Gender, Email) + "Firmenname": company_name, # Originalname fuer Kontext + "CRM Kurzform": crm_kurzform, + "Website": website, # Website der Firma (fuer Email-Generierung) + "Vorname": firstname, + "Nachname": lastname, + "Position": pos_part, # Extrahierte oder Fallback Position + "LinkedInURL": linkedin_url } - found_contacts.append(contact_data) - # self.logger.debug(f"Gefundener LinkedIn Kontakt: {firstname} {lastname} - {pos_part} (URL: {linkedin_url})") # Zu viel Lärm - # else: self.logger.debug(f"LinkedIn Treffer übersprungen (kein Position/Kurzform Match in Text): '{title}'") # Zu viel Lärm + found_contacts.append(contact_data) + # logger.debug(f" -> Gefundener LinkedIn Kontakt: {firstname} {lastname} - {pos_part} (URL: {linkedin_url[:100]}...)") # Zu viel Laerm im Debug - logger.info(f"LinkedIn Suche für '{position_query}' bei '{crm_kurzform}' ergab {len(found_contacts)} Kontakte.") - return found_contacts # Gibt die Liste der gefundenen Kontakte zurück + # Wenn die Schleife durchlaeuft und Kontakte gefunden wurden + logger.info(f"LinkedIn Suche fuer '{position_query}' bei '{crm_kurzform}' ergab {len(found_contacts)} Kontakte.") + return found_contacts # Gibt die Liste der gefundenen Kontakte zurueck (leer bei nicht gefunden oder Fehler) except Exception as e: # retry_on_failure wirft Exception im Fehlerfall erneut - # Loggen Sie den Fehler (wird vom retry_on_failure geloggt) + # Fangen Sie alle anderen unerwarteten Exceptions ab. + # Der Fehler wird bereits vom retry_on_failure Decorator geloggt. logger.error(f"FEHLER bei der SerpAPI LinkedIn Suche (Query: '{position_query}', Firma: '{crm_kurzform}'): {e}") - # Geben Sie eine leere Liste zurück, da keine Kontakte gefunden wurden + # Geben Sie eine leere Liste zurueck, da bei Fehler keine Kontakte gefunden wurden return [] # Signalisiert Fehler bei der Suche +# ============================================================================== +# Ende SerpAPI Search Helpers Block +# ============================================================================== + +# ============================================================================== +# GLOBALE HELPER FUNCTIONS (PART 10: Website Raw Scraping Function) +# ============================================================================== + +# --- Globale Funktion zum Scrapen des Website Rohtextes --- +# Basierend auf get_website_raw aus Teil 7. Global platziert. +# Nutzt globale Helfer: simple_normalize_url, clean_text, re, requests, BeautifulSoup, Config, getattr, logger, retry_on_failure. +@retry_on_failure # Wende den Decorator auf diese Funktion an +def get_website_raw(url, max_length=20000, verify_cert=True): # Längeres Default Limit, SSL-Zertifikat standardmaessig pruefen + """ + Holt Textinhalt von einer Website, versucht Cookie-Banner zu umgehen. + Gibt den Rohtext zurück oder einen Fehlerwert ("k.A.", "k.A. (Fehler)", etc.). + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Pruefen Sie auf ungueltige oder leere URLs, inklusive spezifischer Fehlerwerte wie "http:" + if not url or not isinstance(url, str) or url.strip().lower() in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"]: + logger.debug(f"get_website_raw skipped: Ungueltige oder leere URL '{url}'.") + return "k.A." # Gebe "k.A." zurueck bei ungueltigen Eingaben + + # Falls kein Schema vorhanden ist, fuegen Sie HTTPS als Standard hinzu + if not url.lower().startswith(("http://", "https://")): + #logger.debug(f"Kein Schema in URL '{url}', fuege https:// hinzu.") # Zu viel Laerm im Debug + url = "https://" + url + + # Verwenden Sie eine Requests Session oder requests direkt. + # Eine Session in DataProcessor koennte besser sein, aber globale Funktion nutzt requests direkt. + headers = { + # Nutzt den User-Agent aus Config oder einen Fallback + "User-Agent": getattr(Config, 'USER_AGENT', 'Mozilla/5.0 (compatible; UnternehmenSkript/1.0; +https://www.example.com/bot)') + } + + try: + # Fuehren Sie die GET-Anfrage aus. Der retry_on_failure Decorator behandelt RequestsExceptions. + # Timeout sollte aus Config kommen. verify=verify_cert steuert die SSL-Pruefung. + response = requests.get(url, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15), headers=headers, verify=verify_cert) + # Wirft HTTPError fuer 4xx/5xx Antworten. Wird vom Decorator gefangen und (je nach Status) wiederholt oder als endgueltiger Fehler gemeldet. + response.raise_for_status() + + # Versuchen Sie, das Encoding aus dem Header oder dem Content zu erraten + response.encoding = response.apparent_encoding + + # Parsen Sie den HTML-Inhalt mit BeautifulSoup + # Nutzt den konfigurierten Parser aus Config oder einen Fallback + soup = BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser')) + + # --- Versuch 1: Hauptinhalt-Tags finden --- + # Verwenden Sie eine Liste von gaengigen Selektoren fuer den Hauptinhaltbereich. + content_selectors = [ + 'main', 'article', '#content', '#main-content', '.main-content', '.content', + 'div[role="main"]', 'div.page-content', 'div.container' # Weitere gaengige Selektoren + ] + content_area = None # Initialisieren mit None + for selector in content_selectors: + content_area = soup.select_one(selector) # Versuche, das erste Element zu finden + if content_area: + # logger.debug(f"Gezielten Inhaltsbereich gefunden mit Selektor '{selector}' fuer {url[:100]}...") # Logge den gefundenen Selektor (gekuerzt) + break # Hoere auf, sobald ein Bereich gefunden wurde + + # Wenn kein spezifischer Inhaltsbereich gefunden wurde + if not content_area: + # --- Fallback: Body nehmen, ABER Banner versuchen zu entfernen --- + # logger.debug(f"Kein spezifischer Inhaltsbereich gefunden fuer {url[:100]}... Nutze Body und versuche Banner zu entfernen.") # Logge den Fallback + content_area = soup.find('body') # Versuche, den Body-Tag zu finden + + # Wenn der Body-Tag gefunden wurde + if content_area: + # Versuche, haeufige Cookie-Banner Strukturen zu entfernen + # Diese Selektoren sollten angepasst werden, wenn spezifische Banner Probleme machen + banner_selectors = [ + '[id*="cookie"]', '[class*="cookie"]', '[id*="consent"]', '[class*="consent"]', + '.cookie-banner', '.consent-banner', '.modal', '#modal', '.popup', '#popup', + '[role="dialog"]', '[aria-modal="true"]' # Gaengige Rollen/Attribute fuer Dialoge + ] + banners_removed_count = 0 + # Gehe durch die gefundenen Elemente und versuche, Banner zu identifizieren und zu entfernen + for selector in banner_selectors: + try: + # select findet alle passenden Elemente fuer den aktuellen Selektor + potential_banners = content_area.select(selector) + for banner in potential_banners: + # Zusaetzliche Pruefung: Enthaehlt das Element typischen Banner-Text ODER relevante Klassen/IDs? + # Vermeiden Sie das Entfernen von echtem Inhalt, der zufaellig Banner-Keywords enthaelt. + banner_text = banner.get_text(" ", strip=True).lower() # Text des Elements + keywords = ["cookie", "zustimm", "ablehnen", "einverstanden", "datenschutz", "privacy", "akzeptier", "einstellung", "partner", "analyse", "marketing"] # Gaengige Keywords + element_id_class = (banner.get('id', '') + ' ' + ' '.join(banner.get('class', []))).lower() # ID und Klassen des Elements + + # Pruefe, ob ein Keyword im Text ODER in ID/Klassen vorkommt + if any(keyword in banner_text for keyword in keywords) or any(keyword in element_id_class for keyword in keywords): + # logger.debug(f"Entferne potenzielles Banner ({selector}) mit Text: {banner_text[:100]}... oder ID/Class: {element_id_class[:100]}...") # Logge Entfernung (gekuerzt) + banner.decompose() # Entferne das Element aus dem BeautifulSoup-Baum + banners_removed_count += 1 + except Exception as e_select: + # Logge Fehler bei der Banner-Entfernung auf Debug-Level + logger.debug(f"Fehler beim Versuch Banner mit Selektor '{selector}' zu entfernen: {e_select}") + if banners_removed_count > 0: + logger.debug(f"{banners_removed_count} potenzielle Banner-Elemente fuer {url[:100]}... entfernt.") + + + # --- Text extrahieren aus gefundenem Bereich (oder Body) --- + # Wenn ein Inhaltsbereich (oder der Body) gefunden wurde + if content_area: + # Entferne Skripte und Styles, bevor der Text extrahiert wird + for script_or_style in content_area(["script", "style"]): + script_or_style.decompose() + + # Extrahiere Text mit Leerzeichen als Trenner und bereinige Whitespace + text = content_area.get_text(separator=' ', strip=True) + text = re.sub(r'\s+', ' ', text).strip() # Reduziere multiple Leerzeichen und trimme Enden + + + # --- Zusaetzliche Pruefung: Ist der extrahierte Text *nur* Banner-Text? --- + # Diese Heuristik ist eine Fallback-Massnahme, wenn die Decompose-Logik nicht perfekt war. + banner_keywords_strict = ["cookie", "zustimmen", "ablehnen", "einverstanden", "datenschutz", "privacy", "akzeptier", "einstellung", "partner", "analyse", "marketing"] # Striktere Keywords + text_lower = text.lower() + # Zaehle, wie viele stricte Keywords im extrahierten Text vorkommen + keyword_hits = sum(1 for keyword in banner_keywords_strict if keyword in text_lower) + + # Heuristik: Wenn der Text kurz ist UND viele Banner-Keywords enthaelt -> Verwerfen + # Passen Sie die Schwellenwerte an, um false positives zu vermeiden. + # Eine Laenge unter 500 Zeichen und mindestens 3 stricte Keywords koennte ein Banner sein. + if len(text) < 500 and keyword_hits >= 3: + logger.warning(f"WARNUNG: Extrahierter Text fuer {url[:100]}... scheint nur Cookie-Banner zu sein (Laenge {len(text)}, {keyword_hits} Keywords). Verwerfe Text.") # Logge die Warnung + return "k.A. (Nur Cookie-Banner erkannt)" # Gebe spezifischen Fehlerwert zurueck + + # Wenn der Text nach Bereinigung immer noch sehr kurz ist (z.B. nur ein paar Worte), + # kann es sein, dass kein relevanter Inhalt gescrapt wurde. Logge dies optional. + if len(text.split()) < 10 or len(text) < 50: + # logger.debug(f"Extrahierter Text fuer {url[:100]}... ist sehr kurz ({len(text.split())} Worte, {len(text)} Zeichen).") # Zu viel Laerm im Debug + pass # Behalte den Text, keine weitere Filterung + + # Begrenzen Sie die Laenge des zurueckgegebenen Rohtextes (Optional, aber empfohlen) + result = text[:max_length] + # Loggen Sie den Erfolg des Scrapings + logger.debug(f"Website {url[:100]}... erfolgreich gescrapt. Extrahierter Text (Laenge {len(result)}).") + # logger.debug(f"Extrahierter Text Anfang: {result[:100]}...") # Logge den Anfang des Textes (gekuerzt) + # Gebe den gekuerzten Text zurueck, oder "k.A." wenn er leer ist + return result if result else "k.A. (Extraktion leer)" + + else: + # Wenn weder Body noch spezifischer Inhaltsbereich gefunden wurde + logger.warning(f"Kein oder spezifischer Inhaltsbereich gefunden in {url[:100]}...") # Logge die Warnung + return "k.A. (Kein Body gefunden)" # Gebe spezifischen Fehlerwert zurueck + + + # Exceptions (wie RequestsErrors) werden vom retry_on_failure Decorator behandelt. + # Wenn eine Exception hier durchkommt, hat der Decorator aufgegeben. + except Exception as e: # Fangen Sie alle verbleibenden Exceptions, die nicht vom Decorator behandelt wurden + # Logge den Fehler auf Error-Level + logger.error(f"Allgemeiner Fehler beim Scraping von {url[:100]}...: {type(e).__name__} - {e}") + # Die Exception wurde bereits vom retry_on_failure Decorator als finaler Fehler geloggt. + # Geben Sie einen Fehlerwert zurueck, der im Sheet gespeichert werden kann. + return f"k.A. (Fehler: {str(e)[:100]}...)" # Signalisiert Fehler (gekuerzt) + + +# ============================================================================== +# Ende Website Raw Scraping Funktion Block +# ============================================================================== + +# ============================================================================== +# GLOBALE HELPER FUNCTIONS (PART 11: Website Details Scraping Function) +# ============================================================================== + # --- Experimentelle Website Details Scraping Funktion --- -# Diese Funktion wurde in DataProcessor.process_website_details aufgerufen. -# Sie ist hier global platziert, da sie nicht spezifisch von DataProcessor state abhängt, -# sondern nur von globalen Helfern und Requests. +# Basierend auf scrape_website_details aus Teil 10. Global platziert. +# Nutzt globale Helfer: retry_on_failure, requests, BeautifulSoup, Config, getattr, clean_text, logger. +# Diese Funktion ist als experimentelles Dienstprogramm gedacht. # Ihre Implementierung hängt stark von der Struktur der Zielwebsites ab. +# Derzeit extrahiert sie nur grundlegende Meta-Informationen. def scrape_website_details(url): """ EXPERIMENTELL: Scrapt eine Website und extrahiert spezifische Details. @@ -1504,5459 +2233,129 @@ def scrape_website_details(url): Returns: str: Extrahierte Details als String oder Fehler/k.A. """ - logger.warning(f"Ausführe 'scrape_website_details' für URL {url}.") - # Beispiel: Einfaches Abrufen des Tags + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Pruefen Sie auf ungueltige oder leere URLs + if not url or not isinstance(url, str) or url.strip().lower() in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"]: + logger.debug(f"scrape_website_details skipped: Ungueltige oder leere URL '{url}'.") + return "k.A." # Gebe "k.A." zurueck bei ungueltigen Eingaben + + + logger.warning(f"Ausführe 'scrape_website_details' fuer URL {url[:100]}...") # Logge den Start auf Warning + + try: - # Hilfsfunktion zum Abrufen des Soup-Objekts mit Retry + # Hilfsfunktion zum Abrufen des Soup-Objekts mit Retry. + # Nutzt retry_on_failure, requests, BeautifulSoup, Config. @retry_on_failure def get_soup_for_details(target_url): - response = requests.get(target_url, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) + # Führen Sie die GET-Anfrage aus. Der retry_on_failure Decorator behandelt RequestsExceptions. + # Timeout sollte aus Config kommen. Standardmaessig pruefen wir SSL-Zertifikate. + response = requests.get(target_url, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15), verify=True) #verify=True Standard + # Wirft HTTPError fuer 4xx/5xx Antworten. Wird vom Decorator gefangen und (je nach Status) behandelt. response.raise_for_status() + # Versuchen Sie, das Encoding zu erraten response.encoding = response.apparent_encoding + # Parsen Sie den HTML-Inhalt return BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser')) + # Rufen Sie die Hilfsfunktion auf, um das Soup-Objekt zu erhalten soup = get_soup_for_details(url) + # Wenn das Soup-Objekt erfolgreich erhalten wurde if soup: - title = soup.find('title') - meta_desc = soup.find('meta', attrs={'name': 'description'}) - h1 = soup.find('h1') + # --- Extrahiere spezifische Details --- + # Versuchen Sie, wichtige Meta-Informationen zu extrahieren. + title = soup.find('title') # Titel-Tag + meta_desc = soup.find('meta', attrs={'name': 'description'}) # Meta Description + h1 = soup.find('h1') # Erstes H1-Tag - details_list = [] - # clean_text nutzt globale Funktion + details_list = [] # Liste zum Sammeln der extrahierten Details + + # Extrahieren und bereinigen Sie den Text (clean_text nutzt globale Funktion) if title: details_list.append(f"Title: {clean_text(title.get_text())}") + # Pruefen Sie, ob das 'content' Attribut existiert und nicht leer ist if meta_desc and meta_desc.get('content'): details_list.append(f"Description: {clean_text(meta_desc['content'])}") if h1: details_list.append(f"H1: {clean_text(h1.get_text())}") + # Wenn Details gefunden wurden if details_list: - return " | ".join(details_list) + # Verbinden Sie die Details mit einem Trennzeichen + result_string = " | ".join(details_list) + logger.debug(f"Details fuer {url[:100]}... extrahiert: {result_string[:100]}...") # Logge Ergebnis (gekuerzt) + return result_string # Gebe die extrahierten Details zurueck else: - return "k.A. (Keine Standard-Details gefunden)" + # Wenn keine Standard-Details gefunden wurden + logger.debug(f"Keine Standard-Details (Title, Description, H1) gefunden fuer {url[:100]}...") + return "k.A. (Keine Standard-Details gefunden)" # Gebe spezifischen Wert zurueck else: - # Fehler wurde bereits in get_soup_for_details oder retry geloggt - return "k.A. (Scraping fehlgeschlagen)" + # Wenn get_soup_for_details None zurueckgegeben hat (nach Retries fehlgeschlagen) + # Der Fehler wurde bereits in get_soup_for_details oder retry geloggt. + logger.error(f"Scraping fuer Details fehlgeschlagen nach Retries fuer {url[:100]}...") # Logge finalen Fehler + return "k.A. (Scraping fehlgeschlagen)" # Gebe spezifischen Fehlerwert zurueck - except Exception as e: # retry_on_failure wirft am Ende Exception - # Dieser Fehler wird bereits vom retry_on_failure geloggt - logger.error(f"FEHLER in scrape_website_details für {url}: {e}") - return f"FEHLER: {str(e)[:100]}" # Rückgabe der Fehlermeldung + # Fangen Sie alle anderen verbleibenden Exceptions ab (sollten selten sein) + # retry_on_failure auf get_soup_for_details behandelt die meisten Netzwerk-/HTTP-Fehler. + except Exception as e: + # Logge den Fehler auf Error-Level + logger.error(f"FEHLER in scrape_website_details fuer {url[:100]}...: {type(e).__name__} - {e}") + # Der Fehler wird bereits vom retry_on_failure geloggt, wenn er von get_soup_for_details kam. + # Hier wird nur ein Fehler gefangen, der aus der Logik dieser Funktion selbst kommt (z.B. Fehler beim String-Handling). + # Geben Sie einen Fehlerwert zurueck, der im Sheet gespeichert werden kann. + return f"k.A. (Fehler: {str(e)[:100]}...)" # Signalisiert Fehler (gekuerzt) -# --- Globale Funktion zum Scrapen des Website Rohtextes --- -# Übernommen aus get_website_raw in Teil 7. Global platziert. -# Nutzt globale Helfer: simple_normalize_url, clean_text, re, requests, BeautifulSoup, Config, getattr. -@retry_on_failure -def get_website_raw(url, max_length=20000, verify_cert=True): # Längeres Default Limit, SSL-Zertifikat standardmäßig prüfen - """ - Holt Textinhalt von einer Website, versucht Cookie-Banner zu umgehen. - Gibt den Rohtext zurück oder einen Fehlerwert ("k.A.", "k.A. (Fehler)", etc.). - """ - if not url or not isinstance(url, str) or url.strip().lower() in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"]: # Füge "http:" hinzu basierend auf Log - logger.debug(f"get_website_raw skipped: Ungültige oder leere URL '{url}'.") - return "k.A." - - # Falls kein Schema vorhanden ist, hinzufügen (HTTPS bevorzugen) - if not url.lower().startswith(("http://", "https://")): - #logger.debug(f"Kein Schema in URL '{url}', füge https:// hinzu.") # Zu viel Lärm - url = "https://" + url - - # Verwenden Sie eine Requests Session oder requests direkt. - # Eine Session in DataProcessor könnte besser sein, aber globale Funktion nutzt requests direkt. - headers = { - "User-Agent": getattr(Config, 'USER_AGENT', 'Mozilla/5.0 (compatible; UnternehmenSkript/1.0; +http://www.example.com/bot)') # Nutzt Config oder Fallback - } - - try: - # Der Requests Call wird vom retry_on_failure Decorator behandelt. - # Timeout sollte aus Config kommen. - response = requests.get(url, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15), headers=headers, verify=verify_cert) - response.raise_for_status() # Wirft HTTPError für 4xx/5xx Antworten. Wird vom Decorator gefangen. - - # Versuche, das Encoding aus dem Header oder dem Content zu erraten - response.encoding = response.apparent_encoding - - soup = BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser')) # Nutzt Config oder Fallback - - # --- Versuch 1: Hauptinhalt-Tags finden --- - # Verwenden Sie eine Liste von Selektoren - content_selectors = [ - 'main', 'article', '#content', '#main-content', '.main-content', '.content', - 'div[role="main"]', 'div.page-content', 'div.container' # Weitere gängige Selektoren - ] - content_area = None - for selector in content_selectors: - content_area = soup.select_one(selector) - if content_area: - #logger.debug(f"Gezielten Inhaltsbereich gefunden mit Selektor '{selector}' für {url}.") # Zu viel Lärm - break # Ersten gefundenen Bereich nehmen - - if not content_area: - # --- Fallback: Body nehmen, ABER Banner versuchen zu entfernen --- - #logger.debug(f"Kein spezifischer Inhaltsbereich gefunden für {url}. Nutze Body und versuche Banner zu entfernen.") # Zu viel Lärm - content_area = soup.find('body') - - if content_area: - # Versuche, häufige Cookie-Banner Strukturen zu entfernen - # Diese Selektoren sollten angepasst werden, wenn spezifische Banner Probleme machen - banner_selectors = [ - '[id*="cookie"]', '[class*="cookie"]', '[id*="consent"]', '[class*="consent"]', - '.cookie-banner', '.consent-banner', '.modal', '#modal', '.popup', '#popup', - '[role="dialog"]', '[aria-modal="true"]' - ] - banners_removed_count = 0 - # Gehe rückwärts durch die gefundenen Elemente, um Decompose sicher zu machen - for selector in banner_selectors: - try: - # select findet alle passenden Elemente - potential_banners = content_area.select(selector) - for banner in potential_banners: - # Zusätzliche Prüfung: Enthält das Element typischen Banner-Text? - # Vermeiden Sie das Entfernen von echtem Inhalt, der zufällig das Wort "cookie" enthält. - banner_text = banner.get_text(" ", strip=True).lower() - keywords = ["cookie", "zustimm", "ablehnen", "einverstanden", "datenschutz", "privacy", "akzeptier", "einstellung", "partner", "analyse", "marketing"] - # Prüfe, ob ein Keyword im Text ODER im class/id Namen vorkommt - if any(keyword in banner_text for keyword in keywords) or any(keyword in (banner.get('id', '') + banner.get('class', '')).lower() for keyword in keywords): - #logger.debug(f"Entferne potenzielles Banner ({selector}) mit Text: {banner_text[:100]}...") # Zu viel Lärm - banner.decompose() # Entferne das Element aus dem Baum - banners_removed_count += 1 - except Exception as e_select: - # Logge Fehler bei der Banner-Entfernung, aber fahre fort - logger.debug(f"Fehler beim Versuch Banner mit Selektor '{selector}' zu entfernen: {e_select}") - if banners_removed_count > 0: - logger.debug(f"{banners_removed_count} potenzielle Banner-Elemente für {url} entfernt.") - - # --- Text extrahieren aus gefundenem Bereich (oder Body) --- - if content_area: - # Entferne Skripte und Styles, bevor der Text extrahiert wird - for script_or_style in content_area(["script", "style"]): - script_or_style.decompose() - - # Extrahiere Text mit Leerzeichen als Trenner - text = content_area.get_text(separator=' ', strip=True) - text = re.sub(r'\s+', ' ', text).strip() # Normalisiere und trimme Whitespace - - # --- Zusätzliche Prüfung: Ist der extrahierte Text *nur* Banner-Text? --- - # Diese Heuristik ist eine Fallback-Maßnahme, wenn die Decompose-Logik nicht perfekt war. - banner_keywords_strict = ["cookie", "zustimmen", "ablehnen", "einverstanden", "datenschutz", "privacy", "akzeptier", "einstellung", "partner", "analyse", "marketing"] - text_lower = text.lower() - keyword_hits = sum(1 for keyword in banner_keywords_strict if keyword in text_lower) - - # Heuristik: Wenn der Text kurz ist UND viele Banner-Keywords enthält -> Verwerfen - # Passen Sie die Schwellenwerte an - if len(text) < 500 and keyword_hits >= 3: # Wenn Text kürzer als 500 Zeichen und >= 3 Keywords - logger.warning(f"WARNUNG: Extrahierter Text für {url} scheint nur Cookie-Banner zu sein (Länge {len(text)}, {keyword_hits} Keywords). Verwerfe Text.") - return "k.A. (Nur Cookie-Banner erkannt)" - - # Wenn der Text nach Bereinigung immer noch sehr kurz ist (z.B. nur ein paar Worte) - if len(text.split()) < 10 or len(text) < 50: - #logger.debug(f"Extrahierter Text für {url} ist sehr kurz ({len(text.split())} Worte, {len(text)} Zeichen).") # Zu viel Lärm - # Kann immer noch valide sein, aber ist oft kein relevanter Inhalt. - # Geben wir ihn trotzdem zurück, gekürzt. - - pass # Behalte den Text, keine weitere Filterung - - # Begrenzen Sie die Länge des zurückgegebenen Rohtextes - result = text[:max_length] - logger.debug(f"Website {url} erfolgreich gescrapt. Extrahierter Text (Länge {len(result)}).") - # logger.debug(f"Extrahierter Text Anfang: {result[:100]}...") # Zu viel Lärm - return result if result else "k.A. (Extraktion leer)" # Rückgabe des gekürzten Textes - - else: - logger.warning(f"Kein <body> oder spezifischer Inhaltsbereich gefunden in {url}.") - return "k.A. (Kein Body gefunden)" - - # Exceptions (wie RequestsErrors) werden vom retry_on_failure Decorator behandelt. - # Wenn eine Exception hier durchkommt, hat der Decorator aufgegeben. - except Exception as e: # Fangen Sie alle verbleibenden Exceptions, die nicht vom Decorator behandelt wurden - logger.error(f"Allgemeiner Fehler beim Scraping von {url}: {type(e).__name__} - {e}") - # Die Exception wurde bereits vom Decorator geloggt - return f"k.A. (Fehler: {str(e)[:100]}...)" # Signalisiert Fehler - -# TODO: Weitere globale Helferfunktionen (z.B. für FSM, Emp, Umsatz Schätzung Prompts und Parsing) müssen hier implementiert werden, -# falls sie nicht in den DataProcessor integriert wurden. Platzhalter wurden in DataProcessor._process_single_row hinzugefügt. - # ============================================================================== -# 4. GOOGLE SHEET HANDLER CLASS -# (Entspricht logisch etwa 'google_sheet_handler.py') +# Ende Website Details Scraping Funktion Block # ============================================================================== -class GoogleSheetHandler: - """ - Kapselt die Interaktionen mit dem Google Sheet, inklusive Verbindung, - Daten laden und Batch-Updates. Nutzt den retry_on_failure Decorator. - """ - def __init__(self): - """ - Initialisiert den Handler, stellt die Verbindung her und lädt die Daten. - """ - self.sheet = None - # Daten werden hier als Instanzvariable gespeichert, um nicht bei jedem Zugriff neu laden zu müssen - self.sheet_values = [] - # header_rows sind fix, aber wir können sie hier zur Klarheit definieren - self._header_rows = 5 # Annahme: Die ersten 5 Zeilen sind Header - - logger.info("Initialisiere GoogleSheetHandler...") - try: - # Verbindung wird bei der Initialisierung aufgebaut - self._connect() - # Daten werden ebenfalls bei der Initialisierung geladen - if self.sheet: - self.load_data() # Erste Datenladung nach erfolgreicher Verbindung - else: - # Wenn die Verbindung fehlschlug, aber keine Exception geworfen wurde - logger.critical("GoogleSheetHandler Init FEHLER: Verbindung konnte nicht hergestellt werden.") - # Hier wird keine Exception geworfen, da _connect und load_data Exceptions werfen, - # die von retry_on_failure oder der aufrufenden main-Funktion behandelt werden. - - except Exception as e: - # Fehler bei der Initialisierung werden hier gefangen und erneut geworfen, - # damit die main-Funktion entsprechend reagieren kann. - logger.critical(f"FATAL: Fehler bei Initialisierung von GoogleSheetHandler: {e}") - raise ConnectionError(f"Google Sheet Handler Init failed: {e}") # Signalisiert Verbindungsproblem - - - @retry_on_failure - def _connect(self): - """Stellt Verbindung zum Google Sheet her.""" - self.sheet = None # Setze sheet vor dem Versuch auf None - logger.info("Versuche Verbindung mit Google Sheets herstellen...") - try: - # Stellen Sie sicher, dass CREDENTIALS_FILE korrekt ist - if not os.path.exists(CREDENTIALS_FILE): - raise FileNotFoundError(f"Credential-Datei nicht gefunden: {CREDENTIALS_FILE}") - - scope = ["https://www.googleapis.com/auth/spreadsheets"] - creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope) - gc = gspread.authorize(creds) - sh = gc.open_by_url(Config.SHEET_URL) # Nutzt die URL aus Config - self.sheet = sh.sheet1 # Greift auf das erste Blatt zu (Index 0) - logger.info("Verbindung zu Google Sheets erfolgreich.") - - # Spezifische Fehlerbehandlung für gspread/requests Fehler, die vom Decorator behandelt werden - except (gspread.exceptions.APIError, requests.exceptions.RequestException) as e: - # Der Decorator wird diese Fehler loggen und wiederholen. - # Werfen Sie den Fehler erneut, damit der Decorator ihn fangen kann. - raise e - except FileNotFoundError as e: - # Dieser Fehler sollte nicht wiederholt werden, aber geloggt werden. - logger.critical(f"FEHLER bei der Google Sheets Verbindung: {type(e).__name__} - {e}") - raise e # Wirf ihn trotzdem, damit der Aufrufer (main) es sieht - except Exception as e: - # Logge andere unerwartete Fehler - logger.error(f"FEHLER bei der Google Sheets Verbindung: {type(e).__name__} - {e}") - # Werfen Sie den Fehler erneut, damit der Decorator oder Aufrufer ihn behandeln kann - raise e - - - @retry_on_failure - def load_data(self): - """Lädt alle Daten aus dem Sheet und aktualisiert self.sheet_values.""" - if not self.sheet: - logger.error("Fehler: Keine Sheet-Verbindung zum Laden der Daten.") - self.sheet_values = [] # Stelle sicher, dass die Datenliste leer ist - return False # Signalisiert Fehler - - logger.info("Lade Daten aus Google Sheet...") - try: - # Nutze get_all_values() für alle Daten - self.sheet_values = self.sheet.get_all_values() - - if not self.sheet_values: - logger.warning("Google Sheet scheint leer zu sein oder get_all_values() lieferte keine Daten.") - # Wenn die erste Zeile nicht geladen werden kann (z.B. leeres Sheet), headers ist leer - self.headers = [] - return True # Ladevorgang war technisch erfolgreich, aber keine Daten - - # Logge die Anzahl der Zeilen und Spalten, die geladen wurden - num_rows = len(self.sheet_values) - num_cols = len(self.sheet_values[0]) if num_rows > 0 else 0 - logger.info(f"Daten neu geladen: {num_rows} Zeilen, {num_cols} Spalten.") - - # Optional: Überprüfen Sie, ob die Anzahl der Spalten mindestens dem höchsten Index in COLUMN_MAP entspricht - try: - max_expected_cols = max(COLUMN_MAP.values()) + 1 - if num_cols < max_expected_cols: - logger.warning(f"Geladenes Sheet hat {num_cols} Spalten, erwartet werden aber mindestens {max_expected_cols} basierend auf COLUMN_MAP. Das COLUMN_MAP passt möglicherweise nicht zum Sheet!") - except Exception as e: - logger.error(f"Fehler bei der Prüfung der Spaltenanzahl gegen COLUMN_MAP: {e}") - - - return True # Signalisiert Erfolg - - # Spezifische Fehlerbehandlung - except (gspread.exceptions.APIError, requests.exceptions.RequestException) as e: - # Der Decorator wird diese Fehler loggen und wiederholen. - raise e # Werfen Sie den Fehler erneut - - except Exception as e: - logger.error(f"Allgemeiner Fehler beim Laden der Google Sheet Daten: {e}") - raise e # Werfen Sie den Fehler erneut - - - def get_data(self): - """ - Gibt die aktuell im Handler gespeicherten Datenzeilen zurück - (ohne die ersten N Header-Zeilen). - """ - if not self.sheet_values or len(self.sheet_values) <= self._header_rows: - # Logge nur auf Debug, da dies oft passiert, wenn das Sheet leer ist - logger.debug(f"get_data: Keine Datenzeilen verfügbar (geladen: {len(self.sheet_values) if self.sheet_values else 0} Zeilen, {self._header_rows} Header).") - return [] - # Gibt eine Slice der Liste zurück (Kopie, um unbeabsichtigte Änderungen am Original zu vermeiden) - return self.sheet_values[self._header_rows:].copy() - - - def get_all_data_with_headers(self): - """Gibt alle aktuell im Handler gespeicherten Daten inklusive Header zurück.""" - if not self.sheet_values: - logger.debug("get_all_data_with_headers: Keine Daten im Handler gespeichert.") - return [] - return self.sheet_values.copy() # Rückgabe als Kopie - - - def _get_col_letter(self, col_idx_1_based): - """ - Konvertiert einen 1-basierten Spaltenindex in den entsprechenden - Google Sheets Spaltenbuchstaben (A, B, ..., Z, AA, ...). - """ - if not isinstance(col_idx_1_based, int) or col_idx_1_based < 1: - # Logge den Fehler - logger.error(f"Ungültiger Spaltenindex ({col_idx_1_based}) für _get_col_letter erhalten.") - return None # Ungültiger Index - - string = "" - n = col_idx_1_based - while n > 0: - n, remainder = divmod(n - 1, 26) - string = chr(65 + remainder) + string - return string - - - def get_start_row_index(self, check_column_key, min_sheet_row=7): - """ - Findet den 0-basierten Index in der DATENliste (ohne Header), - ab einer Mindestzeilennummer im Sheet, in der der Wert in der - Spalte (definiert durch check_column_key) EXAKT LEER ("") ist. - Lädt die Daten vor der Prüfung neu. - - Args: - check_column_key (str): Der Schlüssel in COLUMN_MAP für die zu prüfende Spalte. - min_sheet_row (int): Die 1-basierte Zeilennummer im Sheet, ab der gesucht werden soll. - - Returns: - int: Der 0-basierte Index in der Datenliste (ohne Header), - oder -1 bei Fehler (z.B. Schlüssel nicht gefunden), - oder der Index nach der letzten Datenzeile, wenn alle gefüllt sind. - (Ein Rückgabewert >= len(data_rows) bedeutet, dass keine leere Zelle im Suchbereich gefunden wurde). - """ - # Daten neu laden, um sicherzustellen, dass sie aktuell sind - if not self.load_data(): - logger.error("Fehler beim Laden der Daten für get_start_row_index.") - return -1 # Signalisiert Fehler - - data_rows = self.get_data() # Datenzeilen ohne Header - if not data_rows: - logger.info("Keine Datenzeilen im Sheet gefunden. Startindex ist 0 (erste Datenzeile).") - return 0 # Wenn keine Daten da sind, ist 0 der Start - - check_column_index = COLUMN_MAP.get(check_column_key) - if check_column_index is None: - logger.critical(f"FEHLER: Schlüssel '{check_column_key}' nicht in COLUMN_MAP gefunden für get_start_row_index!") - return -1 # Signalisiert Fehler - - actual_col_letter = self._get_col_letter(check_column_index + 1) - # Berechne den Startindex in der 0-basierten 'data_rows' Liste - # min_sheet_row (1-basiert) -> 0-basierten Index in all_data -> 0-basierten Index in data_rows - # min_sheet_row - 1 = 0-basierter Index in all_data - # (min_sheet_row - 1) - self._header_rows = 0-basierter Index in data_rows - search_start_index_in_data = max(0, (min_sheet_row - 1) - self._header_rows) - - logger.info(f"get_start_row_index: Suche ab Daten-Index {search_start_index_in_data} (Sheet-Zeile {search_start_index_in_data + self._header_rows + 1}) nach EXAKT LEEREM Wert (=='') in Spalte '{check_column_key}' ({actual_col_letter})...") - - if search_start_index_in_data >= len(data_rows): - logger.warning(f"Start-Suchindex in Daten ({search_start_index_in_data}) liegt hinter der letzten Datenzeile ({len(data_rows)}). Keine leere Zelle gefunden im Suchbereich.") - # Rückgabe der Länge der Datenliste signalisiert, dass keine leere Zelle gefunden wurde - return len(data_rows) - - # Iteriere über die Datenzeilen ab dem berechneten Startindex - for i in range(search_start_index_in_data, len(data_rows)): - row = data_rows[i] - current_sheet_row = i + self._header_rows + 1 # 1-basierte Sheet-Zeilennummer - - cell_value = ""; is_exactly_empty = True - # Überprüfe, ob die Zeile lang genug ist, um auf die Spalte zuzugreifen - if len(row) > check_column_index: - cell_value = row[check_column_index] - if cell_value != "": is_exactly_empty = False - else: - # Wenn die Zeile nicht lang genug ist, gilt die Zelle in der Spalte als leer - is_exactly_empty = True - - # Logge die ersten paar Zeilen und jede 1000. Zeile oder wenn eine leere Zelle gefunden wird - log_debug = (i < search_start_index_in_data + 5) or (i % 1000 == 0) or is_exactly_empty - if log_debug: - logger.debug(f" -> Prüfe Daten-Index {i} (Sheet {current_sheet_row}): Wert in {actual_col_letter}='{str(cell_value).strip()}' (Roh='{cell_value}' Typ: {type(cell_value)}). Ist exakt leer ('')? {is_exactly_empty}") - - if is_exactly_empty: - logger.info(f"Erste Zeile ab Sheet-Zeile {min_sheet_row} mit EXAKT LEEREM Wert in Spalte {actual_col_letter} gefunden: Zeile {current_sheet_row} (Daten-Index {i})") - return i # Gebe den 0-basierten Index in der Datenliste zurück - - # Wenn die Schleife durchläuft, ohne eine leere Zelle zu finden - last_data_index = len(data_rows) - logger.info(f"Alle Zeilen ab Daten-Index {search_start_index_in_data} im Suchbereich haben einen nicht-leeren Wert in Spalte {actual_col_letter}. Nächster Daten-Index wäre {last_data_index}.") - return last_data_index # Signalisiert, dass keine leere Zelle gefunden wurde - - - @retry_on_failure - def batch_update_cells(self, update_data): - """ - Führt ein Batch-Update im Google Sheet durch. Beinhaltet robustere - Fehlerbehandlung. - - Args: - update_data (list): Eine Liste von Dictionaries, jedes mit 'range' (str) - und 'values' (list of lists). - z.B. [{'range': 'A1', 'values': [['Wert']]}, ...] - - Returns: - bool: True bei Erfolg (nach allen Retries), False bei endgültigem Fehler. - """ - if not self.sheet: - logger.error("FEHLER: Keine Sheet-Verbindung für Batch-Update.") - return False - if not update_data: - # logger.debug("Keine Daten für Batch-Update vorhanden.") # Zu viel Lärm - return True # Nichts zu tun ist technisch ein Erfolg - - # Die retry_on_failure Logik kümmert sich um die Wiederholung und das Werfen - # der Exception im Fehlerfall. Wir müssen hier nur den Aufruf machen und - # das Ergebnis (oder die Exception) weitergeben. - - try: - # Verwende len() des update_data um Anzahl der Operationen zu schätzen, - # aber die tatsächliche Anzahl der Zellen ist die Summe der items in values. - total_cells_to_update = sum(len(row) for item in update_data for row in item.get('values', [])) - logger.debug(f" -> Versuche sheet.batch_update mit {len(update_data)} Anfragen ({total_cells_to_update} Zellen)...") - - # Die gspread-Methode batch_update wirft bei Fehlern Exceptions, - # die vom @retry_on_failure Decorator gefangen werden. - # value_input_option='USER_ENTERED' interpretiert die Eingaben wie ein Nutzer. - self.sheet.batch_update(update_data, value_input_option='USER_ENTERED') - - # Wenn keine Exception aufgetreten ist, war der Aufruf (ggf. nach Retries) erfolgreich. - # logger.debug(f" -> sheet.batch_update erfolgreich abgeschlossen.") # Zu viel Lärm - return True # Signalisiert Erfolg - - # Exceptions werden von retry_on_failure gefangen und (im Fehlerfall) neu geworfen. - # Wenn eine Exception hier durchkommt, hat retry_on_failure aufgegeben. - except Exception as e: - # Der endgültige Fehler wurde bereits vom Decorator geloggt. - # Wir fangen ihn hier nur, um False zurückzugeben, wie in der Signatur versprochen. - # Das re-raising im Decorator sorgt dafür, dass wir hier landen, wenn der Decorator aufgibt. - logger.error(f"Endgültiger Fehler beim Batch-Update nach Retries. Kann {len(update_data)} Operationen nicht durchführen.") - # Der Traceback wurde bereits vom Decorator (im except Exception Fall) geloggt. - return False # Signalisiert endgültigen Fehler - # ============================================================================== -# 5. WIKIPEDIA SCRAPER CLASS -# (Entspricht logisch etwa 'wikipedia_scraper.py') -# ============================================================================== - -class WikipediaScraper: - """ - Handhabt das Suchen von Wikipedia-Artikeln und das Extrahieren relevanter - Unternehmensdaten. Beinhaltet Validierungslogik für Artikel. - Nutzt die wikipedia-Bibliothek und Requests für direktes HTML-Scraping. - """ - def __init__(self, user_agent=None): - """ - Initialisiert den Scraper mit einer Requests-Session und konfigurierter - Wikipedia-Bibliothek. - - Args: - user_agent (str, optional): Der User-Agent für Requests. - Defaults to a script-specific one. - """ - # Erhalten Sie eine Logger-Instanz für diese Klasse - self.logger = logging.getLogger(__name__ + ".WikipediaScraper") - self.logger.debug("WikipediaScraper initialisiert.") - - # User-Agent für Requests (nutzt Config, Fallback wenn nicht gesetzt) - self.user_agent = user_agent or getattr(Config, 'USER_AGENT', 'Mozilla/5.0 (compatible; UnternehmenSkript/1.0; +http://www.example.com/bot)') # Beispiel URL anpassen - self.session = requests.Session() - self.session.headers.update({'User-Agent': self.user_agent}) - self.logger.debug(f"Requests Session mit User-Agent '{self.user_agent}' initialisiert.") - - # Keywords für die Infobox-Extraktion - self.keywords_map = { - 'branche': ['branche', 'wirtschaftszweig', 'industry', 'tätigkeit', 'sektor', 'produkte', 'leistungen'], - 'umsatz': ['umsatz', 'erlös', 'revenue', 'jahresumsatz', 'konzernumsatz', 'ergebnis'], - 'mitarbeiter': ['mitarbeiter', 'mitarbeiterzahl', 'beschäftigte', 'employees', 'number of employees', 'personal', 'belegschaft'] - } - - # Konfiguriere die wikipedia-Bibliothek - try: - wiki_lang = getattr(Config, 'LANG', 'de') - wikipedia.set_lang(wiki_lang) - # Aktivieren Sie Rate Limiting, um die Wikipedia-API nicht zu überlasten - wikipedia.set_rate_limiting(True, min_wait=0.1) # Minimum 0.1 Sekunden warten - self.logger.info(f"Wikipedia library language set to '{wiki_lang}'. Rate limiting enabled (min_wait=0.1).") - except Exception as e: - self.logger.warning(f"Fehler beim Setzen der Wikipedia-Sprache oder Rate Limiting: {e}") - - - # --- Interne Helfermethoden --- - - def _get_full_domain(self, website): - """Extrahiert die normalisierte Domain (ohne www, ohne Pfad) aus einer URL.""" - # Diese Funktion kann die globale simple_normalize_url nutzen, ist aber hier dupliziert - # für die Unabhängigkeit der Klasse. Ggf. Refactoring-Entscheidung: globale Funktion nutzen - # Behalten wir die Kopie hier, da sie leicht abweichend implementiert ist (keine Fehlerbehandlung, kein k.A.) - if not website or not isinstance(website, str): return "" - website_lower = website.lower().strip() - if not website_lower or website_lower == 'k.a.': return "" - # Entferne Schema, @-Teil, Port - website_lower = re.sub(r'^https?:\/\/', '', website_lower) - if '@' in website_lower: website_lower = website_lower.split('@', 1)[1] - if ':' in website_lower: website_lower = website_lower.split(':', 1)[0] - # Entferne www. - if website_lower.startswith('www.'): website_lower = website_lower[4:] - # Nimm nur den Domain-Teil vor dem ersten Schrägstrich - domain = website_lower.split('/')[0] - # Einfache Prüfung auf mindestens einen Punkt (Basic TLD check) - return domain if '.' in domain else "" - - - def _generate_search_terms(self, company_name, website): - """ - Generiert eine Liste von Suchbegriffen für die Wikipedia-Suche, - inklusive normalisiertem Namen, Kurzformen und Domain. - """ - if not company_name: return [] - terms = set() - - # Fügen Sie den originalen Namen hinzu - original_name_cleaned = company_name.strip() - if original_name_cleaned: - terms.add(original_name_cleaned) - - # Fügen Sie die normalisierte Namen und Teile hinzu (nutzt globale Funktion) - normalized_name = normalize_company_name(company_name) # Annahme: normalize_company_name global - if normalized_name: - terms.add(normalized_name) - name_parts = normalized_name.split() - if len(name_parts) > 0: terms.add(name_parts[0]) # Erstes Wort - if len(name_parts) > 1: terms.add(" ".join(name_parts[:2])) # Erste zwei Wörter - - # Fügen Sie die Domain hinzu (nutzt interne Methode) - full_domain = self._get_full_domain(website) - if full_domain: terms.add(full_domain) - - # Entferne leere Strings und limitiere die Anzahl der Begriffe - final_terms = [term for term in list(terms) if term][:getattr(Config, 'WIKIPEDIA_SEARCH_RESULTS', 5)] # Limitiere auf Anzahl der Suchergebnisse - self.logger.debug(f"Generierte Suchbegriffe für '{company_name}': {final_terms}") - return final_terms - - - @retry_on_failure # Nutzt den globalen Decorator - def _get_page_soup(self, url): - """Holt HTML von einer URL und gibt ein BeautifulSoup-Objekt zurück.""" - if not url or not isinstance(url, str) or not url.lower().startswith(("http://", "https://")): - self.logger.warning(f"_get_page_soup: Ungültige URL '{url}'.") - return None - try: - self.logger.debug(f"_get_page_soup: Rufe URL ab: {url}") - # Verwenden Sie die Instanz Session - response = self.session.get(url, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) # Konfigurierbarer Timeout - response.raise_for_status() # Wirft HTTPError für schlechte Antworten (4xx oder 5xx) - response.encoding = response.apparent_encoding # Versuche, Encoding zu erraten - - # Nutzt den HTML_PARSER aus Config - soup = BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser')) - self.logger.debug(f"_get_page_soup: Parsen von {url} erfolgreich.") - return soup - except requests.exceptions.Timeout: - self.logger.error(f"_get_page_soup: Timeout beim Abrufen von {url}") - raise # Exception weitergeben für Retry - except requests.exceptions.RequestException as e: - self.logger.error(f"_get_page_soup: Netzwerk-/HTTP-Fehler beim Abrufen von HTML von {url}: {e}") - raise e # Exception weitergeben für Retry - except Exception as e: - # Fängt andere unerwartete Fehler ab (z.B. Probleme mit BeautifulSoup) - self.logger.error(f"_get_page_soup: Fehler beim Parsen von HTML von {url}: {type(e).__name__} - {e}") - raise e # Exception weitergeben (könnte auch retry triggern) - - - # --- Überarbeitete Validierungsmethode --- - def _validate_article(self, page, company_name, website): - """ - Validiert, ob ein Wikipedia-Artikel zum Unternehmen passt. - Prüft Titelähnlichkeit (gewichtete Anfangsworte), Domain-Match in Links - und passt Schwellenwerte dynamisch an. - - Args: - page (wikipedia.WikipediaPage): Das geladene Wikipedia Page Objekt. - company_name (str): Der Name des Unternehmens. - website (str): Die Website des Unternehmens. - - Returns: - bool: True, wenn der Artikel validiert wurde, sonst False. - """ - if not page or not company_name: return False # Grundlegende Prüfung - # page.title ist der Titel des Wikipedia-Artikels - self.logger.debug(f"Validiere Artikel '{page.title}' (URL: {page.url}) für Firma '{company_name}' (Website: {website})...") - - # Normalisiere Namen (nutzt globale Funktion) - normalized_company = normalize_company_name(company_name) - normalized_title = normalize_company_name(page.title) - - if not normalized_company or not normalized_title: - self.logger.warning("Validierung nicht möglich, da Normalisierung eines Namens fehlschlug.") - return False - - # Basisschwelle für Ähnlichkeit - standard_threshold = getattr(Config, 'SIMILARITY_THRESHOLD', 0.65) - - # 1. Titelähnlichkeit (Gesamt) - similarity = SequenceMatcher(None, normalized_title, normalized_company).ratio() - self.logger.debug(f" -> Gesamt-Ähnlichkeit (normalized): {similarity:.2f} ('{normalized_title}' vs '{normalized_company}')") - - # 2. Ähnlichkeit der ersten Worte (Normalisiert) - company_tokens = normalized_company.split() - title_tokens = normalized_title.split() - first_word_match = False - first_two_words_match = False - - if len(company_tokens) > 0 and len(title_tokens) > 0: - if company_tokens[0] == title_tokens[0]: - first_word_match = True - # self.logger.debug(" -> Erstes normalisiertes Wort stimmt überein.") - if len(company_tokens) > 1 and len(title_tokens) > 1: - if company_tokens[1] == title_tokens[1]: - first_two_words_match = True - # self.logger.debug(" -> Erste zwei normalisierte Worte stimmen überein.") - - # 3. Link-Prüfung (Domain-Match im Artikel-HTML) - domain_found = False - full_domain = self._get_full_domain(website) # Nutzt interne Methode - if full_domain and full_domain != "k.A.": - self.logger.debug(f" -> Suche nach Domain '{full_domain}' in externen Links des Artikels...") - try: - # Direkte Abfrage über wikipedia.page.html() kann schneller sein als erneuter Requests Call - article_html = page.html() - if article_html: - soup = BeautifulSoup(article_html, getattr(Config, 'HTML_PARSER', 'html.parser')) - # Suche nach externen Links, die die Domain enthalten - # Schließe Wikipedia-eigene Domains aus - external_links = soup.select('a[href^="http"]') # Links, die mit http starten - relevant_links = [link for link in external_links if full_domain in self._get_full_domain(link.get('href', '')) and not any(exclude in link.get('href', '') for exclude in ['wikipedia.org', 'wikimedia.org', 'wikidata.org', 'archive.org'])] - - if relevant_links: - # Optional: Prüfe, ob der Link in der Infobox ist oder typischen Text hat - # Dies kann komplex sein und zu Fehlern führen. Einfacher ist, nur den Link zu prüfen. - domain_found = True - # logger.debug(f" -> Domain '{full_domain}' in {len(relevant_links)} externen Links gefunden.") - else: - # logger.debug(f" -> Domain '{full_domain}' nicht in externen Links gefunden.") - pass # domain_found bleibt False - else: - self.logger.warning(" -> Konnte HTML für Link-Prüfung nicht abrufen (page.html() leer).") - - except Exception as e_link_check: - self.logger.error(f"Fehler während der Domain-Link-Prüfung für '{page.title}': {e_link_check}") - # Fehler beim Link-Check sollte die Validierung nicht blockieren, nur beeinflussen - - else: - self.logger.debug(" -> Keine Website-Domain für Link-Prüfung vorhanden oder ungültig.") - - - # 4. Dynamische Schwellenwert-Entscheidung (Bewertung) - is_valid = False - reason = "Keine Validierungsregel traf zu" # Default Grund - - # Prüfe Bedingungen in absteigender Reihenfolge ihrer Stärke / Relevanz - if similarity >= standard_threshold: - is_valid = True - reason = f"Gesamt-Ähnlichkeit ({similarity:.2f}) >= Standard-Schwelle ({standard_threshold:.2f})" - elif domain_found and first_two_words_match: # Stärkste Kombination von Indikatoren - is_valid = True - reason = f"Domain gefunden UND erste 2 normalisierte Worte stimmen überein (Sim={similarity:.2f})" - elif domain_found and first_word_match and similarity >= 0.40: # Domain + Erstes Wort + Moderate Ähnlichkeit - is_valid = True - reason = f"Domain gefunden UND erstes normalisiertes Wort stimmt überein UND Ähnlichkeit >= 0.40 (Sim={similarity:.2f})" - elif first_two_words_match and similarity >= 0.45: # Erste zwei Worte + Moderate Ähnlichkeit (auch ohne Domain) - is_valid = True - reason = f"Erste zwei normalisierte Worte stimmen überein UND Ähnlichkeit >= 0.45 (Sim={similarity:.2f})" - elif domain_found and similarity >= 0.50: # Nur Domain + Etwas höhere Ähnlichkeit - is_valid = True - reason = f"Domain gefunden UND Ähnlichkeit >= 0.50 (Sim={similarity:.2f})" - elif first_word_match and similarity >= 0.55: # Nur Erstes Wort + Etwas höhere Ähnlichkeit - is_valid = True - reason = f"Erstes normalisiertes Wort stimmt überein UND Ähnlichkeit >= 0.55 (Sim={similarity:.2f})" - # Niedrigere Schwellen für Fälle, wo die Namen stark abweichen, aber andere Indikatoren passen? - # elif domain_found and similarity >= 0.30: # Ggf. zu aggressiv - # is_valid = True - # reason = f"Domain gefunden UND Ähnlichkeit >= 0.30" - - - log_level = logging.INFO if is_valid else logging.DEBUG - self.logger.log(log_level, f" => Artikel '{page.title}' {'VALIDIERT' if is_valid else 'NICHT validiert'} (Grund: {reason}. Details: Sim={similarity:.2f}, Domain? {domain_found}, 1stWord? {first_word_match}, 2ndWord? {first_two_words_match})") - - return is_valid - - # --- Extraktionsmethoden --- - - def _extract_first_paragraph_from_soup(self, soup): - """Extrahiert den ersten aussagekräftigen Absatz aus dem Soup-Objekt.""" - if not soup: return "k.A." - paragraph_text = "k.A." - try: - # Finden Sie den Hauptinhalt-Div - content_div = soup.find('div', class_='mw-parser-output') - # Suchen Sie die ersten p-Tags direkt unter diesem Div (recursive=False) oder im gesamten Soup - search_area = content_div if content_div else soup - paragraphs = search_area.find_all('p', recursive=False) - # Fallback, falls keine direkten p-Tags gefunden werden - if not paragraphs: paragraphs = search_area.find_all('p') # Recursive Fallback - - # Gehe durch die gefundenen Absätze - for p in paragraphs: - # Entferne Referenzen und versteckte Spans innerhalb des p-Tags VOR dem Text-Extraktion - for sup in p.find_all('sup', class_='reference'): sup.decompose() - for span in p.find_all('span', style=lambda value: value and 'display:none' in value): span.decompose() - for span in p.find_all('span', id='coordinates'): span.decompose() # Entferne Koordinaten-Span - - # Extrahiere und bereinige den Text (nutzt globale Funktion clean_text) - text = clean_text(p.get_text(separator=' ', strip=True)) - - # Prüfe, ob der Text lang genug ist und nicht nur z.B. Bilderklärungen sind - if text and len(text) > 50: # Mindestlänge anpassen, falls nötig - # Prüfe auf gängige unerwünschte Anfänge (z.B. nach Infoboxen) - if not re.match(r'^(Datei:|Abbildung:|Siehe auch:|Einzelnachweise)', text, re.IGNORECASE): - paragraph_text = text[:1500] # Limitiere die Länge des Absatzes - # logger.debug(f" -> Ersten gültigen Absatz gefunden: {paragraph_text[:100]}...") - break # Höre beim ersten guten Absatz auf - - if paragraph_text == "k.A.": - self.logger.debug("Kein passender erster Absatz gefunden.") - - except Exception as e: - self.logger.error(f"Fehler beim Extrahieren des ersten Absatzes: {e}") - return paragraph_text - - - def extract_categories(self, soup): - """Extrahiert Wikipedia-Kategorien aus dem Soup-Objekt.""" - if not soup: return "k.A." - cats_filtered = [] - try: - # Kategorien sind normalerweise in einem div mit id="mw-normal-catlinks" - cat_div = soup.find('div', id="mw-normal-catlinks") - if cat_div: - # Die Kategorien sind innerhalb eines ul-Tags unter diesem div - ul = cat_div.find('ul') - if ul: - # Jede Kategorie ist ein li-Element innerhalb des ul - cats = [clean_text(li.get_text()) for li in ul.find_all('li')] # Nutzt globale clean_text - # Filtere leere oder unerwünschte Einträge (wie "Kategorien:") - cats_filtered = [c for c in cats if c and "kategorien:" not in c.lower()] - self.logger.debug(f"Kategorien gefunden: {cats_filtered}") - else: self.logger.debug("Kein 'ul' Tag in 'mw-normal-catlinks' gefunden.") - else: self.logger.debug("Kein 'div#mw-normal-catlinks' gefunden.") - - except Exception as e: - self.logger.error(f"Fehler beim Extrahieren der Kategorien: {e}") - - return ", ".join(cats_filtered) if cats_filtered else "k.A." - - - def _extract_infobox_value(self, soup, target): - """ - Extrahiert gezielt Branche, Umsatz oder Mitarbeiter aus der Infobox - eines Wikipedia-Artikels Soup-Objekts. - Berücksichtigt Header in <th> oder fett formatierten <td>. - """ - self.logger.debug(f"--- Entering _extract_infobox_value for target '{target}' ---") - if not soup or target not in self.keywords_map: - self.logger.debug(f"_extract_infobox_value: Ungültiger Input (Soup: {soup is not None}, Target: {target})") - return "k.A." - - keywords = self.keywords_map[target] - self.logger.debug(f"_extract_infobox_value: Suche nach '{target}' mit Keywords: {keywords}") - - # Finden Sie die Infobox (verschiedene Klassen sind möglich) - infobox = soup.select_one('table[class*="infobox"]') # Suche nach class, die "infobox" enthält - if not infobox: - self.logger.debug(" -> KEINE Infobox via select_one 'table[class*=\"infobox\"]' gefunden.") - return "k.A." - - self.logger.debug(f" -> Infobox gefunden.") - value_found = "k.A." - - try: - # Iteriere durch die Zeilen der Infobox - rows = infobox.find_all('tr') - self.logger.debug(f" -> Analysiere {len(rows)} Zeilen in der Infobox.") - - for idx, row in enumerate(rows): - # logger.debug(f" --- Prüfe Roh-HTML Zeile {idx}: {str(row)[:150]}...") # Zu viel Lärm - - # Suche nach TH und TD Elementen direkt unter TR - cells = row.find_all(['th', 'td'], recursive=False) - header_text = None - value_cell = None - - # Gängigste Struktur: TH (Header) gefolgt von TD (Wert) - if len(cells) >= 2 and cells[0].name == 'th' and cells[1].name == 'td': - header_text = cells[0].get_text(strip=True) - value_cell = cells[1] - # logger.debug(f" -> Zeile {idx}: Struktur TH + TD erkannt.") - - # Alternative Struktur: TD (Header-ähnlich, z.B. fett) gefolgt von TD (Wert) - # Hier ist Vorsicht geboten, um nicht reguläre Datenzellen zu erfassen. - elif len(cells) >= 2 and cells[0].name == 'td' and cells[1].name == 'td': - first_cell_is_header_like = False - # Prüfe auf Style-Attribut mit font-weight bold - style = cells[0].get('style', '').lower() - if 'font-weight' in style and ('bold' in style or '700' in style): - first_cell_is_header_like = True - # Prüfe auf fettgedruckten Inhalt (<b> oder <strong>) - elif cells[0].find(['b', 'strong'], recursive=False): - first_cell_is_header_like = True - - if first_cell_is_header_like: - header_text = cells[0].get_text(strip=True) - value_cell = cells[1] - # logger.debug(f" -> Zeile {idx}: Struktur TD(Header-like) + TD erkannt.") - # else: - # logger.debug(f" -> Zeile {idx}: Struktur TD + TD, aber erstes TD nicht als Header erkannt.") - - # Wenn eine passende Struktur gefunden wurde - if header_text is not None and value_cell is not None: - # logger.debug(f" -> Verarbeite Zeile {idx} mit Header='{header_text}'") - header_text_lower = header_text.lower() - matched_keyword = None - - # Prüfe, ob ein gesuchtes Keyword im Header-Text vorkommt - for kw in keywords: - if kw in header_text_lower: - matched_keyword = kw - break - - # Wenn ein Keyword gefunden wurde, extrahiere den Wert - if matched_keyword: - # logger.debug(f" --> Keyword '{matched_keyword}' gefunden in Header '{header_text}'!") - # Entferne störende Elemente wie Referenz-Tags oder versteckte Spans aus der Value-Zelle - for sup in value_cell.find_all(['sup', 'span']): - if (sup.name == 'sup' and sup.has_attr('class') and 'reference' in sup['class']) or \ - (sup.name == 'span' and sup.get('style') and 'display:none' in sup['style']): - # logger.debug(f" -> Entferne störendes Element: {sup.get_text(strip=True)[:50]}...") # Zu viel Lärm - sup.decompose() # Entferne das Element - - # Extrahiere den Rohtext aus der bereinigten Value-Zelle - raw_value_text = value_cell.get_text(separator=' ', strip=True) - # logger.debug(f" -> Roher TD/Value-Text nach Decompose: '{raw_value_text}'") # Zu viel Lärm - - # Bereinige und konvertiere den Wert basierend auf dem Zieltyp - if target == 'branche': - # Branche: Bereinigen, Klammern entfernen, nur erste Zeile nehmen - clean_val = clean_text(raw_value_text) # Nutzt globale clean_text - clean_val = re.sub(r'\s*\([^)]*\)', '', clean_val).strip() # Klammern entfernen - clean_val = clean_val.split('\n')[0].strip() # Nur erste Zeile - value_found = clean_val if clean_val else "k.A." - self.logger.info(f" --> Branche extrahiert: '{value_found}'") - - elif target == 'umsatz': - # Umsatz: Numerische Extraktion (nutzt globale Funktion extract_numeric_value) - # extract_numeric_value gibt String zurück ("k.A." oder Zahl) - numeric_val_str = extract_numeric_value(raw_value_text, is_umsatz=True) - value_found = numeric_val_str - self.logger.info(f" --> Umsatz extrahiert (aus '{raw_value_text[:50]}...'): '{value_found}'") - - elif target == 'mitarbeiter': - # Mitarbeiter: Numerische Extraktion (nutzt globale Funktion extract_numeric_value) - # extract_numeric_value gibt String zurück ("k.A." oder Zahl) - numeric_val_str = extract_numeric_value(raw_value_text, is_umsatz=False) - value_found = numeric_val_str - self.logger.info(f" --> Mitarbeiter extrahiert (aus '{raw_value_text[:50]}...'): '{value_found}'") - - # Da wir den Wert gefunden haben, können wir die Schleife über die Zeilen abbrechen - break - - # Wenn die Schleife durchläuft und kein passendes Keyword gefunden wurde, bleibt value_found "k.A." - if value_found != "k.A.": - self.logger.debug(f" -> Finaler Wert für '{target}' gefunden: '{value_found}'") - else: - self.logger.debug(f" -> Kein passender Eintrag für '{target}' in der gesamten Infobox gefunden.") - - except Exception as e: - # Logge jeden Fehler, der während der Infobox-Verarbeitung auftritt - self.logger.exception(f"Fehler beim Durchlaufen der Infobox-Zeilen für '{target}': {e}") - return "k.A." # Bei Fehler "k.A." zurückgeben - - return value_found - - - # --- Hauptmethoden --- - - # retry_on_failure Decorator sollte hier angewendet werden, da es externe Calls macht - @retry_on_failure - def search_company_article(self, company_name, website=None): - """ - Sucht einen passenden Wikipedia-Artikel für das Unternehmen und gibt das - wikipedia.WikipediaPage Objekt zurück, wenn ein relevanter und validierter - Artikel gefunden wird. Behandelt explizit Begriffsklärungsseiten. - - Args: - company_name (str): Der Name des Unternehmens (CRM Name). - website (str, optional): Die Website des Unternehmens (CRM Website). Defaults to None. - - Returns: - wikipedia.WikipediaPage: Das validierte Page Objekt oder None. - """ - if not company_name: - self.logger.warning("Wikipedia search skipped: No company name provided.") - return None - - # Generiere Suchbegriffe - search_terms = self._generate_search_terms(company_name, website) - if not search_terms: - self.logger.warning(f"Keine Suchbegriffe für '{company_name}' generiert.") - return None - - self.logger.info(f"Starte Wikipedia-Suche für '{company_name}' (Website: {website}) mit Begriffen: {search_terms}") - - # Menge der bereits geprüften Titel, um Redundanzen zu vermeiden - processed_titles = set() - - # --- Innere Helferfunktion zum Prüfen eines einzelnen Titels --- - def check_page(title_to_check): - """Lädt einen potenziellen Wikipedia-Artikel und validiert ihn.""" - # Prüfen, ob der Titel bereits verarbeitet wurde - if title_to_check in processed_titles: - # self.logger.debug(f" -> Titel '{title_to_check}' bereits geprüft, überspringe.") # Zu viel Lärm - return None # Titel wurde bereits geprüft - - # Titel zur Liste der verarbeiteten hinzufügen, bevor er geladen wird - processed_titles.add(title_to_check) - self.logger.debug(f" -> Prüfe potenziellen Artikel: '{title_to_check}'") - - try: - # Lade die Seite. auto_suggest=False deaktiviert automatische Titelkorrektur, - # preload=True lädt den Inhalt und die InfoBox gleich mit. - page = wikipedia.page(title_to_check, auto_suggest=False, preload=True) - - # Prüfe, ob es sich um eine Begriffsklärungsseite handelt (wird von wikipedia.page selbst als Exception geworfen) - # oder ob unsere Validierung fehlschlägt - if self._validate_article(page, company_name, website): - # Wenn der Artikel validiert wurde, geben Sie das Page-Objekt zurück - self.logger.info(f" -> Titel '{page.title}' erfolgreich validiert.") - return page - else: - self.logger.debug(f" -> Titel '{title_to_check}' nicht validiert.") - return None - - except wikipedia.exceptions.PageError: - # Titel existiert nicht auf Wikipedia - self.logger.debug(f" -> Seite '{title_to_check}' nicht gefunden (PageError).") - return None - except wikipedia.exceptions.DisambiguationError as e_inner: - # Titel führt zu einer Begriffsklärungsseite - self.logger.info(f" -> Begriffsklärung '{title_to_check}' gefunden. Prüfe Optionen: {e_inner.options[:10]}...") # Logge nur die ersten Optionen - best_option_page = None - - # Gehe durch die Optionen der Begriffsklärungsseite - for option in e_inner.options: - option_lower = option.lower() - # Filtere Optionen, die wahrscheinlich keine Unternehmensartikel sind (z.B. Personen, Orte) - # Fügen Sie hier weitere Filter oder eine verbesserte Heuristik hinzu - if any(exclude_word in option_lower for exclude_word in ["(person)", "(ort)", "(geographie)"]): - self.logger.debug(f" -> Option übersprungen (wahrscheinlich keine Firma): '{option}'") - continue - - # Checken Sie die Option rekursiv mit check_page - # Dies wird die Option laden und validieren - validated_option_page = check_page(option) - - # Wenn eine Option validiert wurde, nehmen Sie die erste als besten Treffer (oder implementieren Sie eine Ranking-Logik) - if validated_option_page: - self.logger.info(f" -> Option '{option}' aus Begriffsklärung erfolgreich validiert!") - # Wir könnten hier aufhören oder weiter nach dem besten Treffer suchen. - # Fürs Erste nehmen wir den ersten validierten Treffer. - return validated_option_page - - # Wenn keine Option validiert wurde - self.logger.debug(f" -> Keine passende/validierte Unternehmens-Option in Begriffsklärung '{title_to_check}' gefunden.") - return None # Keine passende Option gefunden - - except (requests.exceptions.RequestException, wikipedia.exceptions.WikipediaException) as e_req: - # Netzwerkfehler oder Wikipedia-spezifische API-Fehler beim Laden/Validieren - # Diese Fehler werden vom @retry_on_failure Decorator (außerhalb von check_page) behandelt, - # ABER: Wenn sie innerhalb von check_page auftreten, brechen sie NUR die Prüfung dieses EINEN Titels ab. - # Wir wollen, dass der Hauptaufruf (search_company_article) retried wird, nicht check_page. - # Also loggen wir hier den Fehler und geben None zurück, ohne die Exception weiterzuwerfen. - self.logger.warning(f" -> Netzwerk/API-Fehler beim Laden/Validieren von '{title_to_check}': {e_req}. Überspringe diesen Titel.") - # Optional: Kleine Pause bei Netzwerkfehlern, um API nicht weiter zu reizen - # time.sleep(0.5) - return None # Diesen Titel überspringen und nächsten versuchen - - except Exception as e_page: - # Andere unerwartete Fehler bei der Seitenverarbeitung - self.logger.error(f" -> Unerwarteter Fehler bei Verarbeitung von Titel '{title_to_check}': {type(e_page).__name__} - {e_page}") - self.logger.debug(traceback.format_exc()) # Logge Traceback für unerwartete Fehler - return None # Diesen Titel überspringen - - - # --- Haupt-Suchlogik (Iteriere durch Suchbegriffe und Ergebnisse) --- - self.logger.debug(f" -> Versuche direkten Match für '{company_name}'...") - # Versuche zuerst den exakten Firmennamen als Titel zu laden und zu validieren - validated_page = check_page(company_name) - if validated_page: - return validated_page # Direkter, validierter Treffer gefunden! - - self.logger.debug(f" -> Kein direkter Treffer/validiert. Starte Suche mit generierten Begriffen: {search_terms}") - # Wenn kein direkter Treffer, führe eine Suche mit den generierten Begriffen durch - for term in search_terms: - try: - self.logger.debug(f" -> Suche mit Begriff: '{term}'...") - # Führe die Suche über die wikipedia-Bibliothek durch - # wikipedia.search wirft exceptions (z.B. PageError), die vom retry_on_failure im Decorator gefangen werden - search_results = wikipedia.search(term, results=getattr(Config, 'WIKIPEDIA_SEARCH_RESULTS', 5)) - self.logger.debug(f" -> Suchergebnisse für '{term}': {search_results}") - - if not search_results: - self.logger.debug(f" -> Keine Suchergebnisse für '{term}'.") - continue # Nächsten Suchbegriff versuchen - - # Prüfe jeden Titel in den Suchergebnissen - for title in search_results: - validated_page = check_page(title) - if validated_page: - return validated_page # Ersten validierten Artikel gefunden! - - # Kleine Pause zwischen dem Prüfen einzelner Suchergebnisse - # time.sleep(0.05) # Sehr kurz, optional - - except Exception as e_search: - # Fehler während wikipedia.search (z.B. Netzwerkfehler, API-Fehler) - # Diese werden vom @retry_on_failure Decorator der search_company_article Methode behandelt. - # Werfen Sie die Exception erneut, damit der Decorator sie fangen kann. - self.logger.error(f"Fehler während Wikipedia-Suche für '{term}': {type(e_search).__name__} - {e_search}") - raise e_search # Exception weitergeben für Retry des gesamten search_company_article Calls - - - # Wenn alle Suchbegriffe und alle Ergebnisse geprüft wurden und kein validierter Artikel gefunden wurde - self.logger.warning(f"Kein passender & validierter Wikipedia-Artikel für '{company_name}' gefunden nach Prüfung aller Begriffe und Optionen.") - return None # Signalisiert, dass kein passender Artikel gefunden wurde - - - # retry_on_failure Decorator sollte hier angewendet werden, da es externe Calls macht - @retry_on_failure - def extract_company_data(self, page_url): - """ - Extrahiert Firmendaten (erster Absatz, Infobox-Werte, Kategorien) - von einer gegebenen Wikipedia-Artikel-URL. - - Args: - page_url (str): Die URL des Wikipedia-Artikels. - - Returns: - dict: Ein Dictionary mit den extrahierten Daten oder Default-Werten ('k.A.'). - """ - # Default-Ergebnis im Fehlerfall oder bei ungültiger URL - default_result = {'url': page_url if page_url else 'k.A.', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'} - - # Grundlegende URL-Prüfung - if not page_url or not isinstance(page_url, str) or "wikipedia.org/wiki/" not in page_url.lower(): - self.logger.warning(f"extract_company_data: Ungültige oder keine Wikipedia-URL '{page_url}'.") - return default_result - - self.logger.info(f"Extrahiere Daten für Wiki-URL: {page_url}") - - # Holen Sie das Soup-Objekt der Seite (nutzt interne Methode mit Retry) - soup = self._get_page_soup(page_url) - if not soup: - self.logger.error(f" -> Fehler: Konnte Seite {page_url} nicht laden oder parsen.") - # Das default_result enthält bereits die URL und k.A. für Daten. - return default_result - - # Extrahiere die einzelnen Datenpunkte - self.logger.debug(" -> Extrahiere erster Absatz...") - first_paragraph = self._extract_first_paragraph_from_soup(soup) - - self.logger.debug(" -> Extrahiere Kategorien...") - categories_val = self.extract_categories(soup) - - self.logger.debug(" -> Extrahiere Branche aus Infobox...") - # Nutzt interne Methode _extract_infobox_value, die extract_numeric_value nutzt - branche_val = self._extract_infobox_value(soup, 'branche') - - self.logger.debug(" -> Extrahiere Umsatz aus Infobox...") - umsatz_val = self._extract_infobox_value(soup, 'umsatz') - - self.logger.debug(" -> Extrahiere Mitarbeiter aus Infobox...") - mitarbeiter_val = self._extract_infobox_value(soup, 'mitarbeiter') - - - # Baue das Ergebnis-Dictionary zusammen - result = { - 'url': page_url, - 'first_paragraph': first_paragraph, - 'branche': branche_val, - 'umsatz': umsatz_val, - 'mitarbeiter': mitarbeiter_val, - 'categories': categories_val - } - - # Loggen Sie eine Zusammenfassung der extrahierten Daten - self.logger.info(f" -> Extrahierte Daten: P='{first_paragraph[:50]}...', B='{branche_val}', U='{umsatz_val}', M='{mitarbeiter_val}', C='{categories_val[:50]}...'") - - return result - -# ============================================================================== -# 6. DATA PROCESSOR CLASS (PART 1: Init & Status-Checker) -# (Entspricht logisch dem Beginn von 'data_processor.py') -# ============================================================================== - -class DataProcessor: - """ - Zentrale Klasse zur Orchestrierung und Verarbeitung von Unternehmensdaten - aus dem Google Sheet. Enthält die Logik für die Verarbeitung einzelner - Zeilen sowie die Steuerung verschiedener Batch-Modi und Dienstprogramme. - Nutzt Instanzen von Handler-Klassen (Sheet, Wiki etc.) als Worker. - """ - def __init__(self, sheet_handler, wiki_scraper): # Akzeptiert benötigte Worker-Instanzen - """ - Initialisiert den DataProcessor mit Instanzen von Handler-Klassen. - - Args: - sheet_handler (GoogleSheetHandler): Eine initialisierte Instanz. - wiki_scraper (WikipediaScraper): Eine initialisierte Instanz. - # Fügen Sie hier weitere benötigte Handler/Worker hinzu (z.B. OpenAIHandler, SerpAPIHandler), - # falls diese als eigene Klassen ausgelagert werden. - """ - # Erhalten Sie eine Logger-Instanz für diese Klasse - self.logger = logging.getLogger(__name__ + ".DataProcessor") - self.logger.info("Initialisiere DataProcessor...") - - # Attribute für ML-Modellierung (werden beim ersten Bedarf geladen) - self.model = None - self.imputer = None - self._expected_features = None # Liste der erwarteten Feature-Spalten für Vorhersage - - # Überprüfen Sie, ob gültige Handler-Instanzen übergeben wurden - if not isinstance(sheet_handler, GoogleSheetHandler): - self.logger.critical("DataProcessor Init FEHLER: Kein gültiger GoogleSheetHandler übergeben!") - raise ValueError("DataProcessor benötigt eine gültige GoogleSheetHandler Instanz.") - if not isinstance(wiki_scraper, WikipediaScraper): - self.logger.critical("DataProcessor Init FEHLER: Kein gültiger WikipediaScraper übergeben!") - raise ValueError("DataProcessor benötigt eine gültige WikipediaScraper Instanz.") - - # Speichern Sie die Handler-Instanzen als Attribute - self.sheet_handler = sheet_handler - self.wiki_scraper = wiki_scraper - # self.openai_handler = openai_handler # Beispiel, falls ausgelagert - # self.serpapi_handler = serpapi_handler # Beispiel, falls ausgelagert - - self.logger.info("DataProcessor initialisiert mit Handlern.") - - # Definieren Sie hier (oder als Klassenattribut) die Zuordnung von Schritt-Typen - # zu den relevanten Spaltenschlüsseln für die Statusprüfung. - # Diese werden von _should_run_based_on_status verwendet. - self._step_status_map = { - 'wiki': { # Wiki Suche & Extraktion (AN) + Wiki Verifizierung (AX) & S='X(URL Copied)' - # ACHTUNG: In _process_single_row wird 'wiki' (AN) und 'wiki_verify' (AX) separat behandelt. - # Diese Map ist primär für die Batch-Modi relevant, die auf EINEM TS prüfen. - # Für _process_single_row machen wir die Checks im Code direkt oder mit granulareren Helfern. - # Lassen wir diese Map erstmal für die Batch-Modi. - 'wiki_verify': "Wiki Verif. Timestamp", # AX - 'website_scrape': "Website Scrape Timestamp", # AT - 'summarize_website': "Website Scrape Timestamp", # AT (Zusammenfassung triggert mit Scraping) - 'branch_eval': "Timestamp letzte Prüfung", # AO - 'find_wiki_serp': "SerpAPI Wiki Search Timestamp", # AY - 'contact_search': "Contact Search Timestamp", # AM - 'wiki_updates_from_chatgpt': "Chat Wiki Konsistenzprüfung" # S (Sonderfall: check auf Status nicht Timestamp) - # 'wiki_extract': "Wikipedia Timestamp", # AN (Wird in _process_single_row speziell geprüft) - } - } - # HINWEIS: Die Logik, ob ein Schritt ausgeführt werden soll, ist komplexer als nur ein Timestamp - # (z.B. 'find_wiki_serp' braucht auch leere M und Größe; 'summarize_website' braucht gefülltes AR). - # Die folgende Methode _should_run_based_on_status wird hauptsächlich für die sequenzielle - # Verarbeitung (_process_single_row) und den Re-Eval Modus verfeinert. Batch-Modi haben oft - # ihre eigene spezifische Logik zur Zeilenauswahl. - - # --- Interne Hilfsmethode zur Statusprüfung einer Zeile für einen Schritt-Typ --- - def _should_run_based_on_status(self, row_data, step_type): - """ - Prüft, ob ein bestimmter Verarbeitungsschritt für die gegebene Zeile - basierend auf Timestamps oder Status im Sheet ausgeführt werden sollte. - Dies ignoriert das force_reeval Flag, das vom Aufrufer behandelt werden muss. - - Args: - row_data (list): Die Listendaten für die Zeile. - step_type (str): Der Typ des Schritts ('wiki', 'web', 'chat' im Kontext von _process_single_row, - oder spezifischere Keys für Batch-Modi wie 'wiki_verify', 'website_scrape', etc.). - ACHTUNG: Die Interpretation von step_type hängt vom Aufrufer ab (_process_single_row vs. Batch-Methoden). - # force_reeval wird HIER nicht geprüft. Der Aufrufer muss das OR force_reeval machen. - - Returns: - bool: True, wenn der Schritt basierend auf dem Status in der Zeile ausgeführt werden sollte. - """ - # Hilfsfunktion für sicheren Zellenzugriff innerhalb dieser Methode - def get_cell_value_safe(row, column_key): - idx = COLUMN_MAP.get(column_key) - if idx is not None and len(row) > idx: - # Rückgabe des Wertes, sicherstellen, dass es nicht None ist - return row[idx] if row[idx] is not None else '' - # Logge auf Debug, wenn der Index fehlt oder die Zeile zu kurz ist - # self.logger.debug(f"Kann Wert für '{column_key}' (Index {idx}) nicht sicher abrufen (Zeilenlänge {len(row)}).") # Zu viel Lärm - return '' # Gebe leeren String für fehlende Spalten zurück - - - status_needs_run = False # Standard: Verarbeitung nicht nötig basierend auf Status - - if step_type == 'wiki': - # Für die 'wiki' Gruppe in _process_single_row (Suche, Extraktion): - # Lauf, wenn AN leer ist ODER S den speziellen Wert 'X (URL Copied)' hat. - an_value = get_cell_value_safe(row_data, "Wikipedia Timestamp").strip() - s_value = get_cell_value_safe(row_data, "Chat Wiki Konsistenzprüfung").strip().upper() # Case-insensitive prüfen - - if not an_value or s_value == "X (URL COPIED)": - status_needs_run = True - # self.logger.debug(f" -> Wiki-Schritt nötig (AN leer? {not an_value}, S='X (URL COPIED)'? {s_value == 'X (URL COPIED)'})") # Zu viel Lärm - # else: self.logger.debug(f" -> Wiki-Schritt nicht nötig (AN='{an_value}', S='{s_value}')") # Zu viel Lärm - - elif step_type == 'web': - # Für die 'web' Gruppe in _process_single_row (Scraping, Summarization): - # Lauf, wenn AT leer ist. - at_value = get_cell_value_safe(row_data, "Website Scrape Timestamp").strip() - if not at_value: - status_needs_run = True - # self.logger.debug(f" -> Web-Schritt nötig (AT leer)") # Zu viel Lärm - # else: self.logger.debug(f" -> Web-Schritt nicht nötig (AT='{at_value}')") # Zu viel Lärm - - elif step_type == 'chat': - # Für die 'chat' Gruppe in _process_single_row (Branch, FSM, Emp, Umsatz Evals): - # Lauf, wenn AO leer ist. - # ACHTUNG: Der Trigger "Wiki Daten gerade aktualisiert" kann HIER nicht geprüft werden, - # da er Laufzeit-Status von _process_single_row ist. Das muss _process_single_row selbst handeln. - ao_value = get_cell_value_safe(row_data, "Timestamp letzte Prüfung").strip() - if not ao_value: - status_needs_run = True - # self.logger.debug(f" -> Chat-Schritt nötig (AO leer)") # Zu viel Lärm - # else: self.logger.debug(f" -> Chat-Schritt nicht nötig (AO='{ao_value}')") # Zu viel Lärm - - # --- Fügen Sie hier Checks für weitere spezifische Batch/Utility-Schritte hinzu, falls _should_run_based_on_status - # universeller verwendet werden soll. Aktuell ist es hauptsächlich für _process_single_row gedacht. --- - - # Für die Batch-Modi (z.B. process_verification_batch): - # Die Batch-Methoden haben oft spezifischere Kriterien ZUSÄTZLICH zum Timestamp, - # z.B. "Wiki URL (M) muss gefüllt sein und AX muss leer sein". - # Es ist oft einfacher, diese spezifische Logik in der jeweiligen Batch-Methode zu implementieren, - # anstatt sie hier zu generalisieren. - # Daher wird _should_run_based_on_status primär von _process_single_row verwendet, um die - # groben Gruppen 'wiki', 'web', 'chat' zu steuern. - - else: - # Wenn ein unbekannter step_type übergeben wird - self.logger.warning(f"_should_run_based_on_status aufgerufen mit unbekanntem step_type '{step_type}'. Gibt False zurück.") - status_needs_run = False - - # Optional: Logge das Ergebnis dieser spezifischen Prüfung - # self.logger.debug(f" -> _should_run_based_on_status('{step_type}') Ergebnis: {status_needs_run}") # Zu viel Lärm - - return status_needs_run - - # --- Die folgenden Methoden werden in separaten Teilen bereitgestellt --- - # _process_single_row method... (kommt in Teil 5 & 6) - # process_reevaluation_rows method... (kommt später) - # process_rows_sequentially method... (kommt später) - # process_verification_batch method... (kommt später) - # process_website_batch method... (kommt später) - # process_summarization_batch method... (kommt später) - # process_branch_batch method... (kommt später) - # process_serp_website_lookup method... (kommt später) - # process_website_details method... (kommt später) - # process_contact_research method... (kommt später) - # process_wiki_updates_from_chatgpt method... (kommt später) - # process_wiki_reextract_missing_an method... (kommt später) - # prepare_data_for_modeling method... (kommt später) - # train_technician_model method... (kommt später) - # run_batch_dispatcher method... (kommt später, falls benötigt) - -# --- Interne Hilfsmethoden zur Prüfung, ob ein Schritt ausgeführt werden soll --- - # Diese Methoden kapseln die Logik zur Entscheidung, ob ein Schritt basierend - # auf dem Zeilenstatus (Timestamps, Flags) und dem force_reeval Flag ausgeführt werden soll. - - def _get_cell_value_safe(self, row, column_key): - """ - Hilfsfunktion für sicheren Zellenzugriff anhand des COLUMN_MAP Schlüssels. - Gibt leeren String zurück, wenn Index nicht existiert oder Zeile zu kurz ist. - """ - idx = COLUMN_MAP.get(column_key) - if idx is not None and len(row) > idx: - # Rückgabe des Wertes, sicherstellen, dass es nicht None ist - return row[idx] if row[idx] is not None else '' - # Logging kann hier sehr laut sein, nur bei Bedarf aktivieren oder auf Debug lassen - # self.logger.debug(f"Kann Wert für '{column_key}' (Index {idx}) nicht sicher abrufen (Zeilenlänge {len(row)}).") - return '' # Gebe leeren String für fehlende Spalten zurück - - - def _needs_website_processing(self, row_data, force_reeval): - """ - Prüft, ob Website-Scraping/Summarization für diese Zeile nötig ist. - Nötig, wenn force_reeval True ist ODER wenn der Website Scrape Timestamp (AT) leer ist. - """ - if force_reeval: - # self.logger.debug(" -> Website-Schritt nötig (force_reeval=True)") # Zu viel Lärm - return True - - # Prüfe, ob der Website Scrape Timestamp (AT) leer ist - at_value = self._get_cell_value_safe(row_data, "Website Scrape Timestamp").strip() - if not at_value: - # self.logger.debug(" -> Website-Schritt nötig (AT leer)") # Zu viel Lärm - return True - - # self.logger.debug(f" -> Website-Schritt nicht nötig (AT='{at_value}')") # Zu viel Lärm - return False - - - def _needs_wiki_processing(self, row_data, force_reeval): - """ - Prüft, ob Wikipedia-Suche/Extraktion für diese Zeile nötig ist. - Nötig, wenn force_reeval True ist ODER wenn der Wikipedia Timestamp (AN) - leer ist ODER wenn Status S 'X (URL Copied)' ist. - """ - if force_reeval: - # self.logger.debug(" -> Wiki-Extraktion/-Suche nötig (force_reeval=True)") # Zu viel Lärm - return True - - # Prüfe, ob der Wikipedia Timestamp (AN) leer ist - an_value = self._get_cell_value_safe(row_data, "Wikipedia Timestamp").strip() - if not an_value: - # self.logger.debug(" -> Wiki-Extraktion/-Suche nötig (AN leer)") # Zu viel Lärm - return True - - # Prüfe, ob Status S 'X (URL Copied)' ist (signalisiert neue URL wurde kopiert) - s_value = self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzprüfung").strip().upper() - if s_value == "X (URL COPIED)": - # self.logger.debug(" -> Wiki-Extraktion/-Suche nötig (S='X (URL Copied)')") # Zu viel Lärm - return True - - # self.logger.debug(f" -> Wiki-Extraktion/-Suche nicht nötig (AN='{an_value}', S='{s_value}')") # Zu viel Lärm - return False - - - def _needs_wiki_verification(self, row_data, force_reeval): - """ - Prüft, ob Wikipedia-Verifizierung (S-Y) für diese Zeile nötig ist. - Nötig, wenn force_reeval True ist ODER wenn der Wiki Verif. Timestamp (AX) leer ist - UND eine Wiki URL (M) vorhanden ist. - """ - if force_reeval: - # self.logger.debug(" -> Wiki-Verifizierung nötig (force_reeval=True)") # Zu viel Lärm - return True - - # Prüfe, ob der Wiki Verif. Timestamp (AX) leer ist - ax_value = self._get_cell_value_safe(row_data, "Wiki Verif. Timestamp").strip() - if not ax_value: - # Prüfe ZUSÄTZLICH, ob eine Wiki URL (M) vorhanden ist, da Verifizierung sonst sinnlos ist - 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"]: - # self.logger.debug(" -> Wiki-Verifizierung nötig (AX leer UND M gefüllt)") # Zu viel Lärm - return True - # else: self.logger.debug(" -> Wiki-Verifizierung nicht nötig (AX leer, aber M leer/k.A.)") # Zu viel Lärm - # else: self.logger.debug(f" -> Wiki-Verifizierung nicht nötig (AX='{ax_value}')") # Zu viel Lärm - - return False - - - def _needs_chat_evaluations(self, row_data, force_reeval, wiki_data_just_updated): - """ - Prüft, ob ChatGPT-Evaluationen (Branch, FSM etc.) für diese Zeile nötig sind. - Nötig, wenn force_reeval True ist ODER wenn der Timestamp letzte Prüfung (AO) - leer ist ODER wenn Wiki-Daten gerade aktualisiert wurden. - """ - if force_reeval: - # self.logger.debug(" -> Chat-Evaluationen nötig (force_reeval=True)") # Zu viel Lärm - return True - - # Prüfe, ob der Timestamp letzte Prüfung (AO) leer ist - ao_value = self._get_cell_value_safe(row_data, "Timestamp letzte Prüfung").strip() - if not ao_value: - # self.logger.debug(" -> Chat-Evaluationen nötig (AO leer)") # Zu viel Lärm - return True - - # Prüfe, ob Wiki-Daten in diesem Lauf gerade aktualisiert wurden - if wiki_data_just_updated: - # self.logger.debug(" -> Chat-Evaluationen nötig (Wiki-Daten gerade aktualisiert)") # Zu viel Lärm - return True - - # self.logger.debug(f" -> Chat-Evaluationen nicht nötig (AO='{ao_value}' und Wiki-Daten nicht aktualisiert)") # Zu viel Lärm - return False - - - # --- Die _process_single_row Methode folgt in den nächsten Teilen --- - # Diese Methode wird sehr lang und wird auf mehrere Nachrichten aufgeteilt. - # Sie wird die oben definierten _needs_... Methoden verwenden. - - # def _process_single_row(...): # <-- Beginnt im nächsten Teil - -# --- Methode: Verarbeitung einer einzelnen Zeile --- - # Diese Methode gehört in die Klasse DataProcessor. - # @retry_on_failure # Nicht sinnvoll auf dieser Orchestrierungsebene - def _process_single_row(self, row_num_in_sheet, row_data, - steps_to_run, force_reeval=False, clear_x_flag=False): # NEUES ARGUMENT hinzugefügt - """ - Verarbeitet die Daten für eine einzelne Zeile im Sheet, führt ausgewählte - Anreicherungs- und Analyseprozesse durch, basierend auf Timestamps/Status - oder dem force_reeval Flag. Sammelt und schreibt Ergebnisse zurück. - - Args: - row_num_in_sheet (int): Die 1-basierte Zeilennummer im Google Sheet. - row_data (list): Die rohen Listendaten für diese Zeile. - steps_to_run (set/list): Menge oder Liste von Schlüsseln der Schritte, - die in diesem Lauf berücksichtigt werden sollen - (z.B. {'wiki', 'web', 'chat'}). - force_reeval (bool, optional): Ignoriert Timestamps/Status und erzwingt - die Ausführung der in steps_to_run - enthaltenen Schritte. Defaults to False. - """ - self.logger.info(f"--- Starte Verarbeitung für Zeile {row_num_in_sheet} {'(Re-Eval)' if force_reeval else ''} (Schritte: {', '.join(steps_to_run) if steps_to_run else 'Keine ausgewählt'}) ---") - updates = [] # Liste zur Sammlung von Sheet-Updates für diese Zeile - now_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - any_processing_done = False # Flag, ob irgendein Schritt ausgeführt wurde - wiki_data_updated_in_this_run = False # Flag, ob Wiki-Daten NEU extrahiert/gesetzt wurden (relevant für Chat-Trigger) - - - # Hilfsfunktion für sicheren Zellenzugriff (verwendet die interne Methode) - # get_cell_value = lambda key: self._get_cell_value_safe(row_data, key) - # Direkter Aufruf self._get_cell_value_safe ist klarer - - # Initiale Werte lesen (die für mehrere Schritte benötigt werden könnten) - # Stellen Sie sicher, dass alle benötigten Spalten in COLUMN_MAP vorhanden sind, - # sonst wirft _get_cell_value_safe auf Debug-Level. - company_name = self._get_cell_value_safe(row_data, "CRM Name").strip() - website_url = self._get_cell_value_safe(row_data, "CRM Website").strip() # Arbeitskopie der URL - original_website_url_in_sheet = website_url # Originalwert aus Sheet behalten, für Lookup-Logik - crm_branche = self._get_cell_value_safe(row_data, "CRM Branche").strip() - crm_beschreibung = self._get_cell_value_safe(row_data, "CRM Beschreibung").strip() - konsistenz_s = self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzprüfung").strip() # Trimme hier schon - - - # Lade vorhandene Wiki-Daten (könnten alt sein, werden ggf. überschrieben) - # Verwende 'k.A.' als Standard, wenn die Zellen leer sind. - # Dies sind die Daten, die am ENDE des Wiki-Schritts in final_wiki_data stehen SOLLTEN, - # falls der Wiki-Schritt NICHT ausgeführt wird. - current_wiki_data = { - 'url': self._get_cell_value_safe(row_data, "Wiki URL") 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() # Arbeitskopie für extrahierte/neue Daten - - # Lade vorhandenen Website-Rohtext und Zusammenfassung - # Dies sind die Daten, die am ENDE des Web-Schritts in den Variablen stehen SOLLTEN, - # falls der Web-Schritt NICHT ausgeführt wird. - current_website_raw = self._get_cell_value_safe(row_data, "Website Rohtext") or 'k.A.' - current_website_summary = self._get_cell_value_safe(row_data, "Website Zusammenfassung") or 'k.A.' - website_raw = current_website_raw # Arbeitskopie - website_summary = current_website_summary # Arbeitskopie - - - # --- 1. Website Handling (Lookup, Scraping, Summarization) --- - # Dieser Schritt wird ausgeführt, wenn 'web' in steps_to_run enthalten ist UND - # (_needs_website_processing True ist ODER force_reeval True ist). - # _needs_website_processing prüft nur AT. Die Lookup-Logik (D leer) ist separat. - # Die Website-Verarbeitung umfasst Lookup (optional), Scraping und Summarization. - - run_website_step = 'web' in steps_to_run - website_processing_needed_based_on_status = self._needs_website_processing(row_data, force_reeval) - - if run_website_step and website_processing_needed_based_on_status: - any_processing_done = True # Markiere, dass in dieser Zeile etwas getan wird - self.logger.info(f"Zeile {row_num_in_sheet}: Führe WEBSITE Schritte aus (Grund: {'Re-Eval' if force_reeval else 'AT leer'})") - - # Website Lookup nur, wenn die URL in Spalte D leer oder "k.A." ist - if not original_website_url_in_sheet or original_website_url_in_sheet.lower() == "k.a.": - self.logger.debug(" -> Website URL (D) leer oder k.A., suche via SERP...") - # Annahme: serp_website_lookup global definiert oder als Methode einer SerpAPIHandler Klasse (hier global/utils) - try: - # Nutzt den globalen retry_on_failure Decorator - new_website = serp_website_lookup(company_name) # Annahme: serp_website_lookup in utils.py - if new_website != "k.A.": - website_url = new_website # Update die lokale Variable für den weiteren Schritt (Scraping) - # Fügen Sie das Update für Spalte D zur Liste hinzu - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["CRM Website"] + 1)}{row_num_in_sheet}', 'values': [[website_url]]}) - self.logger.info(f" -> Neue Website gefunden und für Update D:{row_num_in_sheet} vorgemerkt: {website_url}") - else: - self.logger.warning(f" -> Keine neue Website via SERP gefunden für '{company_name}'.") - # website_url bleibt leer oder k.A. in diesem Fall - except Exception as e_serp_lookup: - self.logger.error(f"FEHLER bei SERP Website Lookup für '{company_name}': {e_serp_lookup}") - # Bei Fehler bleibt website_url unverändert (leer oder k.A.) - pass # Fahren Sie fort, falls eine URL im Sheet war oder gefunden wurde - - # Führe Scraping und Zusammenfassung nur durch, wenn eine gültige Website URL vorhanden ist (lokale Variable website_url) - if website_url and website_url.lower() != "k.a.": - self.logger.debug(f" -> Scrape Rohtext von {website_url}...") - # Annahme: get_website_raw global definiert oder als Methode eines WebsiteScraper (hier global/utils) - try: - # Nutzt den globalen retry_on_failure Decorator - new_website_raw = get_website_raw(website_url) # Annahme: get_website_raw in utils.py - website_raw = new_website_raw # Lokale Variable aktualisieren (AR Wert) - - # Zusammenfassung nur, wenn gültiger Rohtext extrahiert wurde - if website_raw != "k.A." and website_raw != "k.A. (Nur Cookie-Banner erkannt)" and website_raw.strip(): - self.logger.debug(f" -> Fasse Rohtext zusammen (Länge: {len(str(website_raw))})...") - # Annahme: summarize_website_content global definiert oder als Methode eines OpenAICreator (hier global/utils) - try: - # Nutzt den globalen retry_on_failure Decorator - new_website_summary = summarize_website_content(website_raw) # Annahme: summarize_website_content in utils.py - website_summary = new_website_summary if new_website_summary and new_website_summary.strip() else "k.A. (Keine Zusammenfassung erhalten)" - # Fügen Sie das Update für Spalte AS zur Liste hinzu - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[website_summary]]}) - except Exception as e_summary: - self.logger.error(f"FEHLER bei Website Zusammenfassung für '{company_name}': {e_summary}") - website_summary = "k.A. (Fehler Zusammenfassung)" # Lokale Variable setzen - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[website_summary]]}) - - else: - self.logger.warning(" -> Kein gültiger Rohtext zum Zusammenfassen vorhanden.") - website_summary = "k.A." # Stelle sicher, dass die lokale Variable korrekt gesetzt ist, falls nicht zusammengefasst - # Füge 'k.A.' Update für AS hinzu (nur wenn es vorher nicht k.A. war?) - # Oder immer setzen, wenn der Schritt lief und keine Zusammenfassung erstellt wurde. - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]}) - - - # Fügen Sie das Update für Spalte AR (Rohtext) zur Liste hinzu - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [[website_raw]]}) - - except Exception as e_scrape: - self.logger.error(f"FEHLER beim Website Scraping für '{company_name}' ({website_url}): {e_scrape}") - website_raw, website_summary = "k.A. (Fehler)", "k.A. (Fehler)" # Lokale Variablen setzen - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [[website_raw]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[website_summary]]}) - - - else: - self.logger.warning(f" -> Keine gültige Website URL vorhanden/gefunden für '{company_name}'. Website Scraping übersprungen.") - # Stellen Sie sicher, dass AR und AS auf k.A. gesetzt werden, wenn der Schritt lief, aber keine URL da war - website_raw, website_summary = "k.A.", "k.A." # Lokale Variablen setzen - # Fügen Sie Updates für AR und AS hinzu, falls nötig - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]}) - - - # Setzen Sie den Website Scrape Timestamp (AT), da der Website-Schritt lief - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Scrape Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) - - elif run_website_step: # Website Schritt war ausgewählt, aber nicht nötig basierend auf Status/Re-Eval - self.logger.debug(f"Zeile {row_num_in_sheet}: Überspringe WEBSITE Schritte (AT vorhanden und kein Re-Eval).") - # Wenn der Schritt übersprungen wird, verwenden wir die vorhandenen Werte aus dem Sheet, - # die bereits zu Beginn der Methode geladen wurden (current_website_raw, current_website_summary). - # website_raw und website_summary behalten ihre initialen Werte. - - - # --- Der Code für den nächsten Verarbeitungsschritt (Wikipedia) folgt im nächsten Teil --- - # Definiton der Methode _process_single_row wird in der nächsten Nachricht fortgesetzt. - -# --- 2. Wikipedia Handling (Search, Extraction, Status Reset) --- - # Dieser Schritt wird ausgeführt, wenn 'wiki' in steps_to_run enthalten ist UND - # (_needs_wiki_processing True ist ODER force_reeval True ist). - # _needs_wiki_processing prüft AN und S='X (URL Copied)'. - # Die Logik für S='X (URL Copied)' dient dazu, eine URL, die durch die Wiki-Update - # Funktion in M kopiert wurde, sofort neu extrahieren zu lassen. - - run_wiki_step = 'wiki' in steps_to_run - wiki_processing_needed_based_on_status = self._needs_wiki_processing(row_data, force_reeval) - - if run_wiki_step and wiki_processing_needed_based_on_status: - any_processing_done = True # Markiere, dass in dieser Zeile etwas getan wird - - # Bestimme den Grund für die Ausführung dieses Schritts für das Logging - grund_message_parts = [] - if force_reeval: grund_message_parts.append('Re-Eval') - if not self._get_cell_value_safe(row_data, "Wikipedia Timestamp").strip(): grund_message_parts.append('AN leer') - if self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzprüfung").strip().upper() == "X (URL COPIED)": grund_message_parts.append("S='X (URL Copied)'") - grund_message = ", ".join(grund_message_parts) - - self.logger.info(f"Zeile {row_num_in_sheet}: Führe WIKI Suche/Extraktion aus (Grund: {grund_message})...") - - url_in_m = self._get_cell_value_safe(row_data, "Wiki URL").strip() - url_to_extract = None # Die URL, von der wir am Ende Daten extrahieren werden - search_was_needed = False # Flag, ob eine neue Suche durchgeführt wurde - - # --- Logik zur Bestimmung der URL, die verwendet werden soll --- - # Priorität (bei Ausführung des Wiki-Schritts): - # 1. Wenn S == "X (URL Copied)": Ignoriere URL in M, führe neue Suche aus. - # 2. Wenn force_reeval True: Nimm URL in M, WENN gültig aussehend. Sonst neue Suche. - # 3. Wenn AN leer (und kein S="X(URL Copied)", kein Re-Eval): Nimm URL in M, WENN valide. Sonst neue Suche. - - status_s_indicates_reparse = self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzprüfung").strip().upper() == "X (URL COPIED)" - an_value = self._get_cell_value_safe(row_data, "Wikipedia Timestamp").strip() - m_url_exists_and_looks_valid = url_in_m and url_in_m.lower() not in ["k.a.", "kein artikel gefunden"] and url_in_m.lower().startswith("http") and "wikipedia.org/wiki/" in url_in_m.lower() - - - if status_s_indicates_reparse: - self.logger.warning(f" -> Status S ist 'X (URL Copied)', ignoriere URL '{url_in_m}' in M und starte neue Suche...") - search_was_needed = True # Suche ist nötig - elif force_reeval: - self.logger.debug(" -> Re-Eval Modus aktiv für Wiki-Schritt.") - if m_url_exists_and_looks_valid: - # Im Re-Eval Modus nehmen wir die URL aus M an, ohne erneute Validierung oder Suche (Vertrauen auf M, falls valide aussieht)! - self.logger.info(f" -> Re-Eval: Nutze vorhandene URL aus Spalte M direkt: {url_in_m}") - url_to_extract = url_in_m # Verwende die URL aus M - else: - # Wenn M leer/ungültig ist, auch im Re-Eval Modus neu suchen - self.logger.warning(f" -> Re-Eval: Spalte M ist leer oder ungültig ('{url_in_m}'). Starte neue Suche...") - search_was_needed = True # Suche ist nötig - elif not an_value: # Nur wenn AN fehlt (und kein S="X(Copied)" oder Re-Eval) - if m_url_exists_and_looks_valid: - # Wenn AN fehlt und M gefüllt ist, prüfen wir die Validität der M-URL - self.logger.debug(f" -> AN fehlt, prüfe Validität der URL aus M: {url_in_m}") - try: - # Extrahieren des Titels aus der URL (z.B. 'Unternehmen_AG' aus ".../wiki/Unternehmen_AG") - # Nutzt unquote (aus utils) zur Dekodierung von URL-Sonderzeichen - title_from_url_part = url_in_m.split('/wiki/', 1)[1].split('#')[0] # Titelteil nach /wiki/, Anker entfernen - title_from_url = unquote(title_from_url_part).replace('_', ' ') # Dekodieren und Unterstriche ersetzen - - # Laden des Page Objekts, um es mit _validate_article zu prüfen - # Dieser Aufruf kann PageError, DisambiguationError etc. werfen - page_from_m = wikipedia.page(title_from_url, auto_suggest=False, preload=True) - - # Validierung des Artikels mit der Scraper-Methode - if self.wiki_scraper._validate_article(page_from_m, company_name, website_url): - url_to_extract = page_from_m.url # Bestätigte URL verwenden - self.logger.info(f" -> Vorhandene URL aus M '{url_to_extract}' ist valide und wird verwendet.") - else: - self.logger.warning(f" -> Vorhandene URL aus M '{page_from_m.title}' ist NICHT valide. Starte neue Suche...") - search_was_needed = True # Suche ist nötig - except (wikipedia.exceptions.PageError, wikipedia.exceptions.DisambiguationError) as e_wiki_m: - # Wenn die URL in M zu einem nicht existierenden Artikel oder einer Begriffsklärung führt - self.logger.warning(f" -> Vorhandene URL aus M '{url_in_m}' führt zu Fehler ({type(e_wiki_m).__name__}). Starte neue Suche...") - search_was_needed = True # Suche ist nötig - # Logge die Disambiguation Optionen auf Debug - if isinstance(e_wiki_m, wikipedia.exceptions.DisambiguationError): - self.logger.debug(f" -> Disambiguation Optionen: {e_wiki_m.options[:10]}...") - except Exception as e_val_m: - # Andere Fehler beim Prüfen der URL aus M (z.B. URL-Parsing-Fehler vor wikipedia.page) - self.logger.exception(f" -> Unerwarteter Fehler beim Prüfen der URL aus M '{url_in_m}': {e_val_m}. Starte neue Suche...") - search_was_needed = True # Suche ist nötig - - else: # M ist leer/ungültig und AN fehlt -> Suche starten - self.logger.info(f" -> AN fehlt und M leer/ungültig. Starte Wikipedia-Suche für '{company_name}'...") - search_was_needed = True # Suche ist nötig - - - # --- Führe die Suche aus, wenn search_was_needed True ist --- - if search_was_needed: - self.logger.debug(f" -> Führe Wikipedia Suche über scraper durch...") - try: - # Nutzt den retry_on_failure Decorator der search_company_article Methode - validated_page = self.wiki_scraper.search_company_article(company_name, website_url) # Nutze ggf. neue Website URL - if validated_page: - url_to_extract = validated_page.url # Setze die gefundene und validierte URL - self.logger.info(f" -> Suche erfolgreich, validierte URL: {url_to_extract}") - else: - # Suche fand keinen validierten Artikel - self.logger.warning(f" -> Suche fand keinen validierten Artikel für '{company_name}'.") - url_to_extract = 'Kein Artikel gefunden' # Signalisiert kein Artikel gefunden - except Exception as e_wiki_search: - self.logger.error(f"FEHLER bei Wikipedia Suche für '{company_name}': {e_wiki_search}") - url_to_extract = 'FEHLER bei Suche' # Signalisiert Fehler bei Suche - pass # Fahren Sie fort, um zumindest den Status zu setzen - - # --- Datenextraktion, wenn eine URL bestimmt wurde, von der extrahiert werden soll --- - # Extrahiere Daten, wenn url_to_extract einen Wert hat, der NICHT "Kein Artikel gefunden" oder "FEHLER bei Suche" ist - if url_to_extract and url_to_extract not in ['Kein Artikel gefunden', 'FEHLER bei Suche']: - self.logger.debug(f" -> Extrahiere Daten von URL: {url_to_extract}...") - try: - # Nutzt den retry_on_failure Decorator der extract_company_data Methode - extracted_data = self.wiki_scraper.extract_company_data(url_to_extract) - if extracted_data and extracted_data.get('url') != 'k.A.': # Prüfe auf gültige Extraktion - final_wiki_data = extracted_data # Aktualisiere die Arbeitskopie der Wiki-Daten - wiki_data_updated_in_this_run = True # Markieren, dass extrahierte Daten da sind - self.logger.info(f" -> Datenextraktion von {url_to_extract} erfolgreich.") - else: - self.logger.error(f" -> Fehler bei Datenextraktion von {url_to_extract} oder Extraktion war leer. Setze Daten auf 'k.A.'") - # Behalte die URL, aber setze alle anderen Felder auf k.A. - final_wiki_data = {'url': url_to_extract, 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'} - wiki_data_updated_in_this_run = True # Markieren, dass überschrieben wird - except Exception as e_wiki_extract: - self.logger.error(f"FEHLER bei Wikipedia Datenextraktion von {url_to_extract}: {e_wiki_extract}") - # Setze Daten auf k.A., behalte aber die URL, von der extrahiert werden sollte - final_wiki_data = {'url': url_to_extract, 'first_paragraph': 'k.A. (Fehler Extraktion)', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'} - wiki_data_updated_in_this_run = True # Markieren, dass überschrieben wird - pass # Fahren Sie fort - - # --- Sheet Updates für M-R und AN --- - # Diese Updates werden immer hinzugefügt, wenn der WIKI-Schritt lief, - # auch wenn die Suche/Extraktion fehlschlug (dann werden k.A. oder Fehlermeldungen geschrieben). - # Aktualisiere die Spalten M-R mit den finalen Daten - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki URL"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('url', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Absatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('first_paragraph', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Branche"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('branche', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('umsatz', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('mitarbeiter', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Kategorien"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('categories', 'k.A.')]]}) - - # Setze den Wikipedia Timestamp (AN), da der Wiki-Schritt lief - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wikipedia Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) - - # --- Setze S ('Chat Wiki Konsistenzprüfung') und AX ('Wiki Verif. Timestamp') zurück, wenn Neubewertung nötig ist --- - # Eine Neubewertung (Zurücksetzen von S und AX) ist nötig, wenn: - # - force_reeval True ist (immer bei Re-Eval des Wiki-Schritts) - # - Status S zuvor "X (URL Copied)" war (der Trigger für die Re-Extraktion) - # - Die neue URL in M (final_wiki_data['url']) anders ist als die ursprüngliche URL aus M (url_in_m) - # Dies stellt sicher, dass die Verifizierung (die in einem separaten Schritt/Batch laufen kann) - # nach der Datenextraktion erneut angestoßen wird. - - # Prüfen, ob das Zurücksetzen von S und AX überhaupt notwendig ist - url_changed = (url_in_m != final_wiki_data.get('url')) # Prüft ob die NEUE URL anders ist als die ursprünglich in M - - if force_reeval or status_s_indicates_reparse or url_changed: - s_idx = COLUMN_MAP.get("Chat Wiki Konsistenzprüfung") - ax_idx = COLUMN_MAP.get("Wiki Verif. Timestamp") - if s_idx is not None and ax_idx is not None: - s_let = self.sheet_handler._get_col_letter(s_idx + 1) - ax_let = self.sheet_handler._get_col_letter(ax_idx + 1) - - # Füge die Updates zum Zurücksetzen von S und AX hinzu - # S wird auf '?' gesetzt, um anzuzeigen, dass eine Verifizierung aussteht - updates.append({'range': f'{s_let}{row_num_in_sheet}', 'values': [["?"]]}) - # AX wird geleert, um die Batch-Verifizierung zu triggern - updates.append({'range': f'{ax_let}{row_num_in_sheet}', 'values': [[""]]}) - - # Bestimme den Grund-String für das Logging - grund_message_parts = [] - if force_reeval: grund_message_parts.append('Re-Eval') - if status_s_indicates_reparse: grund_message_parts.append("S='X (URL Copied)'") - if url_changed: grund_message_parts.append('URL geändert') - grund_message_s_reset = ", ".join(grund_message_parts) - - self.logger.info(f" -> Status S zurückgesetzt auf '?' und Timestamp AX geleert für erneute Verifikation (Grund: {grund_message_s_reset}).") - else: - self.logger.error("FEHLER: Konnte Spaltenbuchstaben für S oder AX nicht ermitteln, Zurücksetzen übersprungen.") - - - elif run_wiki_step: # Wiki Schritt war ausgewählt, aber nicht nötig basierend auf Status/Re-Eval - self.logger.debug(f"Zeile {row_num_in_sheet}: Überspringe WIKI Suche/Extraktion (AN vorhanden, S nicht 'X (URL Copied)' und kein Re-Eval).") - # Wenn der Schritt übersprungen wird, verwenden wir die vorhandenen Wiki-Daten aus dem Sheet, - # die bereits zu Beginn der Methode geladen wurden (current_wiki_data). - # final_wiki_data behält ihre initialen Werte. - - - # --- Der Code für den nächsten Verarbeitungsschritt (ChatGPT Evaluationen) folgt im nächsten Teil --- - # Definiton der Methode _process_single_row wird in der nächsten Nachricht fortgesetzt. - -# --- 3. ChatGPT Evaluationen (Branch, FSM, Emp, Umsatz Schätzungen etc.) --- - # Dieser Schritt wird ausgeführt, wenn 'chatgpt' (oder 'chat' je nach gewählten Schritt-Keys) - # in steps_to_run enthalten ist UND (_needs_chat_evaluations True ist ODER force_reeval True ist). - # _needs_chat_evaluations prüft AO oder ob Wiki-Daten in diesem Lauf aktualisiert wurden. - - # Annahme: Der Key für diese Gruppe in steps_to_run ist 'chatgpt' oder 'chat' - # Wir verwenden 'chat' wie im Plan vorgeschlagen - run_chat_step = 'chat' in steps_to_run - # _needs_chat_evaluations nutzt den lokalen Flag wiki_data_updated_in_this_run - chat_processing_needed_based_on_status = self._needs_chat_evaluations(row_data, force_reeval, wiki_data_updated_in_this_run) - - if run_chat_step and chat_processing_needed_based_on_status: - any_processing_done = True # Markiere, dass in dieser Zeile etwas getan wird - - # Bestimme den Grund für die Ausführung dieses Schritts für das Logging - grund_message_parts = [] - if force_reeval: grund_message_parts.append('Re-Eval') - if not self._get_cell_value_safe(row_data, "Timestamp letzte Prüfung").strip(): grund_message_parts.append('AO leer') - if wiki_data_updated_in_this_run: grund_message_parts.append('Wiki Daten gerade aktualisiert') - grund_message = ", ".join(grund_message_parts) - - self.logger.info(f"Zeile {row_num_in_sheet}: Führe CHATGPT Evaluationen aus (Grund: {grund_message})...") - - # Hole die notwendigen Daten (nutze die finalen Werte aus den vorherigen Schritten) - # crm_branche, crm_beschreibung wurden initial geladen - # final_wiki_data wurde im Wiki-Schritt aktualisiert oder behält alte Werte - # website_summary wurde im Website-Schritt aktualisiert oder behält alte Werte - - # --- 3a. Branchen-Einstufung (W, X, Y) --- - self.logger.debug(" -> Starte Branchen-Einstufung via ChatGPT...") - try: - # Annahme: evaluate_branche_chatgpt global definiert oder als Methode eines OpenAICreator (hier global/utils) - # evaluate_branche_chatgpt braucht Zugriff auf ALLOWED_TARGET_BRANCHES und TARGET_SCHEMA_STRING (global) - # Nutzt den globalen retry_on_failure Decorator - branch_result = evaluate_branche_chatgpt( # Annahme: evaluate_branche_chatgpt in utils.py - crm_branche, - crm_beschreibung, - final_wiki_data.get('branche', 'k.A.'), # Nutze ggf. neue Wiki-Branche - final_wiki_data.get('categories', 'k.A.'), # Nutze ggf. neue Wiki-Kategorien - website_summary # Nutze ggf. neue Website-Zusammenfassung - ) - # Sammle Updates für die Branchen-Spalten - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('branch', 'FEHLER')]]}) # Fallback auf FEHLER - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenz Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('consistency', 'error')]]}) # Fallback auf error - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('justification', 'Keine Begründung')]]}) # Fallback - - except Exception as e_branch_eval: - self.logger.error(f"FEHLER bei Branchen-Einstufung via ChatGPT für Zeile {row_num_in_sheet}: {e_branch_eval}") - # Füge Updates mit Fehlermeldung hinzu, um den Fehler im Sheet zu dokumentieren - error_msg = f"Fehler: {e_branch_eval}"[:200] # Kürze Fehlermeldung - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER']]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenz Branche"] + 1)}{row_num_in_sheet}', 'values': [['error']]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Branche"] + 1)}{row_num_in_sheet}', 'values': [[error_msg]]}) - pass # Fahren Sie fort mit den nächsten Schritten - - - # --- 3b. FSM Relevanz Bewertung (Z, AA) --- - # TODO: Implementieren Sie die Logik und den Aufruf der Funktion - self.logger.debug(" -> Starte FSM Relevanz Bewertung (Platzhalter)...") - # Beispielaufruf (angenommen, Funktion evaluate_fsm_suitability existiert und liefert dict): - # fsm_result = evaluate_fsm_suitability( - # company_name, - # {'crm_desc': crm_beschreibung, 'wiki_paragraph': final_wiki_data.get('first_paragraph', 'k.A.'), 'web_summary': website_summary} - # ) - # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Prüfung FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[fsm_result.get('suitability', 'k.A.')]]}) - # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung für FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[fsm_result.get('justification', 'k.A.')]]}) - # pass # Fahren Sie fort, auch wenn FSM Eval nicht implementiert ist - - - # --- 3c. Mitarbeiterzahl Schätzung (AB, AC, AD) --- - # TODO: Implementieren Sie die Logik und den Aufruf der Funktion - self.logger.debug(" -> Starte Mitarbeiterzahl Schätzung (Platzhalter)...") - # Beispielaufruf (angenommen, evaluate_employee_chatgpt existiert): - # emp_estimate_result = evaluate_employee_chatgpt( - # company_name, - # {'crm_emp': self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter"), 'wiki_emp': final_wiki_data.get('mitarbeiter', 'k.A.'), 'wiki_paragraph': final_wiki_data.get('first_paragraph', 'k.A.'), 'web_summary': website_summary} - # ) - # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schätzung Anzahl Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[emp_estimate_result.get('estimate', 'k.A.')]]}) - # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenzprüfung Mitarbeiterzahl"] + 1)}{row_num_in_sheet}', 'values': [[emp_estimate_result.get('consistency', 'k.A.')]]}) - # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Mitarbeiterzahl"] + 1)}{row_num_in_sheet}', 'values': [[emp_estimate_result.get('justification', 'k.A.')]]}) - # pass # Fahren Sie fort - - - # --- 3d. Umsatz Schätzung (AG, AH) --- - # TODO: Implementieren Sie die Logik und den Aufruf der Funktion - self.logger.debug(" -> Starte Umsatz Schätzung (Platzhalter)...") - # Beispielaufruf (angenommen, evaluate_umsatz_chatgpt existiert): - # umsatz_estimate_result = evaluate_umsatz_chatgpt( - # company_name, - # {'crm_umsatz': self._get_cell_value_safe(row_data, "CRM Umsatz"), 'wiki_umsatz': final_wiki_data.get('umsatz', 'k.A.'), 'wiki_paragraph': final_wiki_data.get('first_paragraph', 'k.A.'), 'web_summary': website_summary} - # ) - # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schätzung Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[umsatz_estimate_result.get('estimate', 'k.A.')]]}) - # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[umsatz_estimate_result.get('justification', 'k.A.')]]}) - # pass # Fahren Sie fort - - - # --- 3e. Konsolidierung Umsatz/Mitarbeiter (AV, AW) --- - # Diese Logik wurde bisher in prepare_data_for_modeling verwendet, - # kann aber auch hier nach jeder Zeilenverarbeitung durchgeführt und - # ins Sheet geschrieben werden, um die konsolidierten Werte aktuell zu halten. - self.logger.debug(" -> Konsolidiere Umsatz (AV) und Mitarbeiter (AW) (Wiki > CRM Logik)...") - try: - # Nutze get_valid_numeric (globale Hilfsfunktion) - crm_umsatz_val = get_valid_numeric(self._get_cell_value_safe(row_data, "CRM Umsatz")) - wiki_umsatz_val = get_valid_numeric(final_wiki_data.get('umsatz', 'k.A.')) # Nutze finalen Wiki-Wert - final_umsatz = str(int(wiki_umsatz_val)) if pd.notna(wiki_umsatz_val) else (str(int(crm_umsatz_val)) if pd.notna(crm_umsatz_val) else 'k.A.') - - crm_ma_val = get_valid_numeric(self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter")) - wiki_ma_val = get_valid_numeric(final_wiki_data.get('mitarbeiter', 'k.A.')) # Nutze finalen Wiki-Wert - final_ma = str(int(wiki_ma_val)) if pd.notna(wiki_ma_val) else (str(int(crm_ma_val)) if pd.notna(crm_ma_val) else 'k.A.') - - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Umsatz (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [[final_umsatz]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Mitarbeiter (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [[final_ma]]}) - self.logger.debug(f" -> Konsolidiert: Umsatz={final_umsatz}, MA={final_ma}") - - except Exception as e_consolidate: - self.logger.error(f"FEHLER bei Konsolidierung Umsatz/Mitarbeiter für Zeile {row_num_in_sheet}: {e_consolidate}") - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Umsatz (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER']]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Mitarbeiter (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER']]}) - pass # Fahren Sie fort - - - # --- 3f. Servicetechniker Schätzung (ML Modell) (AU) --- - # Dieser Schritt erfordert das trainierte ML-Modell und Imputer. - # Die Schätzung sollte nur ausgeführt werden, wenn das Modell geladen werden kann - # UND konsolidierte MA/Umsatz Werte verfügbar sind (AV, AW). - # Dies ist komplexer und könnte in einer separaten Methode besser aufgehoben sein. - # Die Methode könnte prüfen, ob das Modell geladen ist und die Schätzung durchführen. - # result_bucket = self._predict_technician_bucket(final_umsatz, final_ma, final_branche_aus_chatgpt_eval) - # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Geschätzter Techniker Bucket"] + 1)}{row_num_in_sheet}', 'values': [[result_bucket]]}) - self.logger.debug(" -> Starte Servicetechniker Schätzung (ML) (Platzhalter)...") - pass # Fahren Sie fort - - - # Setze den Timestamp letzte Prüfung (AO), da die ChatGPT-Evaluationen liefen - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Timestamp letzte Prüfung"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) - - - elif run_chat_step: # ChatGPT Schritt war ausgewählt, aber nicht nötig basierend auf Status/Re-Eval/Wiki-Update - self.logger.debug(f"Zeile {row_num_in_sheet}: Überspringe CHATGPT Evaluationen (AO vorhanden, Wiki nicht aktualisiert und kein Re-Eval).") - # Wenn der Schritt übersprungen wird, behalten website_summary und final_wiki_data ihre initialen Werte. - - - # --- Der Code für die abschließenden Updates (Version, Tokens, Batch Write) folgt im nächsten Teil --- - # Definiton der Methode _process_single_row wird in der nächsten Nachricht fortgesetzt. - -# --- 4. Servicetechniker Schätzung (ML Modell) (AU) --- - # Dieser Schritt wird ausgeführt, wenn 'ml_predict' in steps_to_run enthalten ist UND - # es nötig ist (z.B. AU ist leer ODER AO wurde gerade gesetzt UND AU ist leer ODER Re-Eval). - # Die Logik, ob dieser Schritt nötig ist, ist spezifisch und könnte hier oder in einer Helper-Methode geprüft werden. - # Für die Einfachheit der _process_single_row Logik hier prüfen wir nur das Flag. - # Die Notwendigkeit (AU leer, AO frisch etc.) muss ggf. vom Aufrufer (z.B. Batch-Modus) gehandhabt werden. - # Alternativ: Fügen Sie eine Helper-Methode wie _needs_ml_prediction(row_data, force_reeval, chat_just_ran) hinzu. - - # Annahme: Der Key für diesen Schritt ist 'ml_predict' - run_ml_step = 'ml_predict' in steps_to_run - - # Für den ML-Schritt ist es am sinnvollsten, ihn auszuführen, wenn - # A) force_reeval gesetzt ist, ODER - # B) Der "Timestamp letzte Prüfung" (AO) gerade gesetzt wurde (chat_processing_needed_based_on_status ist True) UND der AU Bucket noch leer ist. - # C) Der AU Bucket explizit leer ist und der Modus "ml_predict" ausgewählt wurde. - - # Wir prüfen hier nur das Flag run_ml_step. Die komplexere Logik zur Notwendigkeit kann - # entweder vor dem Aufruf von _process_single_row (in Batch/Sequentiell) geschehen, - # ODER eine eigene _needs_ml_prediction Methode wird hier verwendet. - # Lassen wir es hier einfach: Wenn 'ml_predict' angefordert, versuche es. - - if run_ml_step: # Prüfe nur, ob der Schritt im aktuellen Lauf angefordert wurde - # Prüfe zusätzlich, ob benötigte Daten (AV, AW) vorhanden sind - final_umsatz = self._get_cell_value_safe(row_data, "Finaler Umsatz (Wiki>CRM)").strip() - final_ma = self._get_cell_value_safe(row_data, "Finaler Mitarbeiter (Wiki>CRM)").strip() - - if final_umsatz != 'k.A.' and final_ma != 'k.A.': - any_processing_done = True # Markiere, dass in dieser Zeile etwas getan wird - self.logger.info(f"Zeile {row_num_in_sheet}: Führe ML-Schätzung aus...") - try: - # Annahme: _predict_technician_bucket Methode existiert in DataProcessor - # Diese Methode muss das geladene Modell/Imputer nutzen, Daten vorbereiten und vorhersagen - predicted_bucket = self._predict_technician_bucket(row_data) # Methode braucht raw_data für alle Spalten - if predicted_bucket: - # Sammle Update für den AU Bucket - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Geschätzter Techniker Bucket"] + 1)}{row_num_in_sheet}', 'values': [[predicted_bucket]]}) - self.logger.info(f" -> ML-Schätzung erfolgreich: Bucket '{predicted_bucket}'.") - else: - self.logger.warning(f" -> ML-Schätzung lieferte kein Ergebnis.") - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Geschätzter Techniker Bucket"] + 1)}{row_num_in_sheet}', 'values': [['k.A. (Schätzung fehlgeschlagen)']]}) - except Exception as e_ml: - self.logger.error(f"FEHLER bei ML-Schätzung für Zeile {row_num_in_sheet}: {e_ml}") - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Geschätzter Techniker Bucket"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER Schätzung']]}) - pass # Fahren Sie fort - else: - self.logger.debug(f"Zeile {row_num_in_sheet}: Überspringe ML-Schätzung, da konsolidierter Umsatz/Mitarbeiter fehlt (AV/AW='k.A.').") - - - # --- 5. Abschließende Updates (Version, Tokens) --- - - # Version (AP) wird gesetzt, wenn IRGENDEINE Verarbeitung in dieser Zeile stattgefunden hat - if any_processing_done: - # Annahme: Config.VERSION ist verfügbar - version_col_idx = COLUMN_MAP.get("Version") - 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': [[Config.VERSION]]}) - else: - self.logger.error("FEHLER: Spaltenschlüssel 'Version' nicht in COLUMN_MAP gefunden.") - - # Tokens (AQ) - Hier ist die Zählung komplex, da mehrere OpenAI-Calls passiert sein könnten. - # Eine einfache Lösung ist, die Token-Zahl der letzten relevanten Antwort zu speichern - # oder die Token-Zahl der Prompts/Antworten während des Laufs zu aggregieren. - # Eine Aggregation in den einzelnen Schritten (Web Summary, Branch Eval etc.) wäre genauer. - # Als Platzhalter: Zählen Sie die Tokens der Website Summary (AS) und der Branch Begründung (Y) - # oder überspringen Sie es erstmal hier und implementieren es in den einzelnen Schritten. - # Überspringen wir es hier und implementieren Token-Zählung in den spezifischen OpenAI-Call-Methoden. - # Wenn der Token-Count in einer der OpenAI-Call-Methoden implementiert wird, - # muss er dort gesammelt und dann HIER in _process_single_row ins Update eingefügt werden. - # Beispiel: Sie könnten self.current_row_token_count am Anfang auf 0 setzen - # und in jeder Methode, die call_openai_chat nutzt, += zum Token-Count addieren. - # Dann hier: - # tokens_col_idx = COLUMN_MAP.get("Tokens") - # if tokens_col_idx is not None: - # updates.append({'range': f'{self.sheet_handler._get_col_letter(tokens_col_idx + 1)}{row_num_in_sheet}', 'values': [[str(self.current_row_token_count)]]}) - # else: self.logger.error("FEHLER: Spaltenschlüssel 'Tokens' nicht in COLUMN_MAP gefunden.") - pass # Token-Zählung Implementierung verschoben - - # --- 5b. ReEval Flag (A) löschen (nur wenn im Re-Eval Modus und gewünscht) --- - # Dieses Update wird am Ende hinzugefügt, wenn die Verarbeitung erfolgreich (oder zumindest versucht) wurde - # und der Aufrufer (process_reevaluation_rows) dies angefordert hat. - if force_reeval and clear_x_flag: - # Ermitteln Sie den Index der ReEval Flag Spalte - reeval_col_idx = COLUMN_MAP.get("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: - # Fügen Sie das Update zum Löschen des 'x'-Flags zur Liste hinzu - # Es wird nur gelöscht, wenn die Zeile ansonsten erfolgreich bis hierhin kam. - # Wenn eine schwere Exception in _process_single_row auftrat, wird diese Zeile nicht erreicht. - updates.append({'range': f'{flag_col_letter}{row_num_in_sheet}', 'values': [['']]}) - self.logger.debug(f" -> Update zum Löschen des ReEval-Flags (A{row_num_in_sheet}) vorgemerkt.") - else: - self.logger.error(f"FEHLER: Konnte Spaltenbuchstaben für 'ReEval Flag' ({reeval_col_idx+1}) nicht ermitteln. Flag kann nicht gelöscht werden.") - else: - self.logger.error("FEHLER: 'ReEval Flag' Spaltenindex nicht in COLUMN_MAP gefunden. Flag kann nicht gelöscht werden.") - - - - - # --- 6. Batch Update für diese Zeile --- - # Führen Sie das Batch-Update für alle gesammelten Änderungen dieser Zeile durch. - if updates: - # Info-Log über die Anzahl der Updates für diese spezifische Zeile - self.logger.info(f"Zeile {row_num_in_sheet}: Sende Batch-Update mit {len(updates)} Operationen für diese Zeile...") - # self.sheet_handler.batch_update_cells nutzt logging intern und den retry_on_failure Decorator. - success = self.sheet_handler.batch_update_cells(updates) - if not success: - self.logger.error(f"Zeile {row_num_in_sheet}: ENDGÜLTIGER FEHLER beim Batch-Update nach Retries.") - # Hier könnten Sie einen Fehlerindikator in eine spezielle Spalte schreiben - else: - # Info-Log, wenn nichts zu tun war in dieser Zeile - if not any_processing_done: - self.logger.debug(f"Zeile {row_num_in_sheet}: Keine Updates zum Schreiben (alle relevanten Schritte übersprungen oder nicht angefordert).") - # else: # Dieser Fall sollte nicht eintreten, wenn updates nicht leer ist - # self.logger.warning(f"Zeile {row_num_in_sheet}: Updates Liste war leer, aber any_processing_done=True. Prüfen Sie die Logik.") - - - # Kleine Pause nach der Verarbeitung jeder Zeile, um API-Limits zu respektieren - # und die Belastung für das Google Sheet zu reduzieren. - # Der Wert sollte in Config angepasst werden. Eine kurze Pause ist auch bei Batch-Modi sinnvoll. - pause_duration = max(0.05, getattr(Config, 'RETRY_DELAY', 5) / 20.0) # Mindestens 50ms - # self.logger.debug(f"Wartezeit nach Zeile {row_num_in_sheet}: {pause_duration:.2f}s") # Zu viel Lärm im Debug - time.sleep(pause_duration) - - self.logger.info(f"--- Verarbeitung für Zeile {row_num_in_sheet} abgeschlossen ---") - - # --- Ende der _process_single_row Methode --- - - - # --- Die nächsten Methoden der DataProcessor Klasse folgen in den nächsten Teilen --- - # z.B. process_reevaluation_rows method... (kommt in Teil 10) - # process_rows_sequentially method... (kommt später) - # Batch-Methoden... (kommen später) - # Utility-Methoden... (kommen später) - -# --- Methode für den Re-Eval Modus (Spalte A = 'x') --- - # Diese Methode gehört in die Klasse DataProcessor. - def process_reevaluation_rows(self, row_limit=None, clear_flag=True, - process_wiki_steps=True, - process_chatgpt_steps=True, - process_website_steps=True): - """ - Verarbeitet nur Zeilen, die in Spalte A mit 'x' markiert sind. - Ruft _process_single_row für jede dieser Zeilen auf mit force_reeval=True. - Verarbeitet maximal row_limit Zeilen. - Löscht optional das 'x'-Flag nach erfolgreicher Verarbeitung. - Erlaubt die Auswahl spezifischer Verarbeitungsschritte. - - Args: - row_limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None. - clear_flag (bool, optional): Flag 'x' nach erfolgreicher Verarbeitung löschen. Defaults to True. - process_wiki_steps (bool, optional): Soll der Wiki-Schritt in _process_single_row ausgeführt werden?. Defaults to True. - process_chatgpt_steps (bool, optional): Sollen ChatGPT-Schritte in _process_single_row ausgeführt werden?. Defaults to True. - process_website_steps (bool, optional): Soll der Website-Schritt in _process_single_row ausgeführt werden?. Defaults to True. - # Fügen Sie hier ggf. weitere Parameter hinzu, wenn Sie granularere Schritte in _process_single_row haben. - """ - self.logger.info(f"Starte Re-Evaluierungsmodus (Spalte A = 'x'). Max. Zeilen: {row_limit if row_limit is not None else 'Unbegrenzt'}") - # Logge, welche Schritte für Re-Eval ausgewählt wurden - 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)") - self.logger.info(f"Ausgewählte Schritte für Re-Eval: {', '.join(selected_steps_log) if selected_steps_log else 'Keine ausgewählt!'}") - - # Erstelle das Set der Schritte, die an _process_single_row übergeben werden - 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') # Annahme: 'chat' triggert alle ChatGPT Schritte in _process_single_row - if process_website_steps: steps_to_run_set.add('web') - - if not steps_to_run_set: - self.logger.warning("Keine Verarbeitungsschritte für Re-Eval ausgewählt. Modus wird übersprungen.") - return - - # Daten neu laden vor der Verarbeitung - if not self.sheet_handler.load_data(): - self.logger.error("Fehler beim Laden der Daten für Re-Evaluation.") - return - - all_data = self.sheet_handler.get_all_data_with_headers() - header_rows = 5 # Annahme - if not all_data or len(all_data) <= header_rows: - self.logger.warning("Keine Daten für Re-Evaluation gefunden.") - return - - # Ermitteln Sie den Index der ReEval Flag Spalte - reeval_col_idx = COLUMN_MAP.get("ReEval Flag") - if reeval_col_idx is None: - self.logger.critical("FEHLER: 'ReEval Flag' Spaltenindex nicht in COLUMN_MAP gefunden.") - return - - # Sammeln Sie die Zeilen, die mit 'x' markiert sind - rows_to_process = [] - for idx_in_list in range(header_rows, len(all_data)): - row_data = all_data[idx_in_list] - row_num_in_sheet = idx_in_list + 1 # 1-basierte Zeilennummer im Sheet - # Prüfen Sie sicher auf den Wert 'x' in Spalte A - cell_a_value = self._get_cell_value_safe(row_data, "ReEval Flag").strip().lower() - if cell_a_value == "x": - rows_to_process.append({'row_num': row_num_in_sheet, '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: - self.logger.info("Keine Zeilen zur Re-Evaluation markiert.") - return - - # Verarbeitung der markierten Zeilen - processed_count = 0 - #updates_clear_flag = [] # Updates zum Löschen des 'x'-Flags - rows_actually_processed = [] # Liste der Zeilen, die tatsächlich verarbeitet wurden - - for task in rows_to_process: - # Überprüfen Sie das Limit VOR der Verarbeitung - if row_limit is not None and processed_count >= row_limit: - self.logger.info(f"Zeilenlimit ({row_limit}) für Re-Evaluation erreicht. Breche weitere Verarbeitung ab.") - break - - row_num = task['row_num'] - row_data = task['data'] # Die Rohdaten für diese Zeile - - self.logger.info(f"Bearbeite Re-Eval Zeile {row_num}...") - try: - # RUFE _process_single_row AUF mit force_reeval=True und dem ausgewählten steps_to_run_set - self._process_single_row( - row_num_in_sheet = row_num, - row_data = row_data, - steps_to_run = steps_to_run_set, # <-- Übergibt die ausgewählten Schritte - force_reeval = True, # <-- Erzwingt Re-Evaluation unabhängig von Timestamps - clear_x_flag = clear_flag # <-- ÜBERGIBT, OB DAS FLAG GELÖSCHT WERDEN SOLL - ) - processed_count += 1 # Nur zählen, wenn _process_single_row keine Exception geworfen hat - rows_actually_processed.append(row_num) # Nur Zeilen hinzufügen, die erfolgreich an _process_single_row übergeben wurden - - # Vorbereiten des Updates zum Löschen des 'x'-Flags (falls gewünscht) - #if clear_flag: - # flag_col_letter = self.sheet_handler._get_col_letter(reeval_col_idx + 1) - # if flag_col_letter: - # updates_clear_flag.append({'range': f'{flag_col_letter}{row_num}', 'values': [['']]}) - # else: - # self.logger.error(f"Fehler: Konnte Spaltenbuchstaben für 'ReEval Flag' ({reeval_col_idx+1}) nicht ermitteln.") - - except Exception as e_proc: - # Wenn _process_single_row einen Fehler wirft, fangen wir ihn hier, loggen ihn - # und fahren mit der nächsten Zeile fort. Das 'x'-Flag wird in diesem Fall NICHT gelöscht. - self.logger.exception(f"FEHLER bei Re-Evaluation von Zeile {row_num}: {e_proc}") - # Hier könnten Sie einen Fehlerindikator in eine spezielle Spalte schreiben - - # Lösche Flags am Ende in einem Batch-Update - #if clear_flag and updates_clear_flag: - # self.logger.info(f"Lösche ReEval-Flags für {len(updates_clear_flag)} erfolgreich verarbeitete Zeilen ({rows_actually_processed})...") - # # Nutzen Sie die batch_update_cells Methode des Sheet Handlers - # success = self.sheet_handler.batch_update_cells(updates_clear_flag) - # if success: - # self.logger.info("ReEval-Flags erfolgreich gelöscht.") - # else: - # self.logger.error("FEHLER beim Löschen der ReEval-Flags nach Re-Evaluation.") - - self.logger.info(f"Re-Evaluierung abgeschlossen. {processed_count} Zeilen verarbeitet (Gefunden: {found_count}, Limit: {row_limit}).") - - - # --- Die nächste Methode der DataProcessor Klasse folgt in der nächsten Nachricht --- - # z.B. process_rows_sequentially method... (kommt in Teil 11) - -# --- Methode für sequenzielle Verarbeitung (full_run) --- - # Diese Methode gehört in die Klasse DataProcessor. - def process_rows_sequentially(self, start_sheet_row, num_to_process, - process_wiki_steps=True, - process_chatgpt_steps=True, - process_website_steps=True, - # Fügen Sie hier ggf. weitere boolsche Flags für andere Schrittgruppen hinzu - force_reeval_in_single_row=False): # Optionale Steuerung für _process_single_row - """ - Verarbeitet eine feste Anzahl von Zeilen beginnend bei einer bestimmten - Sheet-Zeilennummer sequenziell, eine nach der anderen, unter Verwendung - von _process_single_row. - - Args: - start_sheet_row (int): Die 1-basierte Zeilennummer im Sheet, ab der gestartet werden soll. - num_to_process (int): Die maximale Anzahl der zu verarbeitenden Zeilen. - process_wiki_steps (bool, optional): Soll der Wiki-Schritt in _process_single_row ausgeführt werden?. Defaults to True. - process_chatgpt_steps (bool, optional): Sollen ChatGPT-Schritte in _process_single_row ausgeführt werden?. Defaults to True. - process_website_steps (bool, optional): Soll der Website-Schritt in _process_single_row ausgeführt werden?. Defaults to True. - # Fügen Sie hier ggf. weitere boolsche Flags für andere Schrittgruppen hinzu. - force_reeval_in_single_row (bool, optional): Erzwingt force_reeval=True in _process_single_row - für alle verarbeiteten Zeilen in diesem Lauf. Defaults to False. - """ - header_rows = 5 # Annahme - - # Prüfen Sie, ob num_to_process gültig ist - if num_to_process is None or num_to_process <= 0: - self.logger.info("Sequenzielle Verarbeitung übersprungen: num_to_process ist 0 oder None.") - return - - self.logger.info(f"Starte sequenzielle Verarbeitung von {num_to_process} Zeilen ab Sheet-Zeile {start_sheet_row}...") - self.logger.info(f" Ausgewählte Schritte: Wiki={process_wiki_steps}, ChatGPT={process_chatgpt_steps}, Website={process_website_steps}") - if force_reeval_in_single_row: self.logger.warning(" !!! force_reeval=True wird für alle Zeilen in _process_single_row gesetzt !!!") - - - # Lade Daten einmalig vor der Verarbeitung - if not self.sheet_handler.load_data(): - self.logger.error("Fehler beim Laden der Daten für sequenzielle Verarbeitung.") - return - - all_data = self.sheet_handler.get_all_data_with_headers() - total_sheet_rows = len(all_data) - - # Berechnen Sie den tatsächlichen Start-Index in der all_data Liste (0-basiert) - 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} (Index {start_index_in_all_data}) liegt außerhalb der verfügbaren Daten ({total_sheet_rows} Zeilen insgesamt). Keine Verarbeitung.") - return - if start_index_in_all_data < header_rows: - self.logger.warning(f"Start-Sheet-Zeile {start_sheet_row} liegt innerhalb der Header-Zeilen ({header_rows} Header). Verarbeitung startet ab Sheet-Zeile {header_rows + 1}.") - start_index_in_all_data = header_rows # Starten Sie nach den Headern - - # Berechne den tatsächlichen End-Index in der all_data Liste (exklusiv) - # end_index_in_all_data = start_index_in_all_data + num_to_process - # Der End-Index sollte auch die Gesamtanzahl der Zeilen nicht überschreiten - end_index_in_all_data = min(start_index_in_all_data + num_to_process, total_sheet_rows) - - - self.logger.info(f"Sequenzielle Verarbeitung: Sheet-Zeilen (1-basiert) von {start_index_in_all_data + 1} bis {end_index_in_all_data}. (Verarbeite max {end_index_in_all_data - start_index_in_all_data} Zeilen)") - - if start_index_in_all_data >= end_index_in_all_data: - self.logger.info("Berechneter Start liegt bei oder nach dem berechneten Ende. Keine Zeilen zu verarbeiten.") - return - - - # Erstelle das Set der Schritte, die an _process_single_row übergeben werden - 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') - # Fügen Sie hier weitere Schritte hinzu, falls granularere Flags verwendet werden - - if not steps_to_run_set: - self.logger.warning("Keine Verarbeitungsschritte für sequenziellen Lauf ausgewählt. Modus wird übersprungen.") - return - - - processed_count = 0 - # Iteriere über die Zeilen im angegebenen Bereich (0-basiert) - for i in range(start_index_in_all_data, end_index_in_all_data): - row_num_in_sheet = i + 1 # 1-basierte Zeilennummer im Sheet - row_data = all_data[i] # Tatsächliche Zeilendaten aus der Gesamtliste - - # Überspringen Sie Header-Zeilen explizit, falls der Startindex fälschlicherweise <= header_rows war - if row_num_in_sheet <= header_rows: - self.logger.debug(f"Überspringe Header-Zeile {row_num_in_sheet}.") - continue - - # Stellen Sie sicher, dass die Zeile nicht leer ist oder nur aus leeren Strings besteht - if not any(cell and cell.strip() for cell in row_data): - self.logger.debug(f"Überspringe scheinbar leere Zeile {row_num_in_sheet}.") - continue - - - try: - # Rufe die Methode zur Verarbeitung einer einzelnen Zeile auf - # _process_single_row wird intern die Timestamps prüfen (außer force_reeval) - self._process_single_row( - row_num_in_sheet = row_num_in_sheet, - row_data = row_data, - steps_to_run = steps_to_run_set, # <-- Übergibt die ausgewählten Schritte - force_reeval = force_reeval_in_single_row # <-- Steuert force_reeval in _process_single_row - ) - - processed_count += 1 # Zählen, wenn _process_single_row erfolgreich aufgerufen wurde (unabhängig von internen Überspringungen) - - except Exception as e_proc: - # Logge den spezifischen Fehler für diese Zeile, fahre aber mit der nächsten fort - self.logger.exception(f"FEHLER bei sequenzieller Verarbeitung von Zeile {row_num_in_sheet}: {e_proc}") - # Hier könnten Sie einen Fehlerindikator in eine spezielle Spalte schreiben - - self.logger.info(f"Sequenzielle Verarbeitung abgeschlossen. {processed_count} Zeilen im Bereich [{start_sheet_row}, {end_index_in_all_data}] bearbeitet.") - - - # --- Die nächsten Batch-Methoden der DataProcessor Klasse folgen in den nächsten Teilen --- - # process_verification_batch method... (kommt in Teil 12) - # process_website_scraping_batch method... (kommt in Teil 12) - # process_summarization_batch method... (kommt in Teil 12) - # process_branch_batch method... (kommt in Teil 13) - # process_find_wiki_serp method... (kommt in Teil 13) - # process_contact_search method... (kommt in Teil 13) - # process_wiki_updates_from_chatgpt method... (kommt in Teil 14) - # process_wiki_reextract_missing_an method... (kommt in Teil 14) - # ========================================================================== - # === Batch Processing Methods ============================================= - # ========================================================================== - - # --- Interne Hilfsfunktion für Wiki-Verifizierungs-Batch (OpenAI Call) --- - # Übernommen aus Ihrem Code (_process_batch in Teil 8), angepasst als Methode. - # Diese Methode ist spezifisch für den Wiki-Verifizierungs-Batchmodus. - @retry_on_failure # Anwenden des Decorators, da hier call_openai_chat aufgerufen wird - def _process_verification_openai_batch(self, batch_data): - """ - Verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen über OpenAI. - Sammelt die Ergebnisse und gibt sie zurück. Aktualisiert NICHT das Sheet direkt. - - Args: - batch_data (list): Liste von Dictionaries, jedes enthält: - {'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. - """ - if not batch_data: - return {} - - self.logger.debug(f"Sende OpenAI-Batch für Wiki-Verifizierung ({len(batch_data)} Zeilen, Start: {batch_data[0]['row_num']})...") - - # --- Prompt Erstellung --- - aggregated_prompt = ( - "Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln für Unternehmen. " - "Für jeden der folgenden Einträge prüfe, ob der vorhandene Wikipedia-Artikel (URL, Absatz, Kategorien) plausibel zum Firmennamen und zur Beschreibung passt. " - "Gib das Ergebnis für jeden Eintrag ausschließlich im folgenden Format auf einer neuen Zeile aus:\n" - "Eintrag <Zeilennummer>: <Antwort>\n\n" - "Mögliche Antworten:\n" - "- 'OK' (wenn der Artikel gut passt)\n" - "- 'X | Alternativer Artikel: <URL> | Begründung: <Kurze Begründung>' (wenn der Artikel nicht passt, aber ein besserer gefunden wurde)\n" - "- 'X | Kein passender Artikel gefunden | Begründung: <Kurze Begründung>' (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" - "Einträge zur Prüfung:\n" - "--------------------\n" - ) - - # Fügen Sie die Daten für jeden Eintrag im Batch hinzu - for item in batch_data: - row_num = item['row_num'] - # Kürze die Beschreibungen und Kategorien, um das Prompt-Limit zu reduzieren - crm_desc_short = item.get('crm_desc', 'k.A.')[:200] + '...' if len(item.get('crm_desc', '')) > 200 else item.get('crm_desc', 'k.A.') - wiki_paragraph_short = item.get('wiki_paragraph', 'k.A.')[:200] + '...' if len(item.get('wiki_paragraph', '')) > 200 else item.get('wiki_paragraph', 'k.A.') - wiki_categories_short = item.get('wiki_categories', 'k.A.')[:200] + '...' if len(item.get('wiki_categories', '')) > 200 else item.get('wiki_categories', 'k.A.') - - - entry_text = ( - f"Eintrag {row_num}:\n" - f" Firmenname: {item.get('company_name', 'k.A.')}\n" - f" CRM-Beschreibung: {crm_desc_short}\n" - f" Wikipedia-URL: {item.get('wiki_url', 'k.A.')}\n" - f" Wiki-Absatz: {wiki_paragraph_short}\n" - f" Wiki-Kategorien: {wiki_categories_short}\n" - f"----\n" - ) - aggregated_prompt += entry_text - - aggregated_prompt += "--------------------\nBitte nur die 'Eintrag X: Antwort'-Zeilen ausgeben." - - # Token Count für den Prompt (nutzt globale Funktion) - prompt_tokens = token_count(aggregated_prompt) - self.logger.debug(f"Geschätzte Token-Zahl für Wiki-Verifizierungs-Batch: {prompt_tokens}") - - # --- ChatGPT Aufruf --- - try: - # Annahme: call_openai_chat global definiert oder als Methode (hier global/utils) - # Nutzt den retry_on_failure Decorator - chat_response = call_openai_chat(aggregated_prompt, temperature=0.0) # Niedrige Temperatur für konsistente Antworten - - if not chat_response: - self.logger.error(f"Keine Antwort von OpenAI für Verifizierungs-Batch {batch_data[0]['row_num']}-{batch_data[-1]['row_num']}.") - # Geben Sie ein Dictionary zurück, das signalisiert, dass für alle Zeilen ein Fehler aufgetreten ist - return {item['row_num']: "FEHLER: Keine Antwort von OpenAI" for item in batch_data} - - # --- Antwort parsen --- - # Das Parsen erfolgt hier und die Ergebnisse werden zurückgegeben. - # Das Sheet-Update erfolgt in der aufrufenden Methode. - answers = {} - lines = chat_response.strip().split('\n') - parsed_count = 0 - for line in lines: - # Regulärer Ausdruck, um "Eintrag <Nummer>:" zu finden und den Rest der Zeile zu erfassen - match = re.match(r"Eintrag (\d+): (.*)", line.strip()) - if match: - row_num = int(match.group(1)) - answer_text = match.group(2).strip() - # Prüfen, ob die Zeilennummer im ursprünglichen Batch enthalten war - if any(item['row_num'] == row_num for item in batch_data): - answers[row_num] = answer_text - parsed_count += 1 - # else: - # self.logger.debug(f"Warnung: Antwort für unerwartete Zeilennummer {row_num} im Batch erhalten: {answer_text}") # Zu viel Lärm - - self.logger.debug(f"OpenAI-Batch-Antwort geparst: {parsed_count} von {len(batch_data)} Zeilen erfolgreich zugeordnet.") - if parsed_count < len(batch_data): - self.logger.warning(f"Nicht alle Zeilen aus dem Batch ({len(batch_data)}) konnten in der OpenAI-Antwort ({len(lines)} Zeilen) geparst werden.") - self.logger.debug(f"Unerwartete Antwortteile (erste 500 Zeichen): {chat_response[:500]}") - # Geben Sie für fehlende Zeilen einen Fehlerwert zurück - for item in batch_data: - if item['row_num'] not in answers: - answers[item['row_num']] = "FEHLER: Antwort nicht geparst" - - # Wenn die Antwort geparst werden konnte (auch wenn nicht alle Zeilen geparst wurden) - return answers - - - except Exception as e: - # Jeder Fehler, der nicht vom Decorator gefangen und wiederholt wurde, wird hier geloggt. - # Der Decorator wirft bei endgültigem Scheitern eine Exception, die hier gefangen wird. - self.logger.error(f"Endgültiger FEHLER beim OpenAI-Aufruf für Wiki-Verifizierungs-Batch: {e}") - # Geben Sie ein Dictionary zurück, das signalisiert, dass für alle Zeilen im Batch ein Fehler aufgetreten ist - return {item['row_num']: f"FEHLER API: {str(e)[:100]}" for item in batch_data} - - - # --- Methode für den Wiki-Verifizierungs-Batchmodus (AX) --- - # Übernommen aus process_verification_only in Teil 8, angepasst als Methode. - def process_verification_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None): - """ - Batch-Prozess nur für Wikipedia-Verifizierung (Spalten S-Y). - Lädt Daten neu, prüft für 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 für bearbeitete Zeilen und schreibt S-Y in Batches. - - Args: - start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung). - 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 übersprungener) Zeilen. Defaults to None. - """ - self.logger.info(f"Starte Wikipedia-Verifizierungsmodus (Batch). Bereich: {start_sheet_row}-{end_sheet_row}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") - - # --- Daten laden --- - # 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...") - # Nutzt get_start_row_index des Sheet Handlers - start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Wiki Verif. Timestamp") - if start_data_index_no_header == -1: - self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche 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 AX Zelle): {start_sheet_row}") - else: - # Daten trotzdem neu laden, um aktuell zu sein - if not self.sheet_handler.load_data(): - self.logger.error("FEHLER beim Laden der Daten für process_verification_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) - - # Berechne Endzeile, wenn nicht gesetzt oder wenn Limit aktiv ist - if end_sheet_row is None: - end_sheet_row = total_sheet_rows # Bis zur letzten Zeile - # Das Limit wird bei der Iteration unten angewendet - - 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 --- - # Stellen Sie sicher, dass alle benötigten Spalten in COLUMN_MAP vorhanden sind - required_keys = [ - "Wiki Verif. Timestamp", "Wiki URL", "Chat Wiki Konsistenzprüfung", # Prüfkriterien / Timestamp - "CRM Name", "CRM Beschreibung", "Wiki Absatz", "Wiki Kategorien", # Daten für Prompt - "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", # Ergebnisspalten (T, U) - ] - 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: Benötigte Spaltenschlüssel fehlen in COLUMN_MAP für process_verification_batch: {missing}. Breche ab.") - return - - # Spaltenbuchstaben für Updates - ts_ax_letter = self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"] + 1) - s_letter = self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"] + 1) - t_letter = self.sheet_handler._get_col_letter(col_indices["Chat Begründung Wiki Inkonsistenz"] + 1) - u_letter = self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"] + 1) - - # Spalten V-Y leeren (werden in diesem Modus nicht neu befüllt) - v_idx = COLUMN_MAP.get("Begründung bei Abweichung") # V ist Index 21 - y_idx = COLUMN_MAP.get("Chat Begründung Abweichung Branche") # Y ist Index 24 - if v_idx is None or y_idx is None: - self.logger.error("FEHLER: Indizes für Spalten V oder Y fehlen in COLUMN_MAP. Kann V-Y nicht leeren.") - # Gehen Sie weiter, da dies kein kritischer Fehler ist, aber loggen Sie es. - v_y_range_letter = None - else: - 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 - - # --- Verarbeitung --- - batch_size = getattr(Config, 'PROCESSING_BATCH_SIZE', 20) # Nutze die Batch-Größe aus Config - current_batch_data = [] # Daten für den aktuellen OpenAI Batch - rows_in_current_batch = [] # Zeilennummern für den aktuellen OpenAI Batch - all_sheet_updates = [] # Gesammelte Updates für Batch-Schreiben - update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) # Nutze die Update-Batch-Größe aus Config - - processed_count = 0 # Zählt Zeilen, die im Batch verarbeitet wurden - skipped_count = 0 # Zählt Zeilen, die übersprungen wurden - skipped_no_wiki_url = 0 # Zählt Zeilen, die wegen fehlender M-URL übersprungen wurden - - # Iteriere über die Sheet-Zeilen im definierten Bereich - 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 - if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht - - row = all_data[row_index_in_list] - - # Stellen Sie sicher, dass die Zeile nicht leer ist (mindestens Name vorhanden) - company_name = self._get_cell_value_safe(row, "CRM Name").strip() - if not company_name: - self.logger.debug(f"Zeile {i}: Übersprungen (Kein Firmenname).") - skipped_count += 1 - continue - - # --- Prüfung, ob Verarbeitung für diese Zeile nötig ist --- - # Kriterium: AX ist leer UND Wiki URL (M) ist gefüllt UND Status S ist NICHT Endzustand. - ax_value = self._get_cell_value_safe(row, "Wiki Verif. Timestamp").strip() - m_value = self._get_cell_value_safe(row, "Wiki URL").strip() - s_value_upper = self._get_cell_value_safe(row, "Chat Wiki Konsistenzprüfung").strip().upper() - - is_wiki_url_valid_looking = m_value and m_value.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche"] # Prüfe, ob M eine gültige URL sein könnte - is_s_in_endstate = s_value_upper in ["OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)"] # Endzustände von S - - # Verarbeitung nötig, wenn AX leer UND M gefüllt/gültig 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 Prüfergebnisse für diese Zeile auf Debug - 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 Verif. Check): AX leer? {not ax_value}, M gültig? {is_wiki_url_valid_looking}, S ('{s_value_upper}') Endzustand? {is_s_in_endstate}. Benötigt Verarbeitung? {processing_needed_for_row}") - - - if not processing_needed_for_row: - skipped_count += 1 - if not is_wiki_url_valid_looking: skipped_no_wiki_url += 1 # Zähle separat, wenn M leer/ungültig war - continue - - # --- Wenn Verarbeitung nötig: Füge zur Batch-Liste hinzu --- - processed_count += 1 # Zähle die Zeile, die verarbeitet wird (zum Limit zählen) - - # Prüfe das Limit für verarbeitete Zeilen - if limit is not None and processed_count > limit: - self.logger.info(f"Verarbeitungslimit ({limit}) für process_verification_batch erreicht. Breche weitere Zeilenprüfung ab.") - break # Schleife abbrechen - - # Sammle die benötigten Daten für den OpenAI Prompt - crm_desc = self._get_cell_value_safe(row, "CRM Beschreibung") - wiki_paragraph = self._get_cell_value_safe(row, "Wiki Absatz") - wiki_categories = self._get_cell_value_safe(row, "Wiki Kategorien") - - current_batch_data.append({ - 'row_num': i, - 'company_name': company_name, - 'crm_desc': crm_desc, - 'wiki_url': m_value, - 'wiki_paragraph': wiki_paragraph, - 'wiki_categories': wiki_categories - }) - rows_in_current_batch.append(i) # Sammle Zeilennummer - - # --- Verarbeite den Batch, wenn voll --- - if len(current_batch_data) >= batch_size: - self.logger.debug(f"\n--- Starte Wiki-Verifizierungs-Batch ({len(current_batch_data)} Tasks, Zeilen {rows_in_current_batch[0]}-{rows_in_current_batch[-1]}) ---") - # Rufe die interne Methode auf, die den OpenAI Call macht - batch_results = self._process_verification_openai_batch(current_batch_data) - - # Sammle Sheet Updates basierend auf den Batch-Ergebnissen - # Setze immer den Timestamp AX und die Werte in S, T, U - current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - batch_sheet_updates = [] - - for row_num in rows_in_current_batch: - answer = batch_results.get(row_num, "FEHLER: Batch-Ergebnis fehlt") # Fallback - # self.logger.debug(f"Zeile {row_num} Verifizierungsantwort: '{answer}'") # Zu viel Lärm - - # Logik zur Bestimmung der Werte für S, T, U basierend auf 'answer' (wie in _process_batch) - wiki_confirm, alt_article, wiki_explanation = "", "", "" - # Spalten V-Y (Begründung bei Abweichung etc.) werden in diesem Modus geleert - v_y_values = [''] * (y_idx - v_idx + 1) if v_y_range_letter else [] - - - if answer.upper() == "OK": - wiki_confirm = "OK" - wiki_explanation = "Passt laut KI zur Firma." # Standard Begründung - elif answer.startswith("X |"): - parts = answer.split("|", 2) - wiki_confirm = "X" - if len(parts) > 1: - detail = parts[1].strip() - if detail.startswith("Alternativer Artikel:"): alt_article = detail.split(":", 1)[1].strip() - elif detail == "Kein passender Artikel gefunden": alt_article = detail - else: alt_article = detail # Unbekanntes Detail - if len(parts) > 2: - reason_part = parts[2].strip() - if reason_part.startswith("Begründung:"): wiki_explanation = reason_part.split(":", 1)[1].strip() - else: wiki_explanation = reason_part # Unbekannte Begründung - # Füge ggf. den rohen Antworttext zur Begründung hinzu, wenn Parsing unvollständig war - if not alt_article or not wiki_explanation: - wiki_explanation += f" (Rohantwort: {answer[:100]}...)" - - # else if answer == "FEHLER: Keine Antwort von OpenAI" etc.: - elif answer.startswith("FEHLER"): - wiki_confirm = "FEHLER" - wiki_explanation = answer # Fehlermeldung in Begründung schreiben - alt_article = "Siehe Begründung" - else: # Unerwartetes Format - wiki_confirm = "?" - wiki_explanation = f"Unerwartetes Format: {answer}" - alt_article = "Siehe Begründung" - - - # Füge Updates für S, T, U und AX hinzu - batch_sheet_updates.append({'range': f'{s_letter}{row_num}', 'values': [[wiki_confirm]]}) - batch_sheet_updates.append({'range': f'{t_letter}{row_num}', 'values': [[alt_article]]}) - batch_sheet_updates.append({'range': f'{u_letter}{row_num}', 'values': [[wiki_explanation]]}) - batch_sheet_updates.append({'range': f'{ts_ax_letter}{row_num}', 'values': [[current_timestamp]]}) # Setze AX Timestamp - - # Füge 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]}) - - - # --- Sende gesammelte Updates für diesen Batch --- - if batch_sheet_updates: - self.logger.debug(f" Sende Sheet-Update für {len(rows_in_current_batch)} Zeilen ({len(batch_sheet_updates)} Zellen)...") - # Nutzt die batch_update_cells Methode des Sheet Handlers mit Retry - success = self.sheet_handler.batch_update_cells(batch_sheet_updates) - if success: - self.logger.info(f" Sheet-Update für Wiki-Verifizierungs-Batch {rows_in_current_batch[0]}-{rows_in_current_batch[-1]} erfolgreich.") - # Der Fehlerfall wird von batch_update_cells geloggt - - # Setze Batch-Listen zurück - current_batch_data = [] - rows_in_current_batch = [] - - # Pause nach jedem Batch-API-Call - # Nutze Config.RETRY_DELAY, ggf. kürzer, da es ein Batch war - pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.5 # 50% der Retry-Wartezeit - self.logger.debug(f"Warte {pause_duration:.2f}s vor nächstem Batch...") - time.sleep(pause_duration) - - # --- Verarbeitung des letzten unvollständigen Batches nach der Schleife --- - if current_batch_data: - self.logger.debug(f"\n--- Starte FINALEN Wiki-Verifizierungs-Batch ({len(current_batch_data)} Tasks, Zeilen {rows_in_current_batch[0]}-{rows_in_current_batch[-1]}) ---") - batch_results = self._process_verification_openai_batch(current_batch_data) - - current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - batch_sheet_updates = [] - - for row_num in rows_in_current_batch: - answer = batch_results.get(row_num, "FEHLER: Batch-Ergebnis fehlt") - wiki_confirm, alt_article, wiki_explanation = "", "", "" - v_y_values = [''] * (y_idx - v_idx + 1) if v_y_range_letter else [] - - if answer.upper() == "OK": wiki_confirm = "OK"; wiki_explanation = "Passt laut KI zur Firma." - elif 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.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.startswith("Begründung:") else reason_part - if not alt_article or not wiki_explanation: wiki_explanation += f" (Rohantwort: {answer[:100]}...)" - elif answer.startswith("FEHLER"): wiki_confirm = "FEHLER"; wiki_explanation = answer; alt_article = "Siehe Begründung" - else: wiki_confirm = "?"; wiki_explanation = f"Unerwartetes Format: {answer}"; alt_article = "Siehe Begründung" - - batch_sheet_updates.append({'range': f'{s_letter}{row_num}', 'values': [[wiki_confirm]]}) - batch_sheet_updates.append({'range': f'{t_letter}{row_num}', 'values': [[alt_article]]}) - batch_sheet_updates.append({'range': f'{u_letter}{row_num}', 'values': [[wiki_explanation]]}) - batch_sheet_updates.append({'range': f'{ts_ax_letter}{row_num}', 'values': [[current_timestamp]]}) - if v_y_range_letter: batch_sheet_updates.append({'range': f'{v_letter}{row_num}:{y_letter}{row_num}', 'values': [v_y_values]}) - - - if batch_sheet_updates: - self.logger.debug(f" Sende FINALES Sheet-Update für {len(rows_in_current_batch)} Zeilen ({len(batch_sheet_updates)} Zellen)...") - success = self.sheet_handler.batch_update_cells(batch_sheet_updates) - if success: self.logger.info(f" FINALES Sheet-Update für Wiki-Verifizierungs-Batch erfolgreich.") - - - self.logger.info(f"Wikipedia-Verifizierungs-Batch abgeschlossen. {processed_count} Zeilen verarbeitet, {skipped_count} Zeilen übersprungen ({skipped_no_wiki_url} wegen fehlender M-URL).") - - - # --- Die nächsten Batch-Methoden der DataProcessor Klasse folgen in den nächsten Teilen --- - # process_website_batch method... (kommt in Teil 13) - # process_summarization_batch method... (kommt in Teil 13) - # process_branch_batch method... (kommt in Teil 14) - # process_find_wiki_serp method... (kommt in Teil 14) - # process_contact_search method... (kommt in Teil 14) - # process_wiki_updates_from_chatgpt method... (kommt in Teil 15) - # process_wiki_reextract_missing_an method... (kommt in Teil 15) - -# --- Methode für den Website-Scraping-Batchmodus (AR) --- - # Übernommen aus process_website_batch in Teil 9, angepasst als Methode. - def process_website_scraping_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None): - """ - Batch-Prozess NUR für Website-Scraping (Rohtext AR). - Lädt Daten neu, prüft Spalte AR auf Inhalt ('', 'k.A.', etc.) und überspringt Zeilen mit Inhalt. - Setzt AR + AT + AP für bearbeitete Zeilen. Sendet Updates gebündelt. - - Args: - start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung). - 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 übersprungener) Zeilen. Defaults to None. - """ - self.logger.info(f"Starte Website-Scraping (Batch) für Rohtext (AR). Bereich: {start_sheet_row}-{end_sheet_row}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") - - # --- Daten laden --- - # Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt - if start_sheet_row is None: - self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AT...") - # Nutzt get_start_row_index des Sheet Handlers. Prüft auf leeren AT. - start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Website Scrape Timestamp") - if start_data_index_no_header == -1: - self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche 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 AT Zelle): {start_sheet_row}") - else: - # Daten trotzdem neu laden, um aktuell zu sein - if not self.sheet_handler.load_data(): - self.logger.error("FEHLER beim Laden der Daten für process_website_scraping_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) - - # Berechne Endzeile, wenn nicht gesetzt - if end_sheet_row is None: - end_sheet_row = total_sheet_rows # Bis zur letzten Zeile - - 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 = [ - "Website Rohtext", "CRM Website", "Version", "Website Scrape Timestamp", "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: Benötigte Spaltenschlüssel fehlen in COLUMN_MAP für process_website_scraping_batch: {missing}. Breche ab.") - return - - rohtext_col_idx = col_indices["Website Rohtext"] - website_col_idx = col_indices["CRM Website"] - version_col_idx = col_indices["Version"] - timestamp_col_idx = col_indices["Website Scrape Timestamp"] - name_col_idx = col_indices["CRM Name"] - - rohtext_col_letter = self.sheet_handler._get_col_letter(rohtext_col_idx + 1) - version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1) - timestamp_col_letter = self.sheet_handler._get_col_letter(timestamp_col_idx + 1) - - - # --- Worker-Funktion für Scraping --- - # Diese Funktion läuft in einem separaten Thread - def scrape_raw_text_task(task_info): - row_num = task_info['row_num'] - url = task_info['url'] - raw_text = "k.A." - error = None - try: - # Nutzt die globale Funktion get_website_raw mit Retry Decorator - raw_text = get_website_raw(url) # Annahme: get_website_raw in utils.py - except Exception as e: - # Fängt Fehler beim Scraping, damit der Thread nicht abstürzt - error = f"Scraping Fehler Zeile {row_num} ({url}): {e}" - self.logger.error(error) - raw_text = "k.A. (Fehler)" # Setze einen Fehlerwert in den Rohtext - - #logger.debug(f"Scraping Task Zeile {row_num} abgeschlossen. Textlänge: {len(str(raw_text))}.") # Zu viel Lärm - return {"row_num": row_num, "raw_text": raw_text, "error": error} - - - # --- Hauptlogik: Iteriere und sammle Batches --- - tasks_for_processing_batch = [] # Tasks für den aktuellen Scraping-Batch - rows_in_current_scraping_batch = [] # Zeilennummern im aktuellen Batch - all_sheet_updates = [] # Gesammelte Updates für Batch-Schreiben - update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) # Nutze die Update-Batch-Größe aus Config - - -# --- Worker-Funktion für Scraping (intern) --- - # Diese Funktion wird vom ThreadPoolExecutor aufgerufen und hat Zugriff auf den umgebenden Scope. - def scrape_raw_text_task(task_info): - """ - Scrapt den Rohtext einer Website in einem separaten Thread. - Wird vom ThreadPoolExecutor in process_website_scraping_batch aufgerufen. - Nutzt die globale Funktion get_website_raw. - - Args: - task_info (dict): Enthält {'row_num': int, 'url': str}. - - Returns: - dict: Enthält {'row_num': int, 'raw_text': str, 'error': str}. - """ - row_num = task_info['row_num'] - url = task_info['url'] - raw_text = "k.A." - error = None - - try: - # RUFT die globale Funktion get_website_raw auf. - # Der retry_on_failure Decorator auf get_website_raw behandelt Retries und Fehler. - raw_text = get_website_raw(url) # <<< Hier wird die globale Funktion aufgerufen - - # Wenn get_website_raw einen Fehler loggt und einen Fehlerstring zurückgibt, - # wird dies im Ergebnisdict als raw_text gespeichert. - # Wir können hier prüfen, ob der raw_text einen Fehlerwert signalisiert. - if str(raw_text).startswith("k.A. (Fehler") or str(raw_text).startswith("FEHLER:"): - error = f"Scraping Fehler (Details im Rohtext): {raw_text[:100]}..." - # self.logger.error(error) # Wird bereits in get_website_raw geloggt - pass # Fehler wurde bereits im Rückgabewert signalisiert - - - except Exception as e: - # Dieser Block sollte jetzt seltener erreicht werden, da get_website_raw - # die meisten Fehler intern fängt und mit retry behandelt. - # Wenn eine Exception hier durchkommt, ist es ein unerwarteter Fehler im Task-Handling selbst. - error = f"Unerwarteter Fehler im Scraping Task Zeile {row_num} ({url}): {type(e).__name__} - {e}" - # self.logger.error(error) # Loggen Sie diesen unerwarteten Fehler - raw_text = "k.A. (Unerwarteter Fehler Task)" # Setze einen spezifischen Fehlerwert - - - # logger.debug(f"Scraping Task Zeile {row_num} abgeschlossen. Textlänge: {len(str(raw_text))}.") # Zu viel Lärm - return {"row_num": row_num, "raw_text": raw_text, "error": error} - - # --- Hauptlogik: Iteriere und sammle Batches --- - - processed_count = 0 # Zählt Zeilen, die im Batch verarbeitet (versucht) wurden - skipped_count = 0 # Zählt Zeilen, die übersprungen wurden (wegen Inhalt oder fehlender URL) - - # Iteriere über die Sheet-Zeilen im definierten Bereich - 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 - if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht - - row = all_data[row_index_in_list] - - # Stellen Sie sicher, dass die Zeile nicht leer ist - if not any(cell and cell.strip() for cell in row): - #self.logger.debug(f"Zeile {i}: Übersprungen (Leere Zeile).") # Zu viel Lärm - skipped_count += 1 - continue - - # --- Prüfung, ob Verarbeitung für diese Zeile nötig ist --- - # Kriterium: Website Rohtext (AR) ist leer oder "k.A." etc. - # UND Website URL (D) ist vorhanden und nicht "k.A.". - - # Prüfe Website Rohtext (AR) auf Inhalt - cell_value_ar = self._get_cell_value_safe(row, "Website Rohtext") - ar_is_empty_or_default = not cell_value_ar or str(cell_value_ar).strip().lower() in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"] - - # Prüfe Website URL (D) auf Inhalt - website_url = self._get_cell_value_safe(row, "CRM Website").strip() - website_url_is_valid_looking = website_url and website_url.lower() not in ["k.a.", "kein artikel gefunden"] - - # Verarbeitung nötig, wenn AR leer UND D gefüllt - processing_needed_for_row = ar_is_empty_or_default and website_url_is_valid_looking - - # Loggen der Prüfergebnisse auf Debug - 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} (Website Scraping Check): AR leer/default? {ar_is_empty_or_default}, D gültig? {website_url_is_valid_looking}. Benötigt Verarbeitung? {processing_needed_for_row}") - - - if not processing_needed_for_row: - skipped_count += 1 - continue - - # --- Wenn Verarbeitung nötig: Füge zur Batch-Liste hinzu --- - processed_count += 1 # Zähle die Zeile, die verarbeitet wird (zum Limit zählen) - - # Prüfe das Limit für verarbeitete Zeilen - if limit is not None and processed_count > limit: - self.logger.info(f"Verarbeitungslimit ({limit}) für process_website_scraping_batch erreicht. Breche weitere Zeilenprüfung ab.") - break # Schleife abbrechen - - tasks_for_processing_batch.append({"row_num": i, "url": website_url}) - rows_in_current_scraping_batch.append(i) # Sammle Zeilennummer - - - # --- Verarbeite den Batch, wenn voll --- - scraping_batch_size = getattr(Config, 'PROCESSING_BATCH_SIZE', 20) # Batch-Größe aus Config - max_scraping_workers = getattr(Config, 'MAX_SCRAPING_WORKERS', 10) # Max Worker aus Config - - if len(tasks_for_processing_batch) >= scraping_batch_size: - batch_start_row = tasks_for_processing_batch[0]['row_num'] - batch_end_row = tasks_for_processing_batch[-1]['row_num'] - self.logger.debug(f"\n--- Starte Website-Scraping Batch ({len(tasks_for_processing_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") - - scraping_results = {} - batch_error_count = 0 # Fehlerzähler für diesen spezifischen Batch - - self.logger.debug(f" Scrape {len(tasks_for_processing_batch)} Websites parallel (max {max_scraping_workers} worker)...") - # Nutzt concurrent.futures für paralleles Scraping - with concurrent.futures.ThreadPoolExecutor(max_workers=max_scraping_workers) as executor: - # Map tasks to futures - future_to_task = {executor.submit(scrape_raw_text_task, task): task for task in tasks_for_processing_batch} - #future_to_task = {executor.submit(_scrape_raw_text_task_global, task): task for task in tasks_for_processing_batch} # Auf globalen Namen geändert - - # Process results as they complete - for future in concurrent.futures.as_completed(future_to_task): - task = future_to_task[future] # Get the original task data - try: - result = future.result() # Get the result from the future - scraping_results[result['row_num']] = result['raw_text'] - if result['error']: # Check if the worker reported an error - batch_error_count += 1 - except Exception as exc: - # This block catches unexpected errors during future result retrieval - row_num = task['row_num'] - err_msg = f"Generischer Fehler Scraping Task Zeile {row_num} ({task['url']}): {exc}" - self.logger.error(err_msg) - scraping_results[row_num] = "k.A. (Fehler Task)" # Set a default error value - batch_error_count += 1 - - self.logger.debug(f" Scraping für Batch beendet. {len(scraping_results)} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).") - - # Sheet Updates vorbereiten (AR, AT, AP) für diesen Batch - if scraping_results: - current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - current_version = getattr(Config, 'VERSION', 'unknown') - batch_sheet_updates = [] - - for row_num, raw_text_res in scraping_results.items(): - # Updates für AR, AT und AP - batch_sheet_updates.append({'range': f'{rohtext_col_letter}{row_num}', 'values': [[raw_text_res]]}) - batch_sheet_updates.append({'range': f'{timestamp_col_letter}{row_num}', 'values': [[current_timestamp]]}) # Setze AT Timestamp - batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Setze AP Version - - # Sammle diese Batch-Updates für das größere Batch-Update am Ende oder bei Limit - all_sheet_updates.extend(batch_sheet_updates) - - # Leere den Scraping-Batch - tasks_for_processing_batch = [] - rows_in_current_scraping_batch = [] - - # Sende gesammelte Sheet Updates, wenn das Update-Batch-Limit erreicht ist - # Wir prüfen die Anzahl der *Zeilen*, für die Updates gesammelt wurden - # Updates pro Zeile sind 3 (AR, AT, AP). all_sheet_updates.extend fügt 3 Einträge pro Zeile hinzu. - # Anzahl der Zeilen = len(all_sheet_updates) / 3 - rows_in_update_batch = len(all_sheet_updates) // 3 # Ganzzahl-Division - - if rows_in_update_batch >= update_batch_row_limit: - self.logger.debug(f" Sende gesammelte Sheet-Updates ({rows_in_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") - # Nutzt die batch_update_cells Methode des Sheet Handlers mit Retry - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: - self.logger.info(f" Sheet-Update für {rows_in_update_batch} Zeilen erfolgreich.") - # Der Fehlerfall wird von batch_update_cells geloggt - - # Leere die gesammelten Updates nach dem Senden - all_sheet_updates = [] - # rows_in_update_batch muss nicht explizit zurückgesetzt werden, da es aus len(all_sheet_updates) berechnet wird. - - # Keine Pause hier nach jedem kleinen Scraping-Batch, da wir auf batch_update warten. - # Die Pause kommt erst nach dem Batch-Update. - - - # --- Verarbeitung des letzten unvollständigen Scraping-Batches nach der Schleife --- - # Führe den letzten Batch aus, wenn noch Tasks vorhanden sind - if tasks_for_processing_batch: - batch_start_row = tasks_for_processing_batch[0]['row_num'] - batch_end_row = tasks_for_processing_batch[-1]['row_num'] - self.logger.debug(f"\n--- Starte FINALEN Website-Scraping Batch ({len(tasks_for_processing_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") - - scraping_results = {} - batch_error_count = 0 - - self.logger.debug(f" Scrape {len(tasks_for_processing_batch)} Websites parallel (max {max_scraping_workers} worker)...") - with concurrent.futures.ThreadPoolExecutor(max_workers=max_scraping_workers) as executor: - future_to_task = {executor.submit(scrape_raw_text_task, task): task for task in tasks_for_processing_batch} - for future in concurrent.futures.as_completed(future_to_task): - task = future_to_task[future] - try: - result = future.result() - scraping_results[result['row_num']] = result['raw_text'] - if result['error']: batch_error_count += 1 - except Exception as exc: - row_num = task['row_num'] - err_msg = f"Generischer Fehler Scraping Task Zeile {row_num} ({task['url']}): {exc}" - self.logger.error(err_msg) - scraping_results[row_num] = "k.A. (Fehler Task)" - batch_error_count += 1 - - self.logger.debug(f" FINALER Scraping Batch beendet. {len(scraping_results)} Ergebnisse erhalten ({batch_error_count} Fehler).") - - if scraping_results: - current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - current_version = getattr(Config, 'VERSION', 'unknown') - batch_sheet_updates = [] - for row_num, raw_text_res in scraping_results.items(): - batch_sheet_updates.append({'range': f'{rohtext_col_letter}{row_num}', 'values': [[raw_text_res]]}) - batch_sheet_updates.append({'range': f'{timestamp_col_letter}{row_num}', 'values': [[current_timestamp]]}) - batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) - all_sheet_updates.extend(batch_sheet_updates) - - - # --- Finale Sheet Updates senden --- - # Sende alle verbleibenden gesammelten Updates - if all_sheet_updates: - rows_in_final_update_batch = len(all_sheet_updates) // 3 # Ganzzahl-Division - self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({rows_in_final_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: - self.logger.info(f"FINALES Sheet-Update erfolgreich.") - # Der Fehlerfall wird von batch_update_cells geloggt - - self.logger.info(f"Website-Scraping (Batch) abgeschlossen. {processed_count} Zeilen verarbeitet (versucht), {skipped_count} Zeilen übersprungen.") - # Es ist keine Pause nach diesem Modus nötig, da die nächste Aktion im Dispatcher folgt. - - - # --- Methode für den Website-Summarization-Batchmodus (AS) --- - # Übernommen aus process_website_summarization_batch in Teil 9, angepasst als Methode. - def process_summarization_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None): - """ - Batch-Prozess NUR für Website-Zusammenfassung (AS). - Lädt Daten neu, prüft, ob Rohtext (AR) vorhanden und Zusammenfassung (AS) fehlt. - Fasst Rohtexte im Batch via OpenAI zusammen und setzt AS + AP. - - Args: - start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung). - 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 übersprungener) Zeilen. Defaults to None. - """ - self.logger.info(f"Starte Website-Zusammenfassung (Batch). Bereich: {start_sheet_row}-{end_sheet_row}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") - - # --- Konfiguration --- - openai_batch_size = getattr(Config, 'OPENAI_BATCH_SIZE_LIMIT', 4) # Holt Wert aus Config - update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) # Holt Wert aus Config - - # --- Daten laden --- - # Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt - if start_sheet_row is None: - self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AS...") - # Nutzt get_start_row_index des Sheet Handlers. Prüft auf leeren AS. - start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Website Zusammenfassung") - if start_data_index_no_header == -1: - self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche 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 AS Zelle): {start_sheet_row}") - else: - # Daten trotzdem neu laden, um aktuell zu sein - if not self.sheet_handler.load_data(): - self.logger.error("FEHLER beim Laden der Daten für process_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) - - # Berechne Endzeile, wenn nicht gesetzt - if end_sheet_row is None: - end_sheet_row = total_sheet_rows # Bis zur letzten Zeile - - 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 = [ - "Website Rohtext", "Website Zusammenfassung", "Version", "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: Benötigte Spaltenschlüssel fehlen in COLUMN_MAP für process_summarization_batch: {missing}. Breche ab.") - return - - rohtext_col_idx = col_indices["Website Rohtext"] - summary_col_idx = col_indices["Website Zusammenfassung"] - version_col_idx = col_indices["Version"] - name_col_idx = col_indices["CRM Name"] - - - summary_col_letter = self.sheet_handler._get_col_letter(summary_col_idx + 1) - version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1) - - - # --- Verarbeitung --- - tasks_for_openai_batch = [] # Tasks für den aktuellen OpenAI Batch - rows_in_current_openai_batch = [] # Zeilennummern im aktuellen OpenAI Batch - all_sheet_updates = [] # Gesammelte Updates für Batch-Schreiben - update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) # Update Batch Größe aus Config - - processed_count = 0 # Zählt Zeilen, die im Batch verarbeitet (versucht) wurden - skipped_count = 0 # Zählt Zeilen, die übersprungen wurden (wegen fehlendem Rohtext oder vorhandener Zusammenfassung) - - # Iteriere über die Sheet-Zeilen im definierten Bereich - 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 - if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht - - row = all_data[row_index_in_list] - - # Stellen Sie sicher, dass die Zeile nicht leer ist - if not any(cell and cell.strip() for cell in row): - #self.logger.debug(f"Zeile {i}: Übersprungen (Leere Zeile).") # Zu viel Lärm - skipped_count += 1 - continue - - # --- Prüfung, ob Verarbeitung für diese Zeile nötig ist --- - # Kriterium: Website Rohtext (AR) ist vorhanden und nicht "k.A." etc. - # UND Website Zusammenfassung (AS) ist leer oder "k.A.". - - # Prüfe Website Rohtext (AR) auf Inhalt - raw_text = self._get_cell_value_safe(row, "Website Rohtext") - raw_text_is_valid = raw_text and str(raw_text).strip().lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"] - - # Prüfe Website Zusammenfassung (AS) auf Inhalt - 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)"] - - # Verarbeitung nötig, wenn AR gefüllt UND AS leer - processing_needed_for_row = raw_text_is_valid and summary_is_empty_or_default - - # Loggen der Prüfergebnisse auf Debug - 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() - self.logger.debug(f"Zeile {i} ({company_name[:30]}... Website Summarization Check): AR gültig? {raw_text_is_valid} (len={len(str(raw_text))}), AS leer/default? {summary_is_empty_or_default}. Benötigt Verarbeitung? {processing_needed_for_row}") - - - if not processing_needed_for_row: - skipped_count += 1 - continue - - # --- Wenn Verarbeitung nötig: Füge zur Batch-Liste hinzu --- - processed_count += 1 # Zähle die Zeile, die verarbeitet wird (zum Limit zählen) - - # Prüfe das Limit für verarbeitete Zeilen - if limit is not None and processed_count > limit: - self.logger.info(f"Verarbeitungslimit ({limit}) für process_summarization_batch erreicht. Breche weitere Zeilenprüfung ab.") - break # Schleife abbrechen - - # Sammle die benötigten Daten für den OpenAI Batch - tasks_for_openai_batch.append({'row_num': i, 'raw_text': raw_text}) - rows_in_current_openai_batch.append(i) # Sammle Zeilennummer - - - # --- Verarbeite den Batch, wenn voll --- - if len(tasks_for_openai_batch) >= openai_batch_size: - batch_start_row = tasks_for_openai_batch[0]['row_num'] - batch_end_row = tasks_for_openai_batch[-1]['row_num'] - self.logger.debug(f"\n--- Starte Website-Summarization Batch ({len(tasks_for_openai_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") - - # Rufe die globale Funktion auf, die den OpenAI Call macht (nutzt intern call_openai_chat) - # summarize_batch_openai nutzt den retry_on_failure Decorator - try: - summaries_result = summarize_batch_openai(tasks_for_openai_batch) # Annahme: summarize_batch_openai in utils.py - # Ergebnisse sollten ein Dict {row_num: summary_text} sein - - # Sammle Sheet Updates (AS, AP) für diesen Batch - current_version = getattr(Config, 'VERSION', 'unknown') - batch_sheet_updates = [] - - for row_num in rows_in_current_openai_batch: # Iteriere über die Zeilen im *gesendeten* Batch - # Hole das Ergebnis für diese Zeile aus dem Ergebnis-Dict - summary = summaries_result.get(row_num, "k.A. (Batch Ergebnis fehlte)") - # Stelle sicher, dass 'k.A.' bei leeren/kurzen Summaries gesetzt wird - if not summary or summary.strip().lower() in ["k.a.", "k.a. (keine zusammenfassung erhalten)"]: - summary = "k.A. (Keine Zusammenfassung erhalten)" - - batch_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[summary]]}) # Setze AS - batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Setze AP Version - - # Sammle diese Batch-Updates für das größere Batch-Update - all_sheet_updates.extend(batch_sheet_updates) - - except Exception as e_openai_batch: - # Wenn der gesamte summarize_batch_openai Call fehlschlägt (nach Retries) - self.logger.error(f"Endgültiger FEHLER beim OpenAI-Batch-Aufruf für Zusammenfassung: {e_openai_batch}") - # Fügen Sie Fehlerwerte für alle Zeilen im Batch hinzu - current_version = getattr(Config, 'VERSION', 'unknown') - for row_num in rows_in_current_openai_batch: - error_summary = f"FEHLER OpenAI Batch: {str(e_openai_batch)[:100]}" - all_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[error_summary]]}) - all_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) - - - # Leere den OpenAI-Batch - tasks_for_openai_batch = [] - rows_in_current_openai_batch = [] - - # Sende gesammelte Sheet Updates, wenn das Update-Batch-Limit erreicht ist - # Updates pro Zeile sind 2 (AS, AP). len(all_sheet_updates) / 2 - rows_in_update_batch = len(all_sheet_updates) // 2 - - if rows_in_update_batch >= update_batch_row_limit: - self.logger.debug(f" Sende gesammelte Sheet-Updates ({rows_in_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: - self.logger.info(f" Sheet-Update für {rows_in_update_batch} Zeilen erfolgreich.") - # Der Fehlerfall wird von batch_update_cells geloggt - - # Leere die gesammelten Updates nach dem Senden - all_sheet_updates = [] - - # Kurze Pause nach jedem OpenAI Batch - pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.5 # 50% der Retry-Wartezeit - self.logger.debug(f"Warte {pause_duration:.2f}s vor nächstem Batch...") - time.sleep(pause_duration) - - - # --- Verarbeitung des letzten unvollständigen OpenAI Batches nach der Schleife --- - # Führe den letzten Batch aus, wenn noch Tasks vorhanden sind - if tasks_for_openai_batch: - batch_start_row = tasks_for_openai_batch[0]['row_num'] - batch_end_row = tasks_for_openai_batch[-1]['row_num'] - self.logger.debug(f"\n--- Starte FINALEN Website-Summarization Batch ({len(tasks_for_openai_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") - - try: - summaries_result = summarize_batch_openai(tasks_for_openai_batch) - - current_version = getattr(Config, 'VERSION', 'unknown') - batch_sheet_updates = [] - for row_num in rows_in_current_openai_batch: - summary = summaries_result.get(row_num, "k.A. (Batch Ergebnis fehlte)") - if not summary or summary.strip().lower() in ["k.a.", "k.a. (keine zusammenfassung erhalten)"]: - summary = "k.A. (Keine Zusammenfassung erhalten)" - - batch_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[summary]]}) - batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) - all_sheet_updates.extend(batch_sheet_updates) - - except Exception as e_openai_batch: - self.logger.error(f"Endgültiger FEHLER beim FINALEN OpenAI-Batch-Aufruf für Zusammenfassung: {e_openai_batch}") - current_version = getattr(Config, 'VERSION', 'unknown') - for row_num in rows_in_current_openai_batch: - error_summary = f"FEHLER OpenAI Batch: {str(e_openai_batch)[:100]}" - all_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[error_summary]]}) - all_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) - - - # --- Finale Sheet Updates senden --- - if all_sheet_updates: - rows_in_final_update_batch = len(all_sheet_updates) // 2 - self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({rows_in_final_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: - self.logger.info(f"FINALES Sheet-Update erfolgreich.") - # Der Fehlerfall wird von batch_update_cells geloggt - - self.logger.info(f"Website-Zusammenfassung (Batch) abgeschlossen. {processed_count} Zeilen verarbeitet (versucht), {skipped_count} Zeilen übersprungen.") - # Es ist keine Pause nach diesem Modus nötig. - - - # --- Die nächsten Batch-Methoden der DataProcessor Klasse folgen in den nächsten Teilen --- - # process_branch_batch method... (kommt in Teil 14) - # process_find_wiki_serp method... (kommt in Teil 14) - # process_contact_search method... (kommt in Teil 14) - # process_wiki_updates_from_chatgpt method... (kommt in Teil 15) - # process_wiki_reextract_missing_an method... (kommt in Teil 15) - -# --- Interne Hilfsfunktion für Branchen-Batch (OpenAI Call) --- - # Diese Funktion läuft in einem separaten Thread für parallele Verarbeitung. - # Sie nutzt den globalen evaluate_branche_chatgpt, der wiederum call_openai_chat nutzt. - # Der OpenAI Semaphore sollte hier genutzt werden, da dies der Punkt ist, - # der tatsächliche OpenAI API Calls initiiert. - def evaluate_branch_task(self, task_data, openai_semaphore): - """ - Führt die Branchenevaluation für eine einzelne Zeile aus. - Läuft in einem separaten Thread für den Branchen-Batch. - - Args: - task_data (dict): Enthält die Daten für die Zeile. - openai_semaphore (threading.Semaphore): Semaphore zur Begrenzung gleichzeitiger OpenAI-Calls. - - Returns: - dict: Ergebnis von evaluate_branche_chatgpt plus row_num und error. - """ - row_num = task_data['row_num'] - result = {"branch": "k.A. (Fehler Task)", "consistency": "error", "justification": "Fehler in Worker-Task"} - error = None - - try: - # Acquire the semaphore before making the OpenAI call - with openai_semaphore: - # Kleine künstliche Pause reduziert manchmal Race Conditions bei hoher Last oder schnellen APIs - # time.sleep(0.05) # Optional - - # Annahme: evaluate_branche_chatgpt ist global definiert (utils.py) - # evaluate_branche_chatgpt ruft call_openai_chat auf, der den retry_on_failure Decorator nutzt. - 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}" - self.logger.error(error) - # Stellen Sie sicher, dass das Ergebnis-Dict im Fehlerfall korrekt ist - result = {"branch": "FEHLER", "consistency": "error_task", "justification": error[:500]} # Kürze Begründung - - #logger.debug(f"Branch Task Zeile {row_num} abgeschlossen.") # Zu viel Lärm - return {"row_num": row_num, "result": result, "error": error} - - - # --- Methode für den Branchen-Batchmodus (AO) --- - # Übernommen aus process_branch_batch in Teil 9, angepasst als Methode. - def process_branch_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None): - """ - Batch-Prozess für Brancheneinschätzung mit paralleler Verarbeitung via Threads. - Prüft Timestamp AO, führt evaluate_branche_chatgpt parallel aus (limitiert), - setzt W, X, Y, AO + AP und sendet Sheet-Updates GEBÜNDELT PRO VERARBEITUNGS-BATCH. - - Args: - start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung). - 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 übersprungener) Zeilen. Defaults to None. - """ - self.logger.info(f"Starte Brancheneinschätzung (Parallel Batch). Bereich: {start_sheet_row}-{end_sheet_row}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") - - # --- Daten laden --- - # Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt - if start_sheet_row is None: - self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AO...") - # Nutzt get_start_row_index des Sheet Handlers. Prüft auf leeren AO. - start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Timestamp letzte Prüfung") - if start_data_index_no_header == -1: - self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche 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 AO Zelle): {start_sheet_row}") - else: - # Daten trotzdem neu laden, um aktuell zu sein - if not self.sheet_handler.load_data(): - self.logger.error("FEHLER beim Laden der Daten für 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) - - # Berechne Endzeile, wenn nicht gesetzt - if end_sheet_row is None: - end_sheet_row = total_sheet_rows # Bis zur letzten Zeile - - 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 Prüfung", # AO - Prüfkriterium - "CRM Branche", "CRM Beschreibung", "Wiki Branche", "Wiki Kategorien", # Daten für Prompt - "Website Zusammenfassung", "Version", # Weitere Daten für Prompt / Update - "Chat Vorschlag Branche", "Chat Konsistenz Branche", "Chat Begründung Abweichung Branche" # Ergebnisspalten W, X, Y - ] - 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: Benötigte Spaltenschlüssel fehlen in COLUMN_MAP für process_branch_batch: {missing}. Breche ab.") - return - - # Spaltenbuchstaben für Updates - ts_ao_letter = self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Prüfung"] + 1) - version_col_letter = self.sheet_handler._get_col_letter(col_indices["Version"] + 1) - branch_w_letter = self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Branche"] + 1) - branch_x_letter = self.sheet_handler._get_col_letter(col_indices["Chat Konsistenz Branche"] + 1) - branch_y_letter = self.sheet_handler._get_col_letter(col_indices["Chat Begründung Abweichung Branche"] + 1) - - - # --- Konfiguration für Parallelisierung --- - MAX_BRANCH_WORKERS = getattr(Config, 'MAX_BRANCH_WORKERS', 10) # Threads für parallele Verarbeitung - OPENAI_CONCURRENCY_LIMIT = getattr(Config, 'OPENAI_CONCURRENCY_LIMIT', 3) # Max. gleichzeitige OpenAI Calls - openai_semaphore_branch = threading.Semaphore(OPENAI_CONCURRENCY_LIMIT) # Semaphore Instanz - - - # --- Verarbeitung --- - processing_batch_size = getattr(Config, 'PROCESSING_BRANCH_BATCH_SIZE', 20) # Größe des Verarbeitungsbatches - tasks_for_processing_batch = [] # Tasks für den aktuellen Batch - rows_in_current_batch = [] # Zeilennummern im aktuellen Batch - # Sheet Updates werden direkt nach Verarbeitung eines Batch geschrieben, - # keine große gesammelte Liste wie bei Scraping/Summarization - - - processed_count = 0 # Zählt Zeilen, die im Batch verarbeitet (versucht) wurden - skipped_count = 0 # Zählt Zeilen, die übersprungen wurden (wegen AO oder fehlender Daten) - - # Laden Sie das Zielschema, falls noch nicht geschehen (evaluate_branche_chatgpt benötigt es) - # evaluate_branche_chatgpt prüft intern, ob das Schema geladen ist und loggt Fehler, - # aber wir können hier auch prüfen und ggf. abbrechen. - global ALLOWED_TARGET_BRANCHES - if not ALLOWED_TARGET_BRANCHES: - # Annahme: load_target_schema ist global (utils.py) - load_target_schema() # Versuche, das Schema zu laden - if not ALLOWED_TARGET_BRANCHES: - self.logger.critical("FEHLER: Ziel-Branchenschema konnte nicht geladen werden. Branchenbewertung nicht möglich. Breche ab.") - return - - # Iteriere über die Sheet-Zeilen im definierten Bereich - 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 - if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht - - row = all_data[row_index_in_list] - - # Stellen Sie sicher, dass die Zeile nicht leer ist - if not any(cell and cell.strip() for cell in row): - #self.logger.debug(f"Zeile {i}: Übersprungen (Leere Zeile).") # Zu viel Lärm - skipped_count += 1 - continue - - # --- Prüfung, ob Verarbeitung für diese Zeile nötig ist --- - # Kriterium: Timestamp letzte Prüfung (AO) ist leer. - # ZUSÄTZLICH: Prüfen, ob genügend Quelldaten für die Evaluation vorhanden sind. - # Mindestens CRM Branche ODER Beschreibung ODER Wiki Branche/Kategorien ODER Website Summary. - # evaluate_branche_chatgpt prüft auf mind. 2 Info-Punkte. Wir können hier eine ähnliche Prüfung machen. - - ao_value = self._get_cell_value_safe(row, "Timestamp letzte Prüfung").strip() - processing_needed_based_on_status = not ao_value - - if not processing_needed_based_on_status: - skipped_count += 1 - continue - - # Prüfe, ob ausreichend Daten vorhanden sind (mindestens 2 Quellen) - crm_branche = self._get_cell_value_safe(row, "CRM Branche").strip() - crm_beschreibung = self._get_cell_value_safe(row, "CRM Beschreibung").strip() - wiki_branche = self._get_cell_value_safe(row, "Wiki Branche").strip() - wiki_kategorien = self._get_cell_value_safe(row, "Wiki Kategorien").strip() - website_summary = self._get_cell_value_safe(row, "Website Zusammenfassung").strip() - - info_sources_count = sum(1 for val in [crm_branche, crm_beschreibung, wiki_branche, wiki_kategorien, website_summary] if val and val != "k.A.") - - if info_sources_count < 2: - self.logger.debug(f"Zeile {i} (Branch Check): Übersprungen (AO leer, aber nur {info_sources_count} Informationsquellen verfügbar).") - skipped_count += 1 - continue - - # --- Wenn Verarbeitung nötig: Füge zur Batch-Liste hinzu --- - processed_count += 1 # Zähle die Zeile, die verarbeitet wird (zum Limit zählen) - - # Prüfe das Limit für verarbeitete Zeilen - if limit is not None and processed_count > limit: - self.logger.info(f"Verarbeitungslimit ({limit}) für process_branch_batch erreicht. Breche weitere Zeilenprüfung ab.") - break # Schleife abbrechen - - # Sammle die benötigten Daten für den Branchen-Task - tasks_for_processing_batch.append({ - "row_num": i, - "crm_branche": crm_branche, - "beschreibung": crm_beschreibung, - "wiki_branche": wiki_branche, - "wiki_kategorien": wiki_kategorien, - "website_summary": website_summary - }) - rows_in_current_batch.append(i) # Sammle Zeilennummer - - - # --- Verarbeite den Batch, wenn voll --- - if len(tasks_for_processing_batch) >= processing_batch_size: - batch_start_row = tasks_for_processing_batch[0]['row_num'] - batch_end_row = tasks_for_processing_batch[-1]['row_num'] - self.logger.debug(f"\n--- Starte Branch-Evaluation Batch ({len(tasks_for_processing_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") - - results_list = [] # Ergebnisse dieses Batch - batch_error_count = 0 # Fehlerzähler für diesen spezifischen Batch - - self.logger.debug(f" Evaluiere {len(tasks_for_processing_batch)} Zeilen parallel (max {MAX_BRANCH_WORKERS} worker, {OPENAI_CONCURRENCY_LIMIT} OpenAI gleichzeitig)...") - - # *** BEGINN PARALLELE VERARBEITUNG MIT THREADS *** - # Verwende ThreadPoolExecutor für parallele Ausführung der evaluate_branch_task - with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_BRANCH_WORKERS) as executor: - # Map tasks to futures, passing the semaphore - future_to_task = {executor.submit(self.evaluate_branch_task, task, openai_semaphore): task for task in tasks_for_processing_batch} - - # Process results as they complete - for future in concurrent.futures.as_completed(future_to_task): - task = future_to_task[future] # Get the original task data - try: - result_data = future.result() # Get the result from the future - results_list.append(result_data) # Add the result (including error flag) - if result_data.get('error'): # Check if the worker reported an error - batch_error_count += 1 - except Exception as exc: - # This block catches unexpected errors during future result retrieval - row_num = task['row_num'] - err_msg = f"Generischer Fehler Branch Task Zeile {row_num}: {exc}" - self.logger.error(err_msg) - # Append a specific error result for this row - results_list.append({"row_num": row_num, "result": {"branch": "FEHLER", "consistency": "error_task", "justification": err_msg[:500]}, "error": err_msg}) - batch_error_count += 1 - - # *** ENDE PARALLELE VERARBEITUNG *** - self.logger.debug(f" Branch-Evaluation für Batch beendet. {len(results_list)} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).") - - - # Sheet Updates vorbereiten FÜR DIESEN BATCH - if results_list: - current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - current_version = getattr(Config, 'VERSION', 'unknown') - batch_sheet_updates = [] - - # Sortiere Ergebnisse nach Zeilennummer für geordnetes Schreiben (optional, aber gut) - results_list.sort(key=lambda x: x['row_num']) - - for res_data in results_list: - row_num = res_data['row_num'] - result = res_data['result'] # Das Ergebnis-Dict von evaluate_branche_chatgpt - - # Logge das individuelle Ergebnis VOR dem Update - # self.logger.debug(f" Zeile {row_num}: Ergebnis -> Branch='{result.get('branch')}', Consistency='{result.get('consistency')}', Justification='{result.get('justification', '')[:50]}...'") # Zu viel Lärm - - # Sammle Updates für W, X, Y, AO, AP - batch_sheet_updates.append({'range': f'{branch_w_letter}{row_num}', 'values': [[result.get("branch", "FEHLER")]]}) - batch_sheet_updates.append({'range': f'{branch_x_letter}{row_num}', 'values': [[result.get("consistency", "error")]]}) - batch_sheet_updates.append({'range': f'{branch_y_letter}{row_num}', 'values': [[result.get("justification", "Keine Begründung")]]}) - batch_sheet_updates.append({'range': f'{ts_ao_letter}{row_num}', 'values': [[current_timestamp]]}) # Setze AO Timestamp - batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Setze AP Version - - - # --- Sende Updates für DIESEN BATCH SOFORT --- - if batch_sheet_updates: - self.logger.debug(f" Sende Sheet-Update für {len(results_list)} Zeilen ({len(batch_sheet_updates)} Zellen)...") - # Nutzt die batch_update_cells Methode des Sheet Handlers mit Retry - success = self.sheet_handler.batch_update_cells(batch_sheet_updates) - if success: - self.logger.info(f" Sheet-Update für Batch {batch_start_row}-{batch_end_row} erfolgreich.") - # Der Fehlerfall wird von batch_update_cells geloggt - # else: self.logger.debug(f" Keine Sheet-Updates für Batch {batch_start_row}-{batch_end_row} vorbereitet.") # Zu viel Lärm - - - # Leere den Batch für die nächste Iteration - tasks_for_processing_batch = [] - rows_in_current_batch = [] - - # Pause NACHDEM ein Batch komplett verarbeitet und geschrieben wurde - # Nutze Config.RETRY_DELAY, ggf. kürzer, da es ein Batch war - pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.5 # 50% der Retry-Wartezeit - self.logger.debug(f"--- Batch {batch_start_row}-{batch_end_row} abgeschlossen. Warte {pause_duration:.2f}s vor nächstem Batch ---") - time.sleep(pause_duration) - - - # --- Verarbeitung des letzten unvollständigen Batches nach der Schleife --- - if tasks_for_processing_batch: - batch_start_row = tasks_for_processing_batch[0]['row_num'] - batch_end_row = tasks_for_processing_batch[-1]['row_num'] - self.logger.debug(f"\n--- Starte FINALEN Branch-Evaluation Batch ({len(tasks_for_processing_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") - - results_list = [] - batch_error_count = 0 - - self.logger.debug(f" Evaluiere {len(tasks_for_processing_batch)} Zeilen parallel (max {MAX_BRANCH_WORKERS} worker, {OPENAI_CONCURRENCY_LIMIT} OpenAI gleichzeitig)...") - with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_BRANCH_WORKERS) as executor: - future_to_task = {executor.submit(self.evaluate_branch_task, task, openai_semaphore): task for task in tasks_for_processing_batch} - for future in concurrent.futures.as_completed(future_to_task): - task = future_to_task[future] - try: - result_data = future.result() - results_list.append(result_data) - if result_data.get('error'): batch_error_count += 1 - except Exception as exc: - row_num = task['row_num'] - err_msg = f"Generischer Fehler Branch Task Zeile {row_num}: {exc}" - self.logger.error(err_msg) - results_list.append({"row_num": row_num, "result": {"branch": "FEHLER", "consistency": "error_task", "justification": err_msg[:500]}, "error": err_msg}) - batch_error_count += 1 - - self.logger.debug(f" FINALER Branch Batch beendet. {len(results_list)} Ergebnisse erhalten ({batch_error_count} Fehler).") - - if results_list: - current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - current_version = getattr(Config, 'VERSION', 'unknown') - batch_sheet_updates = [] - results_list.sort(key=lambda x: x['row_num']) - for res_data in results_list: - row_num = res_data['row_num'] - result = res_data['result'] - batch_sheet_updates.append({'range': f'{branch_w_letter}{row_num}', 'values': [[result.get("branch", "FEHLER")]]}) - batch_sheet_updates.append({'range': f'{branch_x_letter}{row_num}', 'values': [[result.get("consistency", "error")]]}) - batch_sheet_updates.append({'range': f'{branch_y_letter}{row_num}', 'values': [[result.get("justification", "Keine Begründung")]]}) - batch_sheet_updates.append({'range': f'{ts_ao_letter}{row_num}', 'values': [[current_timestamp]]}) - batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) - # All updates are in batch_sheet_updates for the final batch - # Send them - if batch_sheet_updates: - self.logger.debug(f" Sende FINALES Sheet-Update für {len(results_list)} Zeilen ({len(batch_sheet_updates)} Zellen)...") - success = self.sheet_handler.batch_update_cells(batch_sheet_updates) - if success: - self.logger.info(f" FINALES Sheet-Update für Branch Batch erfolgreich.") - - - self.logger.info(f"Brancheneinschätzung (Parallel Batch) abgeschlossen. {processed_count} Zeilen verarbeitet (versucht), {skipped_count} Zeilen übersprungen.") - # Keine Pause nach diesem Modus nötig. - - - # --- Methode für den SerpAPI Wiki Search Batchmodus (AY) --- - # Übernommen aus process_find_wiki_with_serp in Teil 2, angepasst als Methode. - 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 (Spalte M = k.A.) über SerpAPI für Unternehmen mit - (Umsatz CRM > min_umsatz MIO € ODER Mitarbeiter CRM > min_employees) - UND wenn der SerpAPI Wiki Search Timestamp (AY) leer ist. - Trägt gefundene URLs in Spalte M ein. Setzt ReEval-Flag (A) - und löscht abhängige Wiki-Spalten (N-V, AN, AO, AP, AX). - Setzt Timestamp in Spalte AY, wann die Suche durchgeführt wurde (unabhängig vom Ergebnis). - - Args: - start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung). - 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 übersprungener) Zeilen. Defaults to None. - 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. - """ - self.logger.info(f"Starte Modus 'find_wiki_serp': Suche fehlende Wiki-URLs für Firmen mit (Umsatz CRM > {min_umsatz} MIO € ODER Mitarbeiter CRM > {min_employees}). Bereich: {start_sheet_row}-{end_sheet_row}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") - - # --- Daten laden --- - # 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...") - # Nutzt get_start_row_index des Sheet Handlers. Prüft auf leeren AY. - start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="SerpAPI Wiki Search Timestamp") - if start_data_index_no_header == -1: - self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche 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 AY Zelle): {start_sheet_row}") - else: - # Daten trotzdem neu laden, um aktuell zu sein - if not self.sheet_handler.load_data(): - self.logger.error("FEHLER beim Laden der Daten für process_find_wiki_serp.") - return - - all_data = self.sheet_handler.get_all_data_with_headers() - header_rows = self.sheet_handler._header_rows - total_sheet_rows = len(all_data) - - # Berechne Endzeile, wenn nicht gesetzt - if end_sheet_row is None: - end_sheet_row = total_sheet_rows # Bis zur letzten Zeile - - 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 = [ - "SerpAPI Wiki Search Timestamp", "Wiki URL", "CRM Umsatz", "CRM Anzahl Mitarbeiter", # Prüfkriterien / Timestamp - "ReEval Flag", "CRM Name", "CRM Website", # Daten für Suche / Updates - "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", # Spalten zum Leeren - "Chat Wiki Konsistenzprüfung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", # Spalten zum Leeren - "Begründung bei Abweichung", "Wikipedia Timestamp", "Timestamp letzte Prüfung", # Spalten zum Leeren - "Version", "Wiki Verif. Timestamp" # Spalten zum Leeren - ] - 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: Benötigte Spaltenschlüssel fehlen in COLUMN_MAP für process_find_wiki_serp: {missing}. Breche ab.") - return - - # Spaltenbuchstaben für Updates/Leerung - ay_letter = self.sheet_handler._get_col_letter(col_indices["SerpAPI Wiki Search Timestamp"] + 1) # Timestamp zu setzen - m_letter = self.sheet_handler._get_col_letter(col_indices["Wiki URL"] + 1) # Wiki URL Spalte - a_letter = self.sheet_handler._get_col_letter(col_indices["ReEval Flag"] + 1) # ReEval Flag - - # Spalten N-V leeren - n_idx = col_indices["Wiki Absatz"] - v_idx = col_indices["Begründung bei Abweichung"] - n_letter = self.sheet_handler._get_col_letter(n_idx + 1) - v_letter = self.sheet_handler._get_col_letter(v_idx + 1) - n_v_range_letter = f'{n_letter}:{v_letter}' - - # Timestamps AN, AO, AX, Version AP leeren - an_letter = self.sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"] + 1) - ao_letter = self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Prüfung"] + 1) - ap_letter = self.sheet_handler._get_col_letter(col_indices["Version"] + 1) - ax_letter = self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"] + 1) - - - # --- Verarbeitung --- - all_sheet_updates = [] # Gesammelte Updates für Batch-Schreiben - update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) # Nutze die Update-Batch-Größe aus Config - - processed_count = 0 # Zählt Zeilen, für die SerpAPI versucht wurde (zum Limit zählen) - skipped_count = 0 # Zählt Zeilen, die übersprungen wurden (verschiedene Gründe) - found_urls_count = 0 # Zählt Zeilen, wo eine URL gefunden und eingetragen wurde - - now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - - # Iteriere durch die Datenzeilen im definierten Bereich - 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 - if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht - - row = all_data[row_index_in_list] - - # Stellen Sie sicher, dass die Zeile nicht leer ist - if not any(cell and cell.strip() for cell in row): - #self.logger.debug(f"Zeile {i}: Übersprungen (Leere Zeile).") # Zu viel Lärm - skipped_count += 1 - continue - - - # --- Prüfung, ob Verarbeitung für diese Zeile nötig ist --- - # Kriterium: 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) - - ay_value = self._get_cell_value_safe(row, "SerpAPI Wiki Search Timestamp").strip() - m_value = self._get_cell_value_safe(row, "Wiki URL").strip() - umsatz_val_str = self._get_cell_value_safe(row, "CRM Umsatz") - ma_val_str = self._get_cell_value_safe(row, "CRM Anzahl Mitarbeiter") - - # Nutze die globale Hilfsfunktion, um die Werte für den Vergleich zu bekommen - umsatz_val_mio = get_numeric_filter_value(umsatz_val_str, is_umsatz=True) # Annahme: get_numeric_filter_value in utils.py - ma_val_num = get_numeric_filter_value(ma_val_str, is_umsatz=False) # Annahme: get_numeric_filter_value in utils.py - - is_m_empty_or_ka = not m_value or m_value.lower() == "k.a." - size_criteria_met = (umsatz_val_mio > min_umsatz) or (ma_val_num > min_employees) - processing_needed_for_row = not ay_value and is_m_empty_or_ka and size_criteria_met - - - # Loggen der Prüfergebnisse auf Debug - 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() - self.logger.debug(f"Zeile {i} ({company_name[:30]}... SerpAPI Wiki Search Check): AY leer? {not ay_value}, M leer/k.A.? {is_m_empty_or_ka}, Größe ({umsatz_val_mio:.1f} Mio, {ma_val_num} MA) Kriterium? {size_criteria_met}. Benötigt Verarbeitung? {processing_needed_for_row}") - - - if not processing_needed_for_row: - skipped_count += 1 - continue - - # --- Wenn Verarbeitung nötig: Führe SerpAPI Suche aus --- - processed_count += 1 # Zähle die Zeile, für die SerpAPI versucht wird (zum Limit zählen) - - # Prüfe das Limit für verarbeitete Zeilen - if limit is not None and processed_count > limit: - self.logger.info(f"Verarbeitungslimit ({limit}) für process_find_wiki_serp erreicht. Breche weitere Zeilenprüfung ab.") - break # Schleife abbrechen - - - # Hole Firmenname und Website für die Suche - company_name = self._get_cell_value_safe(row, "CRM Name").strip() - website_url = self._get_cell_value_safe(row, "CRM Website").strip() # Website kann für SerpAPI Kontext hilfreich sein - - if not company_name: - self.logger.warning(f"Zeile {i}: Übersprungen (kein Firmenname für Suche vorhanden).") - skipped_count += 1 # Zählen als übersprungen, da Suche nicht möglich - # Setze AY Timestamp auch hier, um nicht immer wieder zu versuchen - all_sheet_updates.append({'range': f'{ay_letter}{i}', 'values': [[now_timestamp_str]]}) - continue # Nächste Zeile - - - self.logger.info(f"Zeile {i}: Suche Wiki-URL für '{company_name}' (Umsatz (Mio): {umsatz_val_mio:.1f}, MA: {ma_val_num}) via SerpAPI...") - - # Führe die SerpAPI Suche durch (nutzt globale Funktion mit Retry) - try: - wiki_url_found = serp_wikipedia_lookup(company_name, website=website_url) # Annahme: serp_wikipedia_lookup in utils.py - except Exception as e_serp_wiki: - self.logger.error(f"FEHLER bei serp_wikipedia_lookup für Zeile {i} ('{company_name}'): {e_serp_wiki}") - wiki_url_found = None # Bei Fehler als nicht gefunden behandeln - pass # Fahren Sie fort, um Timestamp zu setzen und Updates vorzubereiten - - - # --- Updates vorbereiten --- - # Timestamp AY IMMER setzen, nachdem der Versuch gemacht wurde, unabhängig vom Ergebnis - all_sheet_updates.append({'range': f'{ay_letter}{i}', 'values': [[now_timestamp_str]]}) - - # Wenn eine URL gefunden wurde, bereite weitere Updates vor - if wiki_url_found and wiki_url_found.strip() and wiki_url_found.lower() != "k.a.": - self.logger.info(f" -> URL gefunden: {wiki_url_found}. Bereite Update vor (Setze M, A; Lösche N-V, AN, AO, AP, AX).") - found_urls_count += 1 - - # Setze M mit der gefundenen URL - all_sheet_updates.append({'range': f'{m_letter}{i}', 'values': [[wiki_url_found]]}) - # Setze ReEval Flag (A) auf 'x' - all_sheet_updates.append({'range': f'{a_letter}{i}', 'values': [['x']]}) - - # Leere Spalten N-V - empty_nv_values = [''] * (v_idx - n_idx + 1) # Anzahl der Spalten von N bis V - all_sheet_updates.append({'range': f'{n_letter}{i}:{v_letter}{i}', 'values': [empty_nv_values]}) - - # Leere Timestamps AN, AO, AX und Version AP - all_sheet_updates.append({'range': f'{an_letter}{i}', 'values': [['']]}) - all_sheet_updates.append({'range': f'{ao_letter}{i}', 'values': [['']]}) - all_sheet_updates.append({'range': f'{ap_letter}{i}', 'values': [['']]}) - all_sheet_updates.append({'range': f'{ax_letter}{i}', 'values': [['']]}) - - else: - self.logger.info(f" -> Keine Wiki-URL für '{company_name}' via SerpAPI gefunden.") - # Nur AY Timestamp wird gesetzt, was bereits oben passiert ist. - - - # Sende gesammelte Sheet Updates, wenn das Update-Batch-Limit erreicht ist - # Die Anzahl der Updates pro Zeile, für die eine URL gefunden wurde, ist hoch (1+1+1+1+1+1+1 + (V-N+1)), ca. 10-15. - # Wenn keine URL gefunden, sind es nur 1 (AY). - # Wir prüfen einfach die Länge der gesammelten Liste. - if len(all_sheet_updates) >= update_batch_row_limit * (10): # Grobe Schätzung pro Zeile - self.logger.debug(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") - # Nutzt die batch_update_cells Methode des Sheet Handlers mit Retry - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: - self.logger.info(f" Sheet-Update für {len(all_sheet_updates)} Zellen erfolgreich.") - # Der Fehlerfall wird von batch_update_cells geloggt - - # Leere die gesammelten Updates nach dem Senden - all_sheet_updates = [] - - # Kleiner Sleep nach jeder SerpAPI-Suche (nutzt Config) - # Der Decorator kümmert sich um Retries mit Backoff, dies ist eine globale Rate-Limit-Vorsorge. - serp_delay = getattr(Config, 'SERPAPI_DELAY', 1.5) - #self.logger.debug(f"Warte {serp_delay:.2f}s nach SerpAPI Suche...") # Zu viel Lärm - time.sleep(serp_delay) - - - # --- Finale Sheet Updates senden --- - # Sende alle verbleibenden gesammelten Updates - if all_sheet_updates: - self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: - self.logger.info(f"FINALES Sheet-Update erfolgreich.") - # Der Fehlerfall wird von batch_update_cells geloggt - - self.logger.info(f"Modus 'find_wiki_serp' abgeschlossen. {processed_count} Zeilen verarbeitet (versucht), {found_urls_count} URLs gefunden & eingetragen, {skipped_count} Zeilen übersprungen.") - # Keine Pause nach diesem Modus nötig. - - - # --- Methode für den Contact Search Batchmodus (AM, AI-AL) --- - # Übernommen aus process_contact_research in Teil 10, angepasst als Methode. - def process_contact_search(self, start_sheet_row=None, end_sheet_row=None, limit=None): - """ - Sucht LinkedIn Kontakte via SERP API für Zeilen, bei denen der - Contact Search Timestamp (AM) leer ist. Trägt 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). - 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 übersprungener) Zeilen. Defaults to None. - """ - self.logger.info(f"Starte Contact Research (LinkedIn via SerpAPI). Bereich: {start_sheet_row}-{end_sheet_row}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") - - # --- Daten laden --- - # 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...") - # Nutzt get_start_row_index des Sheet Handlers. Prüft auf leeren AM. - start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Contact Search Timestamp") - if start_data_index_no_header == -1: - self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche 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 AM Zelle): {start_sheet_row}") - else: - # Daten trotzdem neu laden, um aktuell zu sein - if not self.sheet_handler.load_data(): - self.logger.error("FEHLER beim Laden der Daten für process_contact_search.") - return - - all_data = self.sheet_handler.get_all_data_with_headers() - header_rows = self.sheet_handler._header_rows - total_sheet_rows = len(all_data) - - # Berechne Endzeile, wenn nicht gesetzt - if end_sheet_row is None: - end_sheet_row = total_sheet_rows # Bis zur letzten Zeile - - 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 = [ - "Contact Search Timestamp", # AM - Prüfkriterium / Timestamp - "CRM Name", "CRM Kurzform", "CRM Website", # Daten für Suche - "Linked Serviceleiter gefunden", "Linked It-Leiter gefunden", # Zielspalten für Trefferzahlen - "Linked Management gefunden", "Linked Disponent gefunden" # Zielspalten für Trefferzahlen - ] - 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: Benötigte Spaltenschlüssel fehlen in COLUMN_MAP für process_contact_search: {missing}. Breche ab.") - return - - # Spaltenbuchstaben für Updates (Trefferzahlen AI-AL und Timestamp AM) - ts_am_letter = self.sheet_handler._get_col_letter(col_indices["Contact Search Timestamp"] + 1) - ai_letter = self.sheet_handler._get_col_letter(col_indices["Linked Serviceleiter gefunden"] + 1) - aj_letter = self.sheet_handler._get_col_letter(col_indices["Linked It-Leiter gefunden"] + 1) - ak_letter = self.sheet_handler._get_col_letter(col_indices["Linked Management gefunden"] + 1) - al_letter = self.sheet_handler._get_col_letter(col_indices["Linked Disponent gefunden"] + 1) - - - # Positionen, nach denen gesucht wird (kann in Config verschoben werden) - # Die Zuordnung zur Zählspalte (AI-AL) muss hier im Code erfolgen. - positions_to_search = { - "Serviceleiter": ["Serviceleiter", "Leiter Kundendienst", "Einsatzleiter"], - "IT-Leiter": ["IT-Leiter", "Leiter IT"], - "Management": ["Geschäftsführer", "Vorstand", "Inhaber", "CEO", "CTO", "COO"], # Management erweitert - "Disponent": ["Disponent"] - } - # Stellen Sie sicher, dass die Schlüssel im Dict den COLUMN_MAP Keys entsprechen - - # Kontakte-Blatt öffnen oder erstellen (wird einmalig gemacht) - contacts_sheet = None - try: - # Versuche, das Sheet "Contacts" zu öffnen - contacts_sheet = self.sheet_handler.sheet.spreadsheet.worksheet("Contacts") - self.logger.info("Blatt 'Contacts' gefunden.") - except gspread.exceptions.WorksheetNotFound: - # Wenn nicht gefunden, erstelle es - self.logger.info("Blatt 'Contacts' nicht gefunden, erstelle neu...") - try: - # TODO: Richtige Reihenfolge und Namen für Contacts Sheet Header definieren - contacts_header = ["Firmenname", "CRM Kurzform", "Website", "Geschlecht", "Vorname", "Nachname", "Position", "Suchbegriffskategorie", "E-Mail-Adresse", "LinkedIn-Link", "Timestamp"] - # Geschätzte Anzahl Zeilen/Spalten für neues Blatt (kann angepasst werden) - contacts_sheet = self.sheet_handler.sheet.spreadsheet.add_worksheet(title="Contacts", rows="5000", cols=len(contacts_header)) - # Schreibe Header in die erste Zeile - contacts_sheet.update(values=[contacts_header], range_name=f"A1:{self.sheet_handler._get_col_letter(len(contacts_header))}1") - self.logger.info("Neues Blatt 'Contacts' erstellt und Header eingetragen.") - except Exception as e_create_sheet: - self.logger.critical(f"FEHLER: Konnte Blatt 'Contacts' nicht erstellen: {e_create_sheet}. Kontakt-Details können nicht gespeichert werden.") - contacts_sheet = None # Setze contacts_sheet auf None, um Schreibversuche zu verhindern - - - # --- Verarbeitung --- - all_sheet_updates = [] # Gesammelte Updates für Batch-Schreiben ins Hauptblatt - all_contact_rows_to_append = [] # Gesammelte Zeilen für append_rows ins Contacts-Blatt - # append_rows kann große Batches handhaben, wir können hier mehr sammeln als beim Batch-Update. - # Oder wir schreiben pro Firma in das Contacts-Blatt (weniger sammelbar). - # Fürs Erste sammeln wir pro Firma und schreiben dann. - - processed_count = 0 # Zählt Zeilen im Hauptblatt, die verarbeitet (versucht) wurden - skipped_count = 0 # Zählt Zeilen, die übersprungen wurden (wegen AM oder fehlender Daten) - - now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - - # Iteriere durch die Datenzeilen im definierten Bereich - for i in range(start_sheet_row, end_sheet_row + 1): - row_index_in_list = i - 1 # 0-based index in the all_data list - if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht - - row = all_data[row_index_in_list] - - # Stellen Sie sicher, dass die Zeile nicht leer ist - if not any(cell and cell.strip() for cell in row): - #self.logger.debug(f"Zeile {i}: Übersprungen (Leere Zeile).") # Zu viel Lärm - skipped_count += 1 - continue - - # --- Prüfung, ob Verarbeitung für diese Zeile nötig ist --- - # Kriterium: Contact Search Timestamp (AM) ist leer. - # ZUSÄTZLICH: Prüfen, ob CRM Name, Kurzform und Website vorhanden sind. - - am_value = self._get_cell_value_safe(row, "Contact Search Timestamp").strip() - processing_needed_based_on_status = not am_value - - # Hole Daten für Suche - company_name = self._get_cell_value_safe(row, "CRM Name").strip() - crm_kurzform = self._get_cell_value_safe(row, "CRM Kurzform").strip() - website = self._get_cell_value_safe(row, "CRM Website").strip() - - # Prüfen Sie, ob die Mindestdaten für die Suche vorhanden sind - has_min_data_for_search = company_name and crm_kurzform and website and website.lower() != "k.a." - - processing_needed_for_row = processing_needed_based_on_status and has_min_data_for_search - - - # Loggen der Prüfergebnisse auf Debug - 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[:30]}... Contact Check): AM leer? {not am_value}, Mindestdaten? {has_min_data_for_search}. Benötigt Verarbeitung? {processing_needed_for_row}") - - - if not processing_needed_for_row: - skipped_count += 1 - continue - - # --- Wenn Verarbeitung nötig: Führe LinkedIn Suche(n) aus --- - processed_count += 1 # Zähle die Zeile, für die Suche versucht wird (zum Limit zählen) - - # Prüfe das Limit für verarbeitete Zeilen - if limit is not None and processed_count > limit: - self.logger.info(f"Verarbeitungslimit ({limit}) für process_contact_search erreicht. Breche weitere Zeilenprüfung ab.") - break # Schleife abbrechen - - self.logger.info(f"Zeile {i}: Suche LinkedIn Kontakte für '{crm_kurzform}' ({website})...") - - all_found_contacts_for_row = [] # Alle Kontakte, die für diese EINE Zeile gefunden werden - contact_counts_for_row = {key: 0 for key in positions_to_search.keys()} # Zähler für diese Zeile - - # Führe die Suche für jede Positionskategorie durch - for category, queries in positions_to_search.items(): - # Führe die Suche für jede spezifische Abfrage innerhalb der Kategorie durch - # Suchergebnisse deduplizieren (kann ein Kontakt unter mehreren Positionen auftauchen) - found_contacts_in_category = {} # {linkedin_url: contact_data} - - for position_query in queries: - self.logger.debug(f" -> Suche nach: '{position_query}' bei '{crm_kurzform}'...") - try: - # search_linkedin_contacts nutzt den retry_on_failure Decorator und SerpAPI. - # Es gibt eine Liste von Kontakt-Dicts zurück. - # Wir limitieren die Anzahl der SerpAPI Ergebnisse pro Suche. - contacts_from_query = search_linkedin_contacts( - company_name=company_name, - website=website, # Kann ggf. als Kontext im Prompt helfen (nicht in search_linkedin_contacts genutzt, aber könnte) - position_query=position_query, - crm_kurzform=crm_kurzform, - num_results=getattr(Config, 'SERPAPI_LINKEDIN_RESULTS_PER_QUERY', 5) # Konfigurierbar - ) - - # Zähle Treffer für diese Kategorie (einfache Zählung hier) - # contact_counts_for_row[category] += len(contacts_from_query) # Nicht hier zählen, sondern nach Deduplizierung! - - # Füge gefundene Kontakte (mit Suchkategorie) zur Liste für diese Zeile hinzu, dedupliziert - for contact in contacts_from_query: - linkedin_url = contact.get("LinkedInURL") - if linkedin_url: - if linkedin_url not in found_contacts_in_category: - # Ersten Fund mit dieser URL hinzufügen - contact["Suchbegriffskategorie"] = category # Speichere die Kategorie, die den Treffer brachte - found_contacts_in_category[linkedin_url] = contact - else: - # Wenn der Kontakt schon gefunden wurde (andere Kategorie), füge die neue Kategorie hinzu (optional) - # Oder behalte einfach die erste Kategorie. Behalten wir die erste. - pass - # self.logger.debug(f" -> Gefunden: {contact.get('Vorname')} {contact.get('Nachname')} ({contact.get('Position')})") - - - except Exception as e_linkedin_search: - self.logger.error(f"FEHLER bei search_linkedin_contacts für Zeile {i} (Query: '{position_query}'): {e_linkedin_search}") - pass # Fahren Sie fort mit der nächsten Query - - # Pause nach jeder SerpAPI Suche (pro Position_query) - serp_delay = getattr(Config, 'SERPAPI_DELAY', 1.5) - #self.logger.debug(f"Warte {serp_delay:.2f}s nach LinkedIn Suche für '{position_query}'...") # Zu viel Lärm - time.sleep(serp_delay) - - # Zähle die eindeutigen Treffer in dieser Kategorie nach allen Queries - contact_counts_for_row[category] = len(found_contacts_in_category) - # Füge die eindeutigen Kontakte dieser Kategorie zur Gesamtliste für die Zeile hinzu - all_found_contacts_for_row.extend(found_contacts_in_category.values()) - - - # --- Verarbeite gefundene Kontakte und bereite Updates vor --- - rows_to_append_to_contacts_sheet = [] # Zeilen für das 'Contacts' Blatt - main_sheet_updates_for_row = [] # Updates für das Hauptblatt (AI-AL, AM) - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Timestamp für diese Zeile/Kontakte - - # Fügen Sie die Updates für die Trefferzahlen im Hauptblatt hinzu - # Stellen Sie sicher, dass die Spaltenbuchstaben korrekt sind (AI, AJ, AK, AL) - 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) - main_sheet_updates_for_row.append({'range': f'{ts_am_letter}{i}', 'values': [[timestamp]]}) - - # Sammeln Sie diese Updates für das Hauptblatt - all_sheet_updates.extend(main_sheet_updates_for_row) - self.logger.info(f"Zeile {i}: Kontaktzahlen gesammelt: {contact_counts_for_row} – Timestamp AM vorgemerkt.") - - # Bereiten Sie die Zeilen für das 'Contacts' Blatt vor (falls es existiert) - if contacts_sheet: - unique_contacts_for_row = {c['LinkedInURL']: c for c in all_found_contacts_for_row}.values() # Endgültige Deduplizierung über alle Kategorien - - for contact in unique_contacts_for_row: - firstname = contact.get("Vorname", "") - lastname = contact.get("Nachname", "") - - # Nutzt globale Funktionen get_gender und get_email_address (utils.py) - gender_value = get_gender(firstname) - email = get_email_address(firstname, lastname, website) # Nutzt die Website der Firma - - contact_row = [ - contact.get("Firmenname", ""), - contact.get("CRM Kurzform", ""), - contact.get("Website", ""), - gender_value, - firstname, - lastname, - contact.get("Position", ""), - contact.get("Suchbegriffskategorie", ""), # Welche Kategorie brachte den Treffer - email, - contact.get("LinkedInURL", ""), - timestamp # Wann der Kontakt gefunden wurde - ] - rows_to_append_to_contacts_sheet.append(contact_row) - - if rows_to_append_to_contacts_sheet: - # Fügen Sie diese Zeilen zur globalen Liste der Kontakte hinzu, die später angefügt 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 für Zeile {i} zum Anfügen vorgemerkt.") - else: - self.logger.debug(f" -> Keine neuen Kontakte für Zeile {i} gefunden.") - - - # Sende gesammelte Sheet Updates (Hauptblatt) wenn das Update-Batch-Limit erreicht ist - # Updates pro Zeile sind 5 (AI-AL + AM). 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)...") - # Nutzt die batch_update_cells Methode des Sheet Handlers mit Retry - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: - self.logger.info(f" Hauptblatt-Update für {rows_in_main_sheet_update_batch} Zeilen erfolgreich.") - # Der Fehlerfall wird von batch_update_cells geloggt - - # Leere die gesammelten Updates nach dem Senden - all_sheet_updates = [] - - # Eine längere Pause nach der Verarbeitung jeder Firma im Contact Search Modus - # Dieser Modus ist API-intensiv und sollte langsamer laufen. - pause_duration = getattr(Config, 'RETRY_DELAY', 10) * 0.8 # Längere Pause, z.B. 80% der Retry-Wartezeit - self.logger.debug(f"Warte {pause_duration:.2f}s nach Verarbeitung von Zeile {i}...") - time.sleep(pause_duration) - - - # --- Finale Sheet Updates (Hauptblatt) senden --- - 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)...") - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: - self.logger.info(f"FINALES Hauptblatt-Update erfolgreich.") - # Der Fehlerfall wird von batch_update_cells geloggt - - - # --- Finale Kontakte-Zeilen (Contacts Sheet) anfügen --- - if contacts_sheet and all_contact_rows_to_append: - self.logger.info(f"Füge {len(all_contact_rows_to_append)} gesammelte Kontaktzeilen an Blatt 'Contacts' an...") - try: - # append_rows ist effizienter als batch_update für viele neue Zeilen am Ende - # Nutzt den retry_on_failure Decorator indirekt, wenn sheet.append_rows ihn hat, - # oder wir könnten hier manuell retry hinzufügen. - # Gspread's append_rows wirft bei Fehlern Exceptions, die vom globalen Decorator - # (falls er die Methode umhüllt) oder hier manuell behandelt werden müssten. - # Lassen wir es erstmal so, dass es Exceptions wirft, die die main-Funktion fängt. - contacts_sheet.append_rows(all_contact_rows_to_append, value_input_option='USER_ENTERED') - self.logger.info(f"Anfügen von {len(all_contact_rows_to_append)} Kontaktzeilen erfolgreich.") - except Exception as e_append: - self.logger.error(f"FEHLER beim Anfügen von Kontaktzeilen an Blatt 'Contacts': {e_append}") - pass # Fahren Sie fort, der Rest des Skripts sollte nicht blockiert werden - - - self.logger.info(f"Modus 'contact_search' abgeschlossen. {processed_count} Zeilen verarbeitet (versucht), {skipped_count} Zeilen übersprungen.") - # Keine Pause nach diesem Modus nötig. - - # --- Die nächsten Utility Methoden der DataProcessor Klasse folgen in den nächsten Teilen --- - # prepare_data_for_modeling method... (kommt in Teil 15) - # train_technician_model method... (kommt in Teil 15) - # process_website_details method... (kommt in Teil 16) # Optional/Experimentell - # process_wiki_updates_from_chatgpt method... (kommt in Teil 16) - # process_wiki_reextract_missing_an method... (kommt in Teil 16) - # ========================================================================== - # === Utility Methods (ML Data Prep & Training) ============================ - # ========================================================================== - - # Diese Methode wird in _process_single_row aufgerufen, wenn der ML-Schritt angefordert ist. - def _predict_technician_bucket(self, row_data): - """ - Führt eine Vorhersage des Servicetechniker-Buckets für eine einzelne Zeile - mit dem trainierten ML-Modell durch. Lädt das Modell und den Imputer bei - Bedarf. - - Args: - row_data (list): Die Rohdaten für die Zeile. - - Returns: - str: Der vorhergesagte Bucket-Label oder None bei Fehler/kein Ergebnis. - """ - self.logger.debug(f"Versuche ML-Schätzung für Zeile {self._get_cell_value_safe(row_data, 'CRM Name')[:30]}...") - - # Laden Sie das Modell und den Imputer, falls noch nicht geschehen - # Dies stellt sicher, dass sie nur einmal geladen werden - if self.model is None or self.imputer is None: - self.logger.info("Lade ML-Modell und Imputer...") - try: - self._load_ml_model(MODEL_FILE, IMPUTER_FILE) # Nutzt interne Lademethode - if self.model is None or self.imputer is None: - self.logger.error("Laden von Modell oder Imputer fehlgeschlagen.") - return None # Abbruch, wenn Laden fehlschlägt - self.logger.info("ML-Modell und Imputer erfolgreich geladen.") - except Exception as e: - self.logger.error(f"FEHLER beim Laden von ML-Modell/Imputer: {e}") - return None # Abbruch bei Ladefehler - - - # Bereiten Sie die Daten für DIESE EINE ZEILE für die Vorhersage vor - try: - # Diese Logik ist ähnlich wie in prepare_data_for_modeling, aber nur für eine Zeile - # und muss mit den exakt gleichen Spalten und Encodings arbeiten wie das Training. - - # Hole die benötigten Spaltenwerte für diese Zeile (basierend auf COLUMN_MAP keys) - row_values = { - "CRM Name": self._get_cell_value_safe(row_data, "CRM Name"), - "CRM Branche": self._get_cell_value_safe(row_data, "CRM Branche"), - "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"), - # Technikerzahl wird für die Vorhersage NICHT benötigt, nur für Training - # "CRM Anzahl Techniker": self._get_cell_value_safe(row_data, "CRM Anzahl Techniker"), - } - - # Erstelle einen temporären DataFrame für diese eine Zeile - df_single_row = pd.DataFrame([row_values]) - - # --- Konsolidieren Umsatz/Mitarbeiter (Wiki > CRM) --- - def get_valid_numeric_for_pred(value_str): - # Vereinfachte numerische Extraktion für Vorhersage - # Muss konsistent mit prepare_data_for_modeling sein! - if value_str is None or pd.isna(value_str) or str(value_str).strip() == '': return np.nan - try: - # Nutzt die Logik aus extract_numeric_value / get_valid_numeric - raw_value_str = str(value_str).strip() - processed_value = clean_text(raw_value_str) - if processed_value == "k.A.": return np.nan - - processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|über|unter|mehr als|weniger als|bis zu)\s+', '', processed_value) - processed_value = re.sub(r'[€$£¥]', '', processed_value).strip() - processed_value = re.split(r'\s*(-|–|bis)\s*', processed_value, 1)[0].strip() - processed_value_no_thousands = processed_value.replace('.', '').replace("'", "") - processed_value_final = processed_value_no_thousands.replace(',', '.') - - match = re.search(r'([\d.]+)', processed_value_final) - if not match: return np.nan - - num_str = match.group(1) - if not num_str or num_str == '.': return np.nan - num = float(num_str) - - original_lower = raw_value_str.lower() - multiplier = 1.0 - if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower): multiplier = 1000000000.0 - elif re.search(r'\bmio\s*\b|\bmillionen\s*\b|\bmill.\s*\b', original_lower): multiplier = 1000000.0 - elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): multiplier = 1000.0 - num = num * multiplier - - return num if num > 0 else np.nan # Nur positive Werte - - except Exception as e: - # logger.debug(f"Fehler in get_valid_numeric_for_pred für Wert '{str(value_str)[:50]}...': {e}") # Zu viel Lärm - return np.nan - - - df_single_row['Finaler_Umsatz'] = np.where( - df_single_row['Wiki Umsatz'].apply(get_valid_numeric_for_pred).notna(), - df_single_row['Wiki Umsatz'].apply(get_valid_numeric_for_pred), - df_single_row['CRM Umsatz'].apply(get_valid_numeric_for_pred) - ) - - df_single_row['Finaler_Mitarbeiter'] = np.where( - df_single_row['Wiki Mitarbeiter'].apply(get_valid_numeric_for_pred).notna(), - df_single_row['Wiki Mitarbeiter'].apply(get_valid_numeric_for_pred), - df_single_row['CRM Anzahl Mitarbeiter'].apply(get_valid_numeric_for_pred) - ) - - # --- Kategoriale Features (Branche) --- - branche_col_name = "CRM Branche" # Original Header Name - # Stelle sicher, dass die Spalte String ist und fülle evtl. NaNs - df_single_row[branche_col_name] = df_single_row[branche_col_name].astype(str).fillna('Unbekannt').str.strip() - - # One-Hot Encoding - # WICHTIG: Muss alle BRANCHEN aus dem TRAININGSDATENSATZ enthalten, - # auch wenn diese in der einzelnen Zeile nicht vorkommen. - # Dummy-Spalten für fehlende Branchen müssen hinzugefügt werden! - # load_ml_model muss auch die Liste der Feature-Spalten speichern (inkl. aller Branche Dummies). - # Laden Sie die Liste der erwarteten Features (z.B. aus einer separaten Datei oder dem Imputer/Modell-Artefakt) - if not hasattr(self, '_expected_features') or self._expected_features is None: - self.logger.error("FEHLER: Erwartete Feature-Spalten für ML-Vorhersage nicht geladen.") - return None - - # Führen Sie One-Hot Encoding durch - df_encoded = pd.get_dummies(df_single_row, columns=[branche_col_name], prefix='Branche', dummy_na=False) - - # Fügen Sie fehlende Feature-Spalten hinzu und stellen Sie die Reihenfolge sicher - # Fehlende Spalten werden mit 0 gefüllt - missing_cols = set(self._expected_features) - set(df_encoded.columns) - for c in missing_cols: - df_encoded[c] = 0 - - # Stellen Sie sicher, dass die Spalten in der richtigen Reihenfolge sind (wie im Training) - df_processed = df_encoded[self._expected_features] - - - # --- Imputation der fehlenden Werte --- - # Muss konsistent mit dem Imputer aus dem Training sein - df_imputed = self.imputer.transform(df_processed) - df_imputed = pd.DataFrame(df_imputed, columns=self._expected_features) # Ergebnisse sind ein Numpy Array, konvertiere zurück zu DataFrame - - - # --- Vorhersage --- - # Das Decision Tree Modell erwartet die vorbereiteten und imputierten Features - if not self.model: - self.logger.error("FEHLER: ML-Modell ist nicht geladen.") - return None - - prediction_proba = self.model.predict_proba(df_imputed) - # prediction_proba ist ein Array von Wahrscheinlichkeiten für jede Klasse - # Wir nehmen die Klasse mit der höchsten Wahrscheinlichkeit - predicted_class_index = np.argmax(prediction_proba[0]) - predicted_bucket_label = self.model.classes_[predicted_class_index] # Holt das Label aus dem Modell - - self.logger.debug(f" -> ML Vorhersage: {predicted_bucket_label} (Wahrscheinlichkeiten: {prediction_proba[0]})") - - return predicted_bucket_label # Gibt das Label zurück - - except Exception as e: - self.logger.exception(f"FEHLER bei der Datenvorbereitung/Vorhersage für Zeile (ML): {e}") - return "FEHLER Schätzung" # Signalisiert Fehler - - - def _load_ml_model(self, model_path, imputer_path): - """Lädt das trainierte ML-Modell und den Imputer von der Festplatte.""" - self.model = None - self.imputer = None - self._expected_features = None # Speicherliste der Feature-Spalten - - try: - if not os.path.exists(model_path): - self.logger.error(f"ML-Modell Datei nicht gefunden: {model_path}") - return - if not os.path.exists(imputer_path): - self.logger.error(f"Imputer Datei nicht gefunden: {imputer_path}") - return - # Liste der Feature-Spalten sollte idealerweise auch gespeichert werden! - expected_features_path = PATTERNS_FILE_JSON # Annahme: JSON enthält die Spaltenliste - - with open(model_path, 'rb') as f: - self.model = pickle.load(f) - self.logger.info(f"ML-Modell '{model_path}' erfolgreich geladen.") - # Modell-Klassen loggen zur Info - self.logger.debug(f"Geladene Modell-Klassen: {self.model.classes_}") - - - with open(imputer_path, 'rb') as f: - self.imputer = pickle.load(f) - self.logger.info(f"Imputer '{imputer_path}' erfolgreich geladen.") - - if os.path.exists(expected_features_path): - with open(expected_features_path, 'r', encoding='utf-8') as f: - data = json.load(f) - # Annahme: Die JSON-Datei enthält eine Liste der Feature-Spalten unter dem Key "feature_columns" - self._expected_features = data.get("feature_columns") - if self._expected_features and isinstance(self._expected_features, list): - self.logger.info(f"Erwartete Feature-Spalten ({len(self._expected_features)}) aus '{expected_features_path}' geladen.") - # self.logger.debug(f"Erwartete Features (erste 5): {self._expected_features[:5]}...") # Zu viel Lärm - else: - self.logger.error(f"Formatfehler in '{expected_features_path}' oder Key 'feature_columns' fehlt.") - self._expected_features = None # Setze auf None bei Fehler - - else: - self.logger.warning(f"Datei mit erwarteten Feature-Spalten '{expected_features_path}' nicht gefunden. ML-Vorhersage könnte fehlschlagen.") - self._expected_features = None # Nicht gefunden - - # Wenn expected_features nicht geladen werden konnte, versuchen Sie es aus Imputer/Modell zu extrahieren (wenn möglich) - if self._expected_features is None: - try: - # Scikit-learn Imputer/Model haben oft feature_names_in_ - if hasattr(self.imputer, 'feature_names_in_') and self.imputer.feature_names_in_ is not None: - self._expected_features = list(self.imputer.feature_names_in_) - self.logger.info(f"Erwartete Feature-Spalten ({len(self._expected_features)}) aus Imputer geladen.") - elif hasattr(self.model, 'feature_names_in_') and self.model.feature_names_in_ is not None: - self._expected_features = list(self.model.feature_names_in_) - self.logger.info(f"Erwartete Feature-Spalten ({len(self._expected_features)}) aus Modell geladen.") - else: - self.logger.error("Konnte erwartete Feature-Spalten weder aus Datei noch aus Modell/Imputer extrahieren. ML-Vorhersage wird fehlschlagen.") - self._expected_features = None - except Exception as e_extract: - self.logger.error(f"FEHLER beim Extrahieren der Feature-Namen aus Modell/Imputer: {e_extract}") - self._expected_features = None - - - except Exception as e: - self.logger.exception(f"FEHLER beim Laden von ML-Artefakten: {e}") - self.model = None # Sicherstellen, dass Attribute None sind - self.imputer = None - self._expected_features = None - - - # Methode zur Datenvorbereitung für ML (WIRD VON train_technician_model aufgerufen) - # Übernommen aus prepare_data_for_modeling in Teil 12/13, angepasst als Methode. - def prepare_data_for_modeling(self): - """ - Lädt Daten aus dem Google Sheet über den sheet_handler, - bereitet sie für das Decision Tree Modell vor: - - Wählt relevante Spalten aus und benennt sie um. - - Konsolidiert Umsatz/Mitarbeiter (Wiki > CRM Priorität). - - Filtert nach gültiger Technikerzahl (> 0). - - Erstellt die Zielvariable (Techniker-Bucket). - - Bereitet Features auf (One-Hot Encoding für Branche). - - Behält NaNs in numerischen Features für spätere Imputation. - - Returns: - pandas.DataFrame: Vorbereiteter DataFrame für Training/Test-Split, - oder None bei Fehlern. - """ - self.logger.info("Starte Datenvorbereitung für Modellierung (Training)...") - # Nutze den self.sheet_handler der Klasse - if not self.sheet_handler or not self.sheet_handler.sheet_values: - self.logger.error("Fehler: Sheet Handler nicht initialisiert oder keine Daten geladen für prepare_data_for_modeling.") - # Versuche die Daten einmalig innerhalb dieser Methode zu laden, falls sie fehlen - if not self.sheet_handler.load_data(): - self.logger.critical("Konnte Daten auch nach erneutem Versuch nicht laden. Abbruch der Datenvorbereitung.") - return None - - all_data = self.sheet_handler.get_all_data_with_headers() # Nutze die im Handler geladenen Daten - header_rows = self.sheet_handler._header_rows - # Prüfe auf ausreichende Zeilenzahl (Header + mindestens eine Datenzeile) - min_required_rows = header_rows + 1 - if not all_data or len(all_data) < min_required_rows: - self.logger.error(f"Fehler: Nicht genügend Datenzeilen ({len(all_data)}) im Sheet gefunden für Modellierung (mindestens {min_required_rows} benötigt).") - return None - - try: - # Die erste Zeile sollte die Spaltennamen enthalten - headers = all_data[0] - # Stelle sicher, dass die Header-Zeile auch die erwartete Mindestlänge hat - try: - max_col_idx_in_map = max(COLUMN_MAP.values()) - if len(headers) <= max_col_idx_in_map: - self.logger.critical(f"FEHLER: Header-Zeile ({len(headers)} Spalten) ist kürzer als der höchste Index in COLUMN_MAP ({max_col_idx_in_map}). COLUMN_MAP passt nicht zum Sheet.") - return None - except ValueError: # Tritt auf, wenn COLUMN_MAP leer ist - self.logger.critical("FEHLER: COLUMN_MAP scheint leer zu sein. Kann Max Index nicht ermitteln.") - return None - except Exception as e: - self.logger.critical(f"FEHLER beim Prüfen der Spaltenlänge der Header-Zeile: {e}") - return None - - except IndexError: - self.logger.critical("FEHLER: Sheet scheint leer zu sein oder hat keine erste Zeile, keine Header gefunden.") - return None - except Exception as e: - self.logger.critical(f"FEHLER beim Zugriff auf Header: {e}") - return None - - - data_rows = all_data[header_rows:] # Annahme: Die ersten X Zeilen sind Header - - # Erstelle DataFrame - df = pd.DataFrame(data_rows, columns=headers) - self.logger.info(f"Initialen DataFrame für Modellierung erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.") - - # --- Spaltenauswahl und Umbenennung --- - # Definiere die notwendigen Spalten anhand ihrer COLUMN_MAP Schlüssel - # und weisen ihnen interne, einfachere Namen zu. - col_keys_mapping = { - "name": "CRM Name", # Zur Identifikation, wird später entfernt - "branche_crm": "CRM Branche", # Für One-Hot Encoding - "umsatz_crm": "CRM Umsatz", # Für Konsolidierung - "umsatz_wiki": "Wiki Umsatz", # Für Konsolidierung - "ma_crm": "CRM Anzahl Mitarbeiter", # Für Konsolidierung - "ma_wiki": "Wiki Mitarbeiter", # Für Konsolidierung - "techniker": "CRM Anzahl Techniker" # DIE ZIELVARIABLE (Bekannte Technikerzahl) - } - - # Überprüfe, ob alle benötigten Spalten in der COLUMN_MAP vorhanden sind - missing_keys_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 benötigte Spalten-Schlüssel fehlen in COLUMN_MAP für prepare_data_for_modeling: {missing_keys_in_map}.") - return None - - # Erstelle das Mapping von tatsächlichen Header-Namen zu internen Schlüsseln - # Verwende die Header-Namen aus dem geladenen Sheet und die COLUMN_MAP, um die richtigen Header zu finden - header_to_internal_key = {} - cols_to_select_by_header = [] # Liste der Header-Namen, die aus dem DF ausgewählt werden - - try: - for internal_key, column_map_key in col_keys_mapping.items(): - header_name_from_sheet = headers[COLUMN_MAP[column_map_key]] - header_to_internal_key[header_name_from_sheet] = internal_key - cols_to_select_by_header.append(header_name_from_sheet) - - # Wähle nur die benötigten Spalten im DataFrame aus - df_subset = df[cols_to_select_by_header].copy() # Kopie erstellen - # Benenne die Spalten um - df_subset.rename(columns=header_to_internal_key, inplace=True) - - except KeyError as e: - # Dieser Fehler sollte eigentlich durch die obige Prüfung 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 Auswählen/Umbenennen der Spalten (KeyError: '{e}'). Der Header wurde nicht im DataFrame gefunden.") - self.logger.debug(f"Erwartete Header: {cols_to_select_by_header}. Verfügbare Header im DF: {list(df.columns)}") - return None - except IndexError as e: - # Tritt auf, wenn COLUMN_MAP einen Index > Anzahl Spalten im DF hat - self.logger.critical(f"FEHLER beim Auswählen/Umbenennen der Spalten (IndexError: '{e}'). COLUMN_MAP zeigt auf Spalten, die nicht im geladenen Sheet existieren.") - self.logger.debug(f"COLUMN_MAP: {COLUMN_MAP}. Sheet hat {len(headers)} Spalten.") - return None - except Exception as e: - self.logger.critical(f"Unerwarteter FEHLER beim Auswählen/Umbenennen der Spalten: {e}") - self.logger.debug(traceback.format_exc()) - return None - - - self.logger.info(f"Benötigte Spalten für Modellierung ausgewählt und umbenannt: {list(df_subset.columns)}") - - # --- Features konsolidieren (Umsatz, Mitarbeiter) --- - # Nutzt die globale Hilfsfunktion get_valid_numeric, die numerische Werte als Float/Int oder NaN zurückgibt - cols_to_process = { - 'Umsatz': ('umsatz_wiki', 'umsatz_crm', 'Finaler_Umsatz'), - 'Mitarbeiter': ('ma_wiki', 'ma_crm', 'Finaler_Mitarbeiter') - } - - for base_name, (wiki_col, crm_col, final_col) in cols_to_process.items(): - self.logger.debug(f"Verarbeite und konsolidiere '{base_name}' (Priorität: Wiki > CRM)...") - # Sicherstellen, dass die Spalten im df_subset existieren, bevor apply aufgerufen wird - # Dies sollte durch die Spaltenauswahl oben garantiert sein, aber zur Sicherheit - wiki_series = df_subset[wiki_col].apply(get_valid_numeric) if wiki_col in df_subset.columns else pd.Series(np.nan, index=df_subset.index) - crm_series = df_subset[crm_col].apply(get_valid_numeric) if crm_col in df_subset.columns else pd.Series(np.nan, index=df_subset.index) - - - df_subset[final_col] = np.where( - wiki_series.notna(), # Wenn Wiki-Wert vorhanden ist (nicht NaN) - wiki_series, # Nimm den Wiki-Wert - crm_series # Sonst nimm den CRM-Wert (der auch NaN sein kann) - ) - # Info-Log über Ergebnis - self.logger.info(f" -> {df_subset[final_col].notna().sum()} gültige '{final_col}' Werte erstellt (von {len(df_subset)} Zeilen).") - - # --- Zielvariable vorbereiten (Technikerzahl) --- - techniker_col_internal = "techniker" # Interne Spaltenname nach Umbenennung - self.logger.info(f"Verarbeite Zielvariable '{techniker_col_internal}'...") - - # Sicherstellen, dass die Spalte existiert - if techniker_col_internal not in df_subset.columns: - self.logger.critical(f"FEHLER: Zielvariable '{techniker_col_internal}' (CRM Anzahl Techniker) nicht im DataFrame gefunden nach Umbenennung.") - return None - - # Konvertiere zu Numerisch (Fehler -> NaN) - # Verwende get_valid_numeric, um positive Float-Werte oder NaN zu erhalten - df_subset['Anzahl_Servicetechniker_Numeric'] = df_subset[techniker_col_internal].apply(get_valid_numeric) - - - # Filtere Zeilen: Behalte nur die mit gültiger, positiver Technikerzahl (float > 0) - initial_rows = len(df_subset) - # Hier filtern wir basierend auf der numerischen Spalte, die durch get_valid_numeric erstellt wurde - df_filtered = df_subset[ - df_subset['Anzahl_Servicetechniker_Numeric'].notna() & - (df_subset['Anzahl_Servicetechniker_Numeric'] > 0) - ].copy() # WICHTIG: .copy() um SettingWithCopyWarning zu vermeiden - filtered_rows = len(df_filtered) - removed_rows = initial_rows - filtered_rows - - # Info, wenn Zeilen entfernt wurden - if removed_rows > 0: - self.logger.info(f"{removed_rows} Zeilen entfernt aufgrund fehlender/ungültiger Technikerzahl (Wert <= 0 oder nicht numerisch/parsebar).") - self.logger.info(f"Verbleibende Zeilen für Modellierungstraining (mit gültiger Technikerzahl > 0): {filtered_rows}") - - if filtered_rows == 0: - self.logger.error("FEHLER: Keine Zeilen mit gültiger Technikerzahl (>0) übrig für Modellierungstraining!") - return None - - # --- Techniker-Buckets erstellen --- - # Die Bins und Labels müssen die gefilterten Daten widerspiegeln (die jetzt alle > 0 sind). - # Die Bin-Definition muss so sein, dass alle Werte > 0 einem Bucket zugeordnet werden. - # Beispiel: (-1, 0] -> Bucket 1 (0), (0, 19] -> Bucket 2 (<20), (19, 49] -> Bucket 3 (<50) etc. - # Da wir auf >0 filtern, landet 0 nie im Trainingsset, aber die Bin-Definition muss trotzdem Sinn ergeben. - bins = [-1, 0, 19, 49, 99, 249, 499, float('inf')] - labels = ['Bucket_1_(0)', 'Bucket_2_(<20)', 'Bucket_3_(<50)', 'Bucket_4_(<100)', 'Bucket_5_(<250)', 'Bucket_6_(<500)', 'Bucket_7_(>499)'] - - try: - df_filtered['Techniker_Bucket'] = pd.cut( - df_filtered['Anzahl_Servicetechniker_Numeric'], - bins=bins, - labels=labels, - right=True, # Intervalle sind (links, rechts]. (0, 19] inkludiert 19. - include_lowest=True # Inkludiert den niedrigsten Wert der ersten Bin (-1) - ) - self.logger.info("Techniker-Buckets erstellt.") - - # Prüfe, ob NaNs in Buckets erstellt wurden (sollte bei >0 Filterung und korrekten Bins nicht passieren) - if df_filtered['Techniker_Bucket'].isna().any(): - nan_bucket_rows = df_filtered['Techniker_Bucket'].isna().sum() - self.logger.warning(f"WARNUNG: {nan_bucket_rows} Zeilen mit NaNs in Techniker-Buckets nach pd.cut erstellt. Überprüfen Sie die bins/labels oder die Filterung.") - # Entfernen Sie diese Zeilen, da sie nicht zum Trainieren verwendet werden können - df_filtered.dropna(subset=['Techniker_Bucket'], inplace=True) - self.logger.info(f"Nach Entfernung von {nan_bucket_rows} Zeilen mit NaN Buckets: {len(df_filtered)} Zeilen verbleiben für Training.") - if len(df_filtered) == 0: - self.logger.error("FEHLER: Keine Zeilen übrig nach Entfernung von NaN Buckets. Modell kann nicht trainiert werden.") - return None - - # Verteilung als Info-Log - self.logger.info(f"Verteilung der Techniker-Buckets im Trainingsdatensatz ({len(df_filtered)} Zeilen):\n{df_filtered['Techniker_Bucket'].value_counts(normalize=False).sort_index()}") # Zählung - self.logger.info(f"Verteilung (Prozent):\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True).sort_index().round(3)}") # Prozent - - except Exception as e: - self.logger.critical(f"FEHLER beim Erstellen der Techniker-Buckets: {e}") - self.logger.debug(traceback.format_exc()) - return None - - - # --- Kategoriale Features vorbereiten (Branche) --- - branche_col_internal = "branche_crm" # Interne Spaltenname nach Umbenennung - self.logger.info(f"Verarbeite kategoriales Feature '{branche_col_internal}' für One-Hot Encoding...") - - # Sicherstellen, dass die Spalte existiert - if branche_col_internal not in df_filtered.columns: - self.logger.critical(f"FEHLER: Spalte '{branche_col_internal}' nicht im DataFrame für One-Hot Encoding gefunden.") - return None - - # Stelle sicher, dass die Spalte String ist und fülle evtl. NaNs mit 'Unbekannt' - df_filtered[branche_col_internal] = df_filtered[branche_col_internal].astype(str).fillna('Unbekannt').str.strip() - - # One-Hot Encoding - # dummy_na=False, da wir NaNs gefüllt haben. - # prefix='Branche' ist gut. - df_encoded = pd.get_dummies(df_filtered, columns=[branche_col_internal], prefix='Branche', dummy_na=False) - self.logger.info(f"One-Hot Encoding für '{branche_col_internal}' durchgeführt. Neue Spaltenanzahl: {len(df_encoded.columns)}") - - # --- Finale Auswahl der Features für das Modell --- - # Identifizieren Sie die Feature-Spalten nach dem Encoding - feature_columns = [col for col in df_encoded.columns if col.startswith('Branche_')] # Alle One-Hot Branch-Spalten - # Fügen Sie die konsolidierten numerischen Spalten hinzu - feature_columns.extend(['Finaler_Umsatz', 'Finaler_Mitarbeiter']) - - # Prüfen Sie, ob die konsolidierten numerischen Spalten existieren (sollten sie, wurden oben erstellt) - if not all(col in df_encoded.columns for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter']): - self.logger.critical("FEHLER: Konsolidierte numerische Spalten 'Finaler_Umsatz' oder 'Finaler_Mitarbeiter' fehlen im DataFrame nach Konsolidierung.") - return None - - target_column = 'Techniker_Bucket' # Zielvariable - - # Erstellen Sie den finalen DataFrame nur mit Features und Target - # Behalten Sie 'name' und 'Anzahl_Servicetechniker_Numeric' für Reporting/Debugging - identification_cols = ['name', 'Anzahl_Servicetechniker_Numeric'] - # Sicherstellen, dass diese Spalten existieren - if not all(col in df_encoded.columns for col in identification_cols): - self.logger.critical(f"FEHLER: Identifikationsspalten {identification_cols} fehlen im DataFrame.") - return None - - - # Erstellen Sie den finalen DF - # Stellen Sie sicher, dass alle Feature-Spalten auch wirklich im DataFrame sind - # (Könnte fehlen, wenn z.B. Finaler_Umsatz/Mitarbeiter oben fehlschlug) - final_cols = identification_cols + feature_columns + [target_column] - missing_final_cols = [col for col in final_cols if col not in df_encoded.columns] - if missing_final_cols: - self.logger.critical(f"FEHLER: Finale Spalten für Modellierung fehlen im DataFrame: {missing_final_cols}") - return None - - - df_model_ready = df_encoded[final_cols].copy() - - # Optional: Konvertieren Sie numerische Spalten explizit zu Float64 - for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter', 'Anzahl_Servicetechniker_Numeric']: - if col in df_model_ready.columns: # Sicherheitscheck - # errors='coerce' wandelt Fehler in NaN. Wichtig, da Imputer NaNs erwartet. - df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce') - - - # Reset Index für saubere Verarbeitung - df_model_ready = df_model_ready.reset_index(drop=True) - - self.logger.info("Datenvorbereitung für Modellierung abgeschlossen.") - self.logger.info(f"Finaler DataFrame für Modellierung hat {len(df_model_ready)} Zeilen und {len(df_model_ready.columns)} Spalten.") - self.logger.info(f"Anzahl Feature-Spalten: {len(feature_columns)}") - self.logger.info(f"Ziel-Spalte: {target_column}") - - # WICHTIG: Info über fehlende Werte in den finalen numerischen Features vor Imputation - # Imputer wird im Trainingsschritt angewendet. - numeric_features_for_imputation = ['Finaler_Umsatz', 'Finaler_Mitarbeiter'] - nan_counts = df_model_ready[numeric_features_for_imputation].isna().sum() - self.logger.info(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}") - rows_with_nan = df_model_ready[numeric_features_for_imputation].isna().any(axis=1).sum() - self.logger.info(f"Anzahl Zeilen mit mindestens einem fehlenden numerischen Feature (vor Imputation): {rows_with_nan}") - - - return df_model_ready - - - # Methode zum Trainieren des ML Modells - # Übernommen aus Ihrem Code (Teil 2 Beispiel?), angepasst als Methode. - def train_technician_model(self, model_out=MODEL_FILE, imputer_out=IMPUTER_FILE, patterns_out=PATTERNS_FILE_TXT): - """ - Trainiert ein Decision Tree Modell zur Schätzung der Servicetechniker-Buckets. - Speichert das Modell, den Imputer und die Feature-Spalten. - - Args: - model_out (str): Dateipfad zum Speichern des trainierten Modells (.pkl). - imputer_out (str): Dateipfad zum Speichern des trainierten Imputers (.pkl). - patterns_out (str): Dateipfad zum Speichern der Feature-Spaltenliste (.txt oder .json). - """ - self.logger.info("Starte Training des Servicetechniker Decision Tree Modells...") - - # 1. Daten vorbereiten (nutzt die interne Methode) - df_model_ready = self.prepare_data_for_modeling() - - if df_model_ready is None: - self.logger.error("Datenvorbereitung für Modelltraining fehlgeschlagen. Training abgebrochen.") - return - - # Separate Features (X) und Target (y) - # Identifikationsspalten entfernen - identification_cols = ['name', 'Anzahl_Servicetechniker_Numeric'] # Muss konsistent mit prepare_data_for_modeling sein - target_column = 'Techniker_Bucket' # Muss konsistent sein - - # Feature Spalten sind alle außer Identifikation und Target - feature_columns = [col for col in df_model_ready.columns if col not in identification_cols and col != target_column] - # Stellen Sie sicher, dass es Feature-Spalten gibt - if not feature_columns: - self.logger.critical("FEHLER: Keine Feature-Spalten nach Datenvorbereitung gefunden. Training nicht möglich.") - return - - X = df_model_ready[feature_columns] - y = df_model_ready[target_column] - - self.logger.info(f"Daten für Training vorbereitet. X Shape: {X.shape}, y Shape: {y.shape}") - self.logger.debug(f"Feature Spalten für Training ({len(feature_columns)}): {feature_columns[:10]}...") # Logge erste 10 Features - - # 2. Split in Training und Test Set - # test_size anpassen, random_state für Reproduzierbarkeit - X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y) # Stratify für gleiche Bucket-Verteilung - - self.logger.info(f"Daten gesplittet. Train Set: {len(X_train)} Zeilen, Test Set: {len(X_test)} Zeilen.") - - # 3. Imputation (Fehlende Werte ersetzen) - # Verwenden Sie SimpleImputer (z.B. Median), um NaN-Werte zu ersetzen - # Fitten Sie den Imputer nur auf den Trainingsdaten, aber transformieren Sie beide - imputer = SimpleImputer(strategy='median') # Median ist robust gegenüber Ausreißern - self.logger.info(f"Fitte Imputer mit Strategie '{imputer.strategy}' auf Trainingsdaten...") - imputer.fit(X_train) # Fitten Sie den Imputer auf X_train - - # Speichern Sie den Imputer (wird für Vorhersagen benötigt) - self.imputer = imputer # Speichern Sie ihn in der Instanz - try: - with open(imputer_out, 'wb') as f: - pickle.dump(imputer, f) - self.logger.info(f"Imputer erfolgreich gespeichert in '{imputer_out}'.") - except Exception as e: - self.logger.error(f"FEHLER beim Speichern des Imputers in '{imputer_out}': {e}") - # Fahren Sie fort, aber loggen Sie den Fehler - - # Transformieren Sie Trainings- und Testdaten - X_train_imputed = imputer.transform(X_train) - X_test_imputed = imputer.transform(X_test) - - # Konvertieren Sie die Ergebnisse (Numpy Arrays) zurück zu DataFrames, behalten Sie die Spaltennamen - X_train_imputed = pd.DataFrame(X_train_imputed, columns=feature_columns) - X_test_imputed = pd.DataFrame(X_test_imputed, columns=feature_columns) - self.logger.info("Numerische Features imputiert.") - - # 4. Decision Tree Training - # Definieren Sie das Modell - # max_depth, min_samples_split, min_samples_leaf können getunt werden - # class_weight='balanced' ist hilfreich bei ungleicher Klassenverteilung (wahrscheinlich bei Buckets) - dt_classifier = DecisionTreeClassifier(random_state=42, class_weight='balanced') - - self.logger.info("Starte Training des Decision Tree Modells...") - # Fitten Sie das Modell auf den imputierten Trainingsdaten - dt_classifier.fit(X_train_imputed, y_train) - self.logger.info("Modelltraining abgeschlossen.") - - # Speichern Sie das trainierte Modell - self.model = dt_classifier # Speichern Sie es in der Instanz - try: - with open(model_out, 'wb') as f: - pickle.dump(dt_classifier, f) - self.logger.info(f"Decision Tree Modell erfolgreich gespeichert in '{model_out}'.") - except Exception as e: - self.logger.error(f"FEHLER beim Speichern des Modells in '{model_out}': {e}") - # Fahren Sie fort - - # Speichern Sie die Liste der Feature-Spalten (für die Vorhersage) - self._expected_features = feature_columns # Speichern Sie diese Liste in der Instanz - try: - # Speichern als JSON für bessere Lesbarkeit - patterns_data = {"feature_columns": feature_columns, "target_classes": list(dt_classifier.classes_)} - patterns_out_json = patterns_out.replace('.txt', '.json') # Speichern Sie es als JSON - with open(patterns_out_json, '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_json}'.") - - # Optional: Speichern als einfache Textdatei (wie im Originalcode) - # with open(patterns_out, 'w', encoding='utf-8') as f: - # for col in feature_columns: f.write(f"{col}\n") - # self.logger.info(f"Erwartete Feature-Spalten (txt) erfolgreich gespeichert in '{patterns_out}'.") - - except Exception as e: - self.logger.error(f"FEHLER beim Speichern der Feature-Spalten in '{patterns_out_json}': {e}") - # Fahren Sie fort - - # 5. Evaluation (Optional, aber empfohlen) - self.logger.info("Starte Modellevaluation...") - - # Vorhersagen auf dem Testset - y_pred = dt_classifier.predict(X_test_imputed) - - # Metriken berechnen und loggen - accuracy = accuracy_score(y_test, y_pred) - self.logger.info(f"Modell Genauigkeit auf dem Testset: {accuracy:.4f}") - - # Klassifikationsbericht - # Zero_division='warn' ist Standard, '0' gibt 0 für nicht vorhandene Klassen, 'none' wirft Fehler - class_report = classification_report(y_test, y_pred, zero_division=0) - self.logger.info(f"Klassifikationsbericht auf dem Testset:\n{class_report}") - - # Konfusionsmatrix - # display_labels=dt_classifier.classes_ sorgt für korrekte Beschriftung - cm = confusion_matrix(y_test, y_pred, labels=dt_classifier.classes_) - self.logger.info(f"Konfusionsmatrix auf dem Testset:\n{cm}") - - # Entscheidungsregeln extrahieren (Optional) - try: - # Beschränken Sie die Tiefe für die Ausgabe, falls der Baum sehr tief ist - tree_rules = export_text(dt_classifier, feature_names=feature_columns, max_depth=5) # max_depth anpassen - self.logger.info(f"Erste Regeln des Decision Tree (max Tiefe 5):\n{tree_rules}") - except Exception as e: - self.logger.warning(f"FEHLER beim Exportieren der Baumregeln: {e}") - - self.logger.info("Modelltraining und -evaluation abgeschlossen.") - - # --- Die nächsten Utility Methoden der DataProcessor Klasse folgen in den nächsten Teilen --- - # process_website_details method... (kommt in Teil 16) # Optional/Experimentell - # process_wiki_updates_from_chatgpt method... (kommt in Teil 16) - # process_wiki_reextract_missing_an method... (kommt in Teil 16) -# ========================================================================== - # === Utility Methods (Other Specific Tasks) =============================== - # ========================================================================== - - # --- Methode für experimentelle Website Details --- - # Übernommen aus process_website_details_for_marked_rows in Teil 12, angepasst als Methode. - # Diese Funktion ist als experimentelles Dienstprogramm gedacht. - # Annahme: Eine globale Funktion scrape_website_details existiert (oder muss erstellt werden). - def process_website_details(self, start_sheet_row=None, end_sheet_row=None, limit=None): - """ - EXPERIMENTELL: Extrahiert Website-Details für Zeilen, die mit 'x' in Spalte A markiert sind. - Schreibt die Details in eine definierte Spalte (Website Details oder AR als Fallback). - Löscht NICHT das 'x'-Flag. - - 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 übersprungener) Zeilen. Defaults to None. - """ - self.logger.warning(f"Starte Modus (EXPERIMENTELL): Website Detail Extraction für Zeilen mit 'x' in Spalte A. Bereich: {start_sheet_row}-{end_sheet_row}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") - self.logger.warning("Hinweis: Dieser Modus erfordert die Implementierung der globalen Funktion 'scrape_website_details(url)'.") - - # --- Daten laden --- - # Laden Sie Daten, aber es ist kein automatischer Startindex-Check nötig, - # da wir explizit nach 'x' suchen. - if not self.sheet_handler.load_data(): - self.logger.error("Fehler beim Laden der Daten für Website Details Extraction.") - return - - all_data = self.sheet_handler.get_all_data_with_headers() - header_rows = self.sheet_handler._header_rows - total_sheet_rows = len(all_data) - - # Standard Startzeile, wenn nicht angegeben - if start_sheet_row is None: start_sheet_row = header_rows + 1 # Standardmäßig ab erster Datenzeile - - # Berechne Endzeile, wenn nicht gesetzt - if end_sheet_row is None: end_sheet_row = total_sheet_rows # Bis zur letzten Zeile - - self.logger.info(f"Suchbereich für 'x'-Flag: 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 = ["ReEval Flag", "CRM Website"] - 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: Benötigte Spaltenschlüssel fehlen in COLUMN_MAP für process_website_details: {missing}. Breche ab.") - return - - reeval_col_idx = col_indices["ReEval Flag"] - website_col_idx = col_indices["CRM Website"] - - # Bestimme die Zielspalte für die Details - details_col_idx = COLUMN_MAP.get("Website Details") # Versuche zuerst die dedizierte Spalte - details_col_key = "Website Details" # Für Logging - if details_col_idx is None: - # Fallback auf 'Website Rohtext' (AR) - details_col_idx = COLUMN_MAP.get("Website Rohtext") - details_col_key = "Website Rohtext" - if details_col_idx is None: - self.logger.critical("FEHLER: Weder 'Website Details' noch 'Website Rohtext' Spaltenindex in COLUMN_MAP gefunden.") - return - self.logger.warning(f"Keine Spalte 'Website Details' in COLUMN_MAP, nutze '{details_col_key}' ({self.sheet_handler._get_col_letter(details_col_idx+1)}) als Fallback.") - else: - self.logger.info(f"Nutze Spalte '{details_col_key}' ({self.sheet_handler._get_col_letter(details_col_idx+1)}) für Website Details.") - - - details_col_letter = self.sheet_handler._get_col_letter(details_col_idx + 1) - - - # --- Verarbeitung --- - all_sheet_updates = [] # Gesammelte Updates für Batch-Schreiben - update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) # Update Batch Größe aus Config - - processed_count = 0 # Zählt Zeilen, die im Batch verarbeitet (versucht) wurden - skipped_count = 0 # Zählt Zeilen, die übersprungen wurden (nicht markiert oder fehlende URL) - - - # Iteriere durch die Datenzeilen im definierten Bereich - 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 - if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht - - row = all_data[row_index_in_list] - - # Stellen Sie sicher, dass die Zeile nicht leer ist - if not any(cell and cell.strip() for cell in row): - #self.logger.debug(f"Zeile {i}: Übersprungen (Leere Zeile).") # Zu viel Lärm - skipped_count += 1 - continue - - # --- Prüfung, ob Verarbeitung für diese Zeile nötig ist --- - # Kriterium: Zeile ist mit 'x' in Spalte A markiert - # UND Website URL (D) ist vorhanden und nicht "k.A.". - - # Prüfen, ob die Zeile mit 'x' in Spalte A markiert ist - cell_a_value = self._get_cell_value_safe(row, "ReEval Flag").strip().lower() - is_marked_for_reeval = cell_a_value == "x" - - if not is_marked_for_reeval: - skipped_count += 1 - continue - - # Prüfen, ob eine gültige Website-URL vorhanden ist - website_url = self._get_cell_value_safe(row, "CRM Website").strip() - website_url_is_valid_looking = website_url and website_url.lower() not in ["k.a.", "kein artikel gefunden"] - - - processing_needed_for_row = is_marked_for_reeval and website_url_is_valid_looking - - - # Loggen der Prüfergebnisse auf Debug - 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} (Website Details Check): A='x'? {is_marked_for_reeval}, D gültig? {website_url_is_valid_looking}. Benötigt Verarbeitung? {processing_needed_for_row}") - - - if not processing_needed_for_row: - skipped_count += 1 - continue - - # --- Wenn Verarbeitung nötig: Führe Details-Extraktion aus --- - processed_count += 1 # Zähle die Zeile, die verarbeitet wird (zum Limit zählen) - - # Prüfe das Limit für verarbeitete Zeilen - if limit is not None and processed_count > limit: - self.logger.info(f"Verarbeitungslimit ({limit}) für process_website_details erreicht. Breche weitere Zeilenprüfung ab.") - break # Schleife abbrechen - - - self.logger.info(f"Zeile {i}: Extrahiere Website Details von {website_url}...") - - details = "FEHLER: Funktion 'scrape_website_details' nicht definiert" # Default Fehler - - try: - # Annahme: Globale Funktion scrape_website_details existiert (oder muss erstellt werden) - # Sie muss eine URL nehmen und einen String oder ein serialisierbares Objekt zurückgeben - # Sie sollte interne Fehler behandeln und im Fehlerfall einen Fehlerstring zurückgeben - details = scrape_website_details(website_url) # <<< DIESE FUNKTION MUSS IMPLEMENTIERT WERDEN! - - except NameError: - self.logger.critical("FEHLER: Funktion 'scrape_website_details' ist nicht definiert! Kann Details nicht extrahieren.") - # Fehlertext bleibt der Initialwert - - except Exception as e_detail: - self.logger.exception(f"FEHLER bei scrape_website_details für {website_url}: {e_detail}") - details = f"FEHLER Extraktion: {str(e_detail)[:100]}" # Kürze Fehlermeldung - - - # Füge Update für die Details-Spalte hinzu - # Stelle sicher, dass der Wert in einen String konvertiert wird, falls scrape_website_details z.B. ein Dict zurückgibt - updates_for_row = [] - updates_for_row.append({'range': f'{details_col_letter}{i}', 'values': [[str(details)]]}) - self.logger.info(f"Zeile {i}: Details extrahiert und zum Update für Spalte {details_col_key} ({details_col_letter}{i}) hinzugefügt.") - - # Sammle diese Updates - all_sheet_updates.extend(updates_for_row) - - - # Sende gesammelte Sheet Updates wenn das Update-Batch-Limit erreicht ist - # Updates pro Zeile ist 1 in diesem Modus. - if len(all_sheet_updates) >= update_batch_row_limit: - self.logger.debug(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") - # Nutzt die batch_update_cells Methode des Sheet Handlers mit Retry - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: - self.logger.info(f" Sheet-Update für {len(all_sheet_updates)} Zellen erfolgreich.") - # Der Fehlerfall wird von batch_update_cells geloggt - - # Leere die gesammelten Updates nach dem Senden - all_sheet_updates = [] - - # Kleine Pause nach jeder Extraktion (nutzt Config) - pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.2 - #self.logger.debug(f"Warte {pause_duration:.2f}s nach Extraktion...") # Zu viel Lärm - time.sleep(pause_duration) - - - # --- Finale Sheet Updates senden --- - # Sende alle verbleibenden gesammelten Updates - if all_sheet_updates: - self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: - self.logger.info(f"FINALES Sheet-Update erfolgreich.") - # Der Fehlerfall wird von batch_update_cells geloggt - - - self.logger.info(f"Modus 'website_details' abgeschlossen. {processed_count} Zeilen verarbeitet (versucht), {skipped_count} Zeilen übersprungen.") - # Keine Pause nach diesem Modus nötig. - - - # --- Methode zum Verarbeiten von Wiki-Updates basierend auf ChatGPT Vorschlägen --- - # Übernommen aus process_wiki_updates_from_chatgpt in Teil 4, angepasst als Methode. - def process_wiki_updates_from_chatgpt(self, start_sheet_row=None, end_sheet_row=None, limit=None): - """ - Identifiziert Zeilen, in denen Status S gesetzt ist, aber NICHT auf einem Endzustand - (OK, X (UPDATED/COPIED/INVALID)), prüft ob U eine *valide* und *andere* Wiki-URL ist. - - Wenn ja: Kopiert U->M, markiert S='X (URL Copied)', U='URL übernommen', löscht - abhängige Wiki-Spalten (N-V, AN, AO, AP, AX), setzt ReEval-Flag A='x'. - - Wenn nein (U keine URL, U==M, oder U ungültig): LÖSCHT den Inhalt von U und - markiert S als 'X (Invalid Suggestion)'. - Verarbeitet maximal 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 PRÜFENDER Zeilen. Defaults to None. - """ - self.logger.info(f"Starte Modus: Wiki-Updates (URL-Validierung & Löschen ungültiger Vorschläge). Bereich: {start_sheet_row}-{end_sheet_row}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") - - # --- Daten laden --- - # Laden Sie Daten. Kein automatischer Startindex-Check nötig, - # da wir nach Status S suchen. - if not self.sheet_handler.load_data(): - self.logger.error("Fehler beim Laden der Daten für Wiki Updates.") - return - - all_data = self.sheet_handler.get_all_data_with_headers() - header_rows = self.sheet_handler._header_rows - total_sheet_rows = len(all_data) - - # Standard Startzeile, wenn nicht angegeben - if start_sheet_row is None: start_sheet_row = header_rows + 1 # Standardmäßig ab erster Datenzeile - - # Berechne Endzeile, wenn nicht gesetzt - if end_sheet_row is None: end_sheet_row = total_sheet_rows # Bis zur letzten Zeile - - self.logger.info(f"Suchbereich für Status S: 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 = [ - "Chat Wiki Konsistenzprüfung", "Chat Vorschlag Wiki Artikel", "Wiki URL", # Prüfkriterien / Daten - "Wikipedia Timestamp", "Wiki Verif. Timestamp", "Timestamp letzte Prüfung", "Version", # Spalten zum Leeren - "ReEval Flag", # ReEval Flag setzen - "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", # N-R zum Leeren - "Chat Begründung Wiki Inkonsistenz", "Begründung bei Abweichung" # T-V zum Leeren - ] - 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: Benötigte Spaltenschlüssel fehlen in COLUMN_MAP für process_wiki_updates_from_chatgpt: {missing}. Breche ab.") - return - - # Spaltenbuchstaben für Updates/Leerung - s_letter = self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"] + 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_idx = col_indices["Wiki Absatz"] - v_idx = col_indices["Begründung bei Abweichung"] - 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 - empty_nv_values = [''] * (v_idx - n_idx + 1) # Anzahl der Spalten - - # Timestamps AN, AO, AX, Version AP leeren - an_letter = self.sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"] + 1) - ao_letter = self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Prüfung"] + 1) - ap_letter = self.sheet_handler._get_col_letter(col_indices["Version"] + 1) - ax_letter = self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"] + 1) - - - # --- Verarbeitung --- - all_sheet_updates = [] # Gesammelte Updates für Batch-Schreiben - update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) # Update Batch Größe aus Config - - processed_rows_count = 0 # Zählt Zeilen, die geprüft werden (zum Limit zählen) - skipped_count = 0 # Zählt Zeilen, die übersprungen werden (Status S im Endzustand etc.) - updated_url_count = 0 # Zählt Zeilen, wo URL kopiert wurde - cleared_suggestion_count = 0 # Zählt Zeilen, wo Vorschlag gelöscht wurde - - - # Iteriere durch die Datenzeilen im definierten Bereich - 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 - if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht - - row = all_data[row_index_in_list] - - # Stellen Sie sicher, dass die Zeile nicht leer ist - if not any(cell and cell.strip() for cell in row): - #self.logger.debug(f"Zeile {i}: Übersprungen (Leere Zeile).") # Zu viel Lärm - skipped_count += 1 - continue - - - # --- Prüfung, ob Verarbeitung für diese Zeile nötig ist --- - # Kriterium: Status S ist gesetzt (nicht leer) UND NICHT einer der Endzustände. - # Endzustände: "OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)" - - s_value = self._get_cell_value_safe(row, "Chat Wiki Konsistenzprüfung").strip() - s_value_upper = s_value.upper() - - # Definieren Sie die Endzustände (Großbuchstaben) - end_states = ["OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)"] - - # Verarbeitung nötig, wenn S nicht leer ist UND S NICHT im Endzustand ist - processing_needed_for_row = s_value and s_value_upper not in end_states - - - # Loggen der Prüfergebnisse auf Debug - 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}'. Benötigt Verarbeitung? {processing_needed_for_row}") - - - if not processing_needed_for_row: - skipped_count += 1 - continue - - # --- Wenn Verarbeitung nötig: Prüfe Vorschlag U und handle --- - processed_rows_count += 1 # Zähle die Zeile, die geprüft wird (zum Limit zählen) - - # Prüfe das Limit für verarbeitete Zeilen - if limit is not None and processed_count > limit: - self.logger.info(f"Verarbeitungslimit ({limit}) für process_wiki_updates_from_chatgpt erreicht. Breche weitere Zeilenprüfung ab.") - break # Schleife abbrechen - - - vorschlag_u = self._get_cell_value_safe(row, "Chat Vorschlag Wiki Artikel").strip() - url_m = self._get_cell_value_safe(row, "Wiki URL").strip() - - self.logger.info(f"Zeile {i}: Prüfe Wiki-Vorschlag U='{vorschlag_u[:100]}...' (aktuell M='{url_m[:100]}...')...") - - is_update_candidate = False # Flag, ob U eine gültige, neue URL ist - new_url = "" - - # Kriterium 1: Ist Vorschlag U eine URL und sieht nach Wikipedia aus? - condition1_u_is_wiki_url = vorschlag_u.lower().startswith(("http://", "https://")) and "wikipedia.org/wiki/" in vorschlag_u.lower() - - 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? - condition2_u_differs_m = new_url != url_m - - if condition2_u_differs_m: - self.logger.debug(f" -> Vorschlag U ({new_url}) unterscheidet sich von M ({url_m}). Prüfe Validität...") - # Kriterium 3: Ist die vorgeschlagene URL ein valider Wikipedia-Artikel? - try: - # is_valid_wikipedia_article_url nutzt den retry_on_failure Decorator - condition3_u_is_valid = is_valid_wikipedia_article_url(new_url) # Annahme: globale Funktion in utils.py - if condition3_u_is_valid: - is_update_candidate = True # Alle Kriterien erfüllt! - else: - self.logger.debug(f" -> URL '{new_url}' ist KEIN valider Artikel laut API Check.") - except Exception as e_validity_check: - self.logger.error(f"FEHLER bei Validitätsprüfung von Vorschlag U '{new_url}': {e_validity_check}") - # Bei Fehler bleibt is_update_candidate False - pass # Fahren Sie fort - - else: - self.logger.debug(f" -> Vorschlag U ist identisch mit URL M.") - - else: - self.logger.debug(f" -> Vorschlag U ('{vorschlag_u[:100]}...') ist keine Wikipedia URL.") - - - # --- Verarbeitung des Kandidaten ODER Löschen des ungültigen Vorschlags --- - updates_for_row = [] # Updates nur für diese Zeile sammeln - - if is_update_candidate: - # Fall 1: Gültiges Update durchführen - self.logger.info(f"Zeile {i}: Update-Kandidat VALIDIERUNG ERFOLGREICH. Kopiere U->M, setze ReEval-Flag 'x', lösche abhängige Spalten.") - updated_url_count += 1 - - # Updates sammeln (M, S, U, N-V, AN, AO, AP, AX, A) - updates_for_row.append({'range': f'{m_letter}{i}', 'values': [[new_url]]}) # URL setzen in M - updates_for_row.append({'range': f'{s_letter}{i}', 'values': [["X (URL Copied)"]]},) # Neuer Status in S - updates_for_row.append({'range': f'{u_letter}{i}', 'values': [["URL übernommen"]]},) # Info in U - updates_for_row.append({'range': f'{a_letter}{i}', 'values': [["x"]]},) # ReEval Flag setzen in A - - # Spalten N-V leeren - if nv_range_letter: - updates_for_row.append({'range': f'{n_letter}{i}:{v_letter}{i}', 'values': [empty_nv_values]}) - else: - self.logger.warning(f"Konnte Spaltenbereich N-V für Leerung nicht ermitteln.") - - # Timestamps AN, AO, AX, Version AP leeren - updates_for_row.append({'range': f'{an_letter}{i}', 'values': [['']]}) - updates_for_row.append({'range': f'{ao_letter}{i}', 'values': [['']]}) - updates_for_row.append({'range': f'{ap_letter}{i}', 'values': [['']]}) - updates_for_row.append({'range': f'{ax_letter}{i}', 'values': [['']]}) - - - else: - # Fall 2: Ungültigen Vorschlag löschen/markieren - self.logger.info(f"Zeile {i}: Vorschlag U ('{vorschlag_u[:100]}...') ist ungültig/identisch. Lösche U und setze Status S auf 'X (Invalid Suggestion)'.") - cleared_suggestion_count += 1 - updates_for_row.append({'range': f'{s_letter}{i}', 'values': [["X (Invalid Suggestion)"]]},) # Neuer Status in S - updates_for_row.append({'range': f'{u_letter}{i}', 'values': [[""]]},) # Vorschlag U löschen - # KEIN ReEval-Flag setzen - - - # Sammle die Updates für diese Zeile - all_sheet_updates.extend(updates_for_row) - - - # Sende gesammelte Sheet Updates wenn das Update-Batch-Limit erreicht ist - # Die Anzahl der Updates pro Zeile variiert stark (ca. 2 bei ungültigem Vorschlag, ca. 10+ bei gültigem). - # Prüfen Sie einfach die Länge der gesammelten Liste. - if len(all_sheet_updates) >= update_batch_row_limit * 5: # Grobe Schätzung, dass im Schnitt 5 Updates pro Zeile anfallen - self.logger.debug(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") - # Nutzt die batch_update_cells Methode des Sheet Handlers mit Retry - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: - self.logger.info(f" Sheet-Update für {len(all_sheet_updates)} Zellen erfolgreich.") - # Der Fehlerfall wird von batch_update_cells geloggt - - # Leere die gesammelten Updates nach dem Senden - all_sheet_updates = [] - - # Kleine Pause nach jeder geprüften Zeile (nutzt Config) - # Dieser Modus macht API calls (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 Prüfung...") # Zu viel Lärm - time.sleep(pause_duration) - - - # --- Finale Sheet Updates senden --- - # Sende alle verbleibenden gesammelten Updates - if all_sheet_updates: - self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: - self.logger.info(f"FINALES Sheet-Update erfolgreich.") - # Der Fehlerfall wird von batch_update_cells geloggt - - - self.logger.info(f"Modus 'wiki_updates_from_chatgpt' abgeschlossen. {processed_rows_count} Zeilen geprüft, {updated_url_count} URLs kopiert & für ReEval markiert, {cleared_suggestion_count} ungültige Vorschläge gelöscht/markiert, {skipped_count} Zeilen übersprungen.") - # Keine Pause nach diesem Modus nötig. - - - # --- Methode zur Re-Extraktion von Wiki-Daten bei fehlendem Timestamp AN --- - # NEUE Utility Methode, die den _process_single_row nutzt. - def process_wiki_reextract_missing_an(self, start_sheet_row=None, end_sheet_row=None, limit=None): - """ - Identifiziert Zeilen, bei denen eine Wiki URL (M) vorhanden ist, aber der - Wikipedia Timestamp (AN) fehlt. Führt _process_single_row für diese Zeilen aus, - beschränkt 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). - 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. - """ - self.logger.info(f"Starte Modus 'wiki_reextract_missing_an': Suche Zeilen mit M gefüllt und AN leer. Bereich: {start_sheet_row}-{end_sheet_row}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") - - # --- Daten laden --- - # Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt - # Hier suchen wir nicht nach leeren Spalten für den Start, sondern scannen den Bereich. - # Laden Sie Daten, aber es ist kein automatischer Startindex-Check nötig. - if not self.sheet_handler.load_data(): - self.logger.error("FEHLER beim Laden der Daten für wiki_reextract_missing_an.") - return - - all_data = self.sheet_handler.get_all_data_with_headers() - header_rows = self.sheet_handler._header_rows - total_sheet_rows = len(all_data) - - # Standard Startzeile, wenn nicht angegeben - if start_sheet_row is None: start_sheet_row = header_rows + 1 # Standardmäßig ab erster Datenzeile - - # Berechne Endzeile, wenn nicht gesetzt - if end_sheet_row is None: end_sheet_row = total_sheet_rows # Bis zur letzten Zeile - - self.logger.info(f"Suchbereich für M gefüllt & AN leer: 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 --- - required_keys = ["Wiki URL", "Wikipedia Timestamp"] # Prüfkriterien - 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: Benötigte Spaltenschlüssel fehlen in COLUMN_MAP für wiki_reextract_missing_an: {missing}. Breche ab.") - return - - m_col_idx = col_indices["Wiki URL"] - an_col_idx = col_indices["Wikipedia Timestamp"] - - - # --- Verarbeitung --- - processed_count = 0 # Zählt Zeilen, die an _process_single_row übergeben wurden (zum Limit zählen) - skipped_count = 0 # Zählt Zeilen, die übersprungen wurden - - # Iteriere durch die Datenzeilen im definierten Bereich - 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 - if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht - - row = all_data[row_index_in_list] - - # Stellen Sie sicher, dass die Zeile nicht leer ist - if not any(cell and cell.strip() for cell in row): - #self.logger.debug(f"Zeile {i}: Übersprungen (Leere Zeile).") # Zu viel Lärm - skipped_count += 1 - continue - - - # --- Prüfung, ob Verarbeitung für diese Zeile nötig ist --- - # Kriterium: Wiki URL (M) ist vorhanden und nicht "k.A." etc. - # UND Wikipedia Timestamp (AN) ist leer. - - m_value = self._get_cell_value_safe(row, "Wiki URL").strip() - an_value = self._get_cell_value_safe(row, "Wikipedia Timestamp").strip() - - is_m_valid_looking = m_value and m_value.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche"] - is_an_empty = not an_value - - processing_needed_for_row = is_m_valid_looking and is_an_empty - - - # Loggen der Prüfergebnisse auf Debug - 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 Re-extract Check): M ('{m_value[:50]}...') gültig? {is_m_valid_looking}, AN leer? {is_an_empty}. Benötigt Verarbeitung? {processing_needed_for_row}") - - - if not processing_needed_for_row: - skipped_count += 1 - continue - - # --- Wenn Verarbeitung nötig: Rufe _process_single_row auf --- - processed_count += 1 # Zähle die Zeile, die verarbeitet wird (zum Limit zählen) - - # Prüfe das Limit für verarbeitete Zeilen - if limit is not None and processed_count > limit: - self.logger.info(f"Verarbeitungslimit ({limit}) für wiki_reextract_missing_an erreicht. Breche weitere Zeilenprüfung ab.") - break # Schleife abbrechen - - - self.logger.info(f"Zeile {i}: M gefüllt & AN leer. Versuche Wiki-Re-Extraktion via _process_single_row...") - - try: - # RUFE _process_single_row AUF - # Mit steps_to_run={'wiki'} und force_reeval=True, - # damit nur der Wiki-Schritt ausgeführt wird und Timestamps ignoriert werden - self._process_single_row( - row_num_in_sheet = i, - row_data = row, # Übergibt die aktuellen Rohdaten der Zeile - steps_to_run = {'wiki'}, # Nur der Wiki-Schritt soll laufen - force_reeval = True # Erzwingt die Ausführung des 'wiki' Schritts - ) - # _process_single_row loggt intern und führt das Sheet-Update durch - - except Exception as e_proc: - # Fangen Sie Fehler aus _process_single_row ab, loggen Sie sie - # und fahren Sie mit der nächsten Zeile fort. - self.logger.exception(f"FEHLER bei Verarbeitung von Zeile {i} in wiki_reextract_missing_an: {e_proc}") - # Hier könnten Sie einen Fehlerindikator in eine spezielle Spalte schreiben - - # _process_single_row beinhaltet bereits eine kleine Pause am Ende. - # Hier ist keine zusätzliche Pause nötig, 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 - - - self.logger.info(f"Modus 'wiki_reextract_missing_an' abgeschlossen. {processed_count} Zeilen an _process_single_row übergeben, {skipped_count} Zeilen übersprungen.") - # Keine Pause nach diesem Modus nötig. - - - # --- Das Ende der DataProcessor Klasse ist in der nächsten Nachricht --- -# ============================================================================== -# 7. GLOBALE FUNKTIONEN (die keiner Klasse zugeordnet sind) +# GLOBALE HELPER FUNCTIONS (PART 12: Alignment Demo Function) # ============================================================================== # --- Alignment Demo (Header schreiben) --- -# Übernommen aus alignment_demo in Teil 10. Bleibt global, da es direkt das gspread sheet Objekt verwendet. -# Nutzt COLUMN_MAP und Config.VERSION (globale Konstanten/Klasse). +# Basierend auf alignment_demo aus Teil 10. Bleibt global, da es direkt das gspread sheet Objekt verwendet. +# Nutzt globale Helfer: COLUMN_MAP, Config.VERSION, logger, traceback. def alignment_demo(sheet): - """Schreibt die Header-Struktur (Zeilen 1-5, jetzt bis Spalte AY) ins angegebene Sheet.""" - # Stellen Sie sicher, dass COLUMN_MAP die höchstbenötigte Spalte AY (Index 50) enthält. - # Und dass alle Schlüssel im new_headers[0] Array in COLUMN_MAP existieren, - # um die Indizes für die Beschreibungen in den anderen Header-Zeilen zu finden. - # Diese Funktion nimmt an, dass COLUMN_MAP komplett und korrekt ist. + """ + Schreibt die vordefinierte Header-Struktur (Zeilen 1-5) ins angegebene Google Sheet. + Dies dient als "Alignment" der Spalten und zur Dokumentation im Sheet selbst. - # Header-Texte für die ersten 5 Zeilen - # Die Reihenfolge der Spalten muss EXACT mit der Reihenfolge der Schlüssel in COLUMN_MAP übereinstimmen, - # oder zumindest mit der Reihenfolge, wie sie hier im Array new_headers[0] aufgeführt ist, - # da diese Funktion nicht dynamisch aus COLUMN_MAP liest, sondern feste Listen hat. - # Stellen Sie sicher, dass diese Listen die gleiche LÄNGE haben wie COLUMN_MAP. - # Zählen Sie die Einträge: ReEval Flag (0) bis SerpAPI Wiki Search Timestamp (50) = 51 Spalten (Index 0-50). - # Überprüfen Sie, dass jede Liste unten 51 Einträge hat. + Args: + sheet (gspread.Worksheet): Das Worksheet-Objekt zum Schreiben der Header. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Stellen Sie sicher, dass COLUMN_MAP die hoechstbenuetigte Spalte AY (Index 50) enthaelt. + # Diese Funktion nimmt an, dass COLUMN_MAP komplett und korrekt ist. + # Die Listen unten muessen exakt die gleiche Laenge haben wie COLUMN_MAP. + + # Header-Texte fuer die ersten 5 Zeilen + # Die Reihenfolge der Spalten muss EXACT mit der Reihenfolge der Schluessel in COLUMN_MAP uebereinstimmen. + # Zaehlen Sie die Eintraege: ReEval Flag (Index 0) bis SerpAPI Wiki Search Timestamp (Index 50) = 51 Spalten (Index 0-50). + # Ueberpruefen Sie, dass jede Liste unten 51 Eintraege hat. new_headers = [ - # Zeile 1: Spaltenname - ["ReEval Flag", "CRM Name", "CRM Kurzform", "CRM Website", "CRM Ort", "CRM Beschreibung", "CRM Branche", "CRM Beschreibung Branche extern", "CRM Anzahl Techniker", "CRM Umsatz", "CRM Anzahl Mitarbeiter", "CRM Vorschlag Wiki URL", "Wiki URL", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Chat Wiki Konsistenzprüfung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", "Begründung bei Abweichung", "Chat Vorschlag Branche", "Chat Konsistenz Branche", "Chat Begründung Abweichung Branche", "Chat Prüfung FSM Relevanz", "Chat Begründung für FSM Relevanz", "Chat Schätzung Anzahl Mitarbeiter", "Chat Konsistenzprüfung Mitarbeiterzahl", "Chat Begründung Abweichung Mitarbeiterzahl", "Chat Einschätzung Anzahl Servicetechniker", "Chat Begründung Abweichung Anzahl Servicetechniker", "Chat Schätzung Umsatz", "Chat Begründung Abweichung Umsatz", "Linked Serviceleiter gefunden", "Linked It-Leiter gefunden", "Linked Management gefunden", "Linked Disponent gefunden", "Contact Search Timestamp", "Wikipedia Timestamp", "Timestamp letzte Prüfung", "Version", "Tokens", "Website Rohtext", "Website Zusammenfassung", "Website Scrape Timestamp", "Geschätzter Techniker Bucket", "Finaler Umsatz (Wiki>CRM)", "Finaler Mitarbeiter (Wiki>CRM)", "Wiki Verif. Timestamp", "SerpAPI Wiki Search Timestamp"], + # Zeile 1: Spaltenname (Muss mit COLUMN_MAP Schluesseln uebereinstimmen, falls dynamisch genutzt) + ["ReEval Flag", "CRM Name", "CRM Kurzform", "CRM Website", "CRM Ort", "CRM Beschreibung", "CRM Branche", "CRM Beschreibung Branche extern", "CRM Anzahl Techniker", "CRM Umsatz", "CRM Anzahl Mitarbeiter", "CRM Vorschlag Wiki URL", "Wiki URL", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Chat Wiki Konsistenzpruefung", "Chat Begruendung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", "Begruendung bei Abweichung", "Chat Vorschlag Branche", "Chat Konsistenz Branche", "Chat Begruendung Abweichung Branche", "Chat Pruefung FSM Relevanz", "Chat Begruendung fuer FSM Relevanz", "Chat Schaetzung Anzahl Mitarbeiter", "Chat Konsistenzpruefung Mitarbeiterzahl", "Chat Begruendung Abweichung Mitarbeiterzahl", "Chat Einschaetzung Anzahl Servicetechniker", "Chat Begruendung Abweichung Anzahl Servicetechniker", "Chat Schaetzung Umsatz", "Chat Begruendung Abweichung Umsatz", "Linked Serviceleiter gefunden", "Linked It-Leiter gefunden", "Linked Management gefunden", "Linked Disponent gefunden", "Contact Search Timestamp", "Wikipedia Timestamp", "Timestamp letzte Pruefung", "Version", "Tokens", "Website Rohtext", "Website Zusammenfassung", "Website Scrape Timestamp", "Geschaetzter Techniker Bucket", "Finaler Umsatz (Wiki>CRM)", "Finaler Mitarbeiter (Wiki>CRM)", "Wiki Verif. Timestamp", "SerpAPI Wiki Search Timestamp"], # Zeile 2: Quelle der Daten - ["CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "System", "System", "System", "System", "System", "Web Scraper", "Chat GPT API", "System", "ML Modell / Skript", "Skript (Wiki/CRM)", "Skript (Wiki/CRM)", "System", "System"], + ["CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "LinkedIn (ueber SerpApi)", "LinkedIn (ueber SerpApi)", "LinkedIn (ueber SerpApi)", "LinkedIn (ueber SerpApi)", "System", "System", "System", "System", "System", "Web Scraper", "Chat GPT API", "System", "ML Modell / Skript", "Skript (Wiki/CRM)", "Skript (Wiki/CRM)", "System", "System"], # Zeile 3: Feldkategorie - ["Prozess", "Firmenname", "Firmenname", "Website", "Ort", "Beschreibung (Text)", "Branche", "Branche", "Anzahl Servicetechniker", "Umsatz", "Anzahl Mitarbeiter", "Wikipedia Artikel URL", "Wikipedia Artikel", "Beschreibung (Text)", "Branche", "Umsatz", "Anzahl Mitarbeiter", "Kategorien (Text)", "Verifizierung", "Begründung bei Abweichung", "Wikipedia Artikel", "Wikipedia Artikel", "Branche", "Branche", "Branche", "FSM Relevanz", "FSM Relevanz", "Anzahl Mitarbeiter", "Anzahl Mitarbeiter", "Anzahl Mitarbeiter", "Anzahl Servicetechniker", "Anzahl Servicetechniker", "Umsatz", "Umsatz", "Kontakte zur Firma", "Kontakte zur Firma", "Kontakte zur Firma", "Kontakte zur Firma", "Timestamp", "Timestamp", "Timestamp", "Version des Skripts die verwendet wurde", "ChatGPT Tokens", "Website-Content", "Website Zusammenfassung", "Timestamp", "Anzahl Servicetechniker Bucket", "Umsatz", "Anzahl Mitarbeiter", "Timestamp", "Timestamp"], + ["Prozess", "Firmenname", "Firmenname", "Website", "Ort", "Beschreibung (Text)", "Branche", "Branche", "Anzahl Servicetechniker", "Umsatz", "Anzahl Mitarbeiter", "Wikipedia Artikel URL", "Wikipedia Artikel", "Beschreibung (Text)", "Branche", "Umsatz", "Anzahl Mitarbeiter", "Kategorien (Text)", "Verifizierung", "Begruendung bei Abweichung", "Wikipedia Artikel", "Wikipedia Artikel", "Branche", "Branche", "Branche", "FSM Relevanz", "FSM Relevanz", "Anzahl Mitarbeiter", "Anzahl Mitarbeiter", "Anzahl Mitarbeiter", "Anzahl Servicetechniker", "Anzahl Servicetechniker", "Umsatz", "Umsatz", "Kontakte zur Firma", "Kontakte zur Firma", "Kontakte zur Firma", "Kontakte zur Firma", "Timestamp", "Timestamp", "Timestamp", "Version des Skripts die verwendet wurde", "ChatGPT Tokens", "Website-Content", "Website Zusammenfassung", "Timestamp", "Anzahl Servicetechniker Bucket", "Umsatz", "Anzahl Mitarbeiter", "Timestamp", "Timestamp"], # Zeile 4: Kurze Beschreibung - ["Systemspalte...", "Enthält den Firmennamen...", "Manuell gepflegte Kurzform...", "Website des Unternehmens.", "Ort des Unternehmens.", "Kurze Beschreibung...", "Aktuelle Branchenzuweisung...", "Externe Branchenbeschreibung...", "Recherchierte Anzahl...", "Umsatz in Mio. € (CRM).", "Anzahl Mitarbeiter (CRM).", "Vorgeschlagene Wikipedia URL...", "Wikipedia URL...", "Erster Absatz...", "Wikipedia-Branche...", "Wikipedia-Umsatz...", "Wikipedia-Mitarbeiterzahl...", "Liste der Wikipedia-Kategorien.", "\"OK\" oder \"X\" – Ergebnis...", "Begründung bei Inkonsistenz...", "Chat-Vorschlag Wiki Artikel...", "Nicht genutzt...", "Branchenvorschlag via ChatGPT...", "Vergleich: Übereinstimmung CRM vs. ...", "Begründung bei abweichender...", "FSM-Relevanz: Bewertung...", "Begründung zur FSM-Bewertung.", "Schätzung Anzahl Mitarbeiter...", "Vergleich CRM vs. Wiki vs. ...", "Begründung bei Mitarbeiterabweichung...", "Schätzung Servicetechniker...", "Begründung bei Abweichung...", "Schätzung Umsatz via ChatGPT.", "Begründung bei Umsatzabweichung.", "Anzahl Kontakte (Serviceleiter)...", "Anzahl Kontakte (IT-Leiter)...", "Anzahl Kontakte (Management)...", "Anzahl Kontakte (Disponent)...", "Timestamp der Kontaktsuche.", "Timestamp der Wikipedia-Suche/Extraktion.", "Timestamp der ChatGPT-Bewertung / Letzte Prüfung der Zeile.", "Ausgabe der Skriptversion...", "Token-Zählung...", "Roh extrahierter Text...", "Zusammenfassung des Webseiteninhalts...", "Timestamp des letzten Website-Scrapings (AR, AS).", "Ergebnis der Schätzung durch das trainierte ML-Modell.", "Konsolidierter Umsatz (Mio €) nach Priorität Wiki > CRM.", "Konsolidierte Mitarbeiterzahl nach Priorität Wiki > CRM.", "Timestamp der letzten Wiki-Verifikation (Spalten S-U).", "Timestamp der letzten SerpAPI-Suche nach fehlender Wiki-URL (Modus find_wiki_serp)."] + ["Systemspalte...", "Enthaehlt den Firmennamen...", "Manuell gepflegte Kurzform...", "Website des Unternehmens.", "Ort des Unternehmens.", "Kurze Beschreibung...", "Aktuelle Branchenzuweisung...", "Externe Branchenbeschreibung...", "Recherchierte Anzahl...", "Umsatz in Mio. € (CRM).", "Anzahl Mitarbeiter (CRM).", "Vorgeschlagene Wikipedia URL...", "Wikipedia URL...", "Erster Absatz...", "Wikipedia-Branche...", "Wikipedia-Umsatz...", "Wikipedia-Mitarbeiterzahl...", "Liste der Wikipedia-Kategorien.", "\"OK\" oder \"X\" – Ergebnis...", "Begruendung bei Inkonsistenz...", "Chat-Vorschlag Wiki Artikel...", "Nicht genutzt...", "Branchenvorschlag ueber ChatGPT...", "Vergleich: Uebereinstimmung CRM vs. ...", "Begruendung bei abweichender...", "FSM-Relevanz: Bewertung...", "Begruendung zur FSM-Bewertung.", "Schaetzung Anzahl Mitarbeiter...", "Vergleich CRM vs. Wiki vs. ...", "Begruendung bei Mitarbeiterabweichung...", "Schaetzung Servicetechniker...", "Begruendung bei Abweichung...", "Schaetzung Umsatz ueber ChatGPT.", "Begruendung bei Umsatzabweichung.", "Anzahl Kontakte (Serviceleiter)...", "Anzahl Kontakte (IT-Leiter)...", "Anzahl Kontakte (Management)...", "Anzahl Kontakte (Disponent)...", "Timestamp der Kontaktsuche.", "Timestamp der Wikipedia-Suche/Extraktion.", "Timestamp der ChatGPT-Bewertung / Letzte Pruefung der Zeile.", "Ausgabe der Skriptversion...", "Token-Zaehlung...", "Roh extrahierter Text...", "Zusammenfassung des Webseiteninhalts...", "Timestamp des letzten Website-Scrapings (AR, AS).", "Ergebnis der Schaetzung durch das trainierte ML-Modell.", "Konsolidierter Umsatz (Mio €) nach Prioritaet Wiki > CRM.", "Konsolidierte Mitarbeiterzahl nach Prioritaet Wiki > CRM.", "Timestamp der letzten Wiki-Verifikation (Spalten S-U).", "Timestamp der letzten SerpAPI-Suche nach fehlender Wiki-URL (Modus find_wiki_serp)."] # Zeile 5: Aufgabe / Funktion - # Stellen Sie sicher, dass die Spaltenreihenfolge hier mit den anderen Zeilen übereinstimmt! - # Dies ist die längste Zeile und kann schwierig zu pflegen sein. - # Zählen Sie die Einträge sorgfältig. - ,["Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Wird durch Wikipedia Scraper bereitgestellt", "Wird zunächst nicht verwendet...", "Wird u.a. zur finalen Ermittlung...", "Wird u.a. mit CRM-Umsatz...", "Wird u.a. mit CRM-Anzahl...", "Wenn Website-Daten fehlen...", "\"Es soll durch ChatGPT geprüft werden...", "\"Liegt eine Inkonsistenz...", "\"Sollte durch die Wikipedia-Suche...", "XXX derzeit nicht verwendet...", "\"ChatGPT soll anhand der vorliegenden...", "Die in Spalte CRM festgelegte...", "Weicht die von ChatGPT ermittelte...", "ChatGPT soll anhand der vorliegenden Daten prüfen...", "Die in 'Chat Begründung für FSM Relevanz'...", "Nur wenn kein Wikipedia-Eintrag...", "Entspricht die durch ChatGPT ermittelte...", "Weicht die von ChatGPT geschätzte...", "ChatGPT soll auf Basis öffentlich...", "Weicht die von ChatGPT geschätzte...", "Nur wenn kein Wikipedia-Eintrag...", "ChatGPT soll signifikante Umsatzabweichungen...", "Über SerpAPI wird zusammen...", "Über SerpAPI wird zusammen...", "Über SerpAPI wird zusammen...", "Über SerpAPI wird zusammen...", "Wenn die Kontaktsuche gestartet wird...", "Wenn die Wikipedia-Suche gestartet wird...", "Wenn die ChatGPT-Bewertung gestartet wird...", "Wird durch das System befüllt", "Wird durch tiktoken berechnet", "Wird durch Web Scraper...", "Wird durch ChatGPT API...", "Timestamp wird gesetzt, wenn Website Rohtext/Zusammenfassung geschrieben werden.", "Ergebnis der Schätzung durch das trainierte ML-Modell.", "Vom Skript berechneter Wert, priorisiert Wiki > CRM...", "Vom Skript berechneter Wert, priorisiert Wiki > CRM...", "Timestamp wird gesetzt, wenn Wiki-Verifikation (S-Y) durchgeführt wurde.", "Timestamp wird gesetzt, nachdem versucht wurde, eine fehlende Wiki-URL via SerpAPI zu finden."] + # Stellen Sie sicher, dass die Spaltenreihenfolge hier mit den anderen Zeilen uebereinstimmt! + # Dies ist die laengste Zeile und kann schwierig zu pflegen sein. + # Zaehlen Sie die Eintraege sorgfaeltig (51 Eintraege). + ,["Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Wird durch Wikipedia Scraper bereitgestellt", "Wird zunaechst nicht verwendet...", "Wird u.a. zur finalen Ermittlung...", "Wird u.a. mit CRM-Umsatz...", "Wird u.a. mit CRM-Anzahl...", "Wenn Website-Daten fehlen...", "\"Es soll durch ChatGPT geprueft werden...", "\"Liegt eine Inkonsistenz...", "\"Sollte durch die Wikipedia-Suche...", "XXX derzeit nicht verwendet...", "\"ChatGPT soll anhand der vorliegenden...", "Die in Spalte CRM festgelegte...", "Weicht die von ChatGPT ermittelte...", "ChatGPT soll anhand der vorliegenden Daten pruefen...", "Die in 'Chat Begruendung fuer FSM Relevanz'...", "Nur wenn kein Wikipedia-Eintrag...", "Entspricht die durch ChatGPT ermittelte...", "Weicht die von ChatGPT geschaetzte...", "ChatGPT soll auf Basis oeffentlich...", "Weicht die von ChatGPT geschaetzte...", "Nur wenn kein Wikipedia-Eintrag...", "ChatGPT soll signifikante Umsatzabweichungen...", "Ueber SerpAPI wird zusammen...", "Ueber SerpAPI wird zusammen...", "Ueber SerpAPI wird zusammen...", "Ueber SerpAPI wird zusammen...", "Wenn die Kontaktsuche gestartet wird...", "Wenn die Wikipedia-Suche gestartet wird...", "Wenn die ChatGPT-Bewertung gestartet wird...", "Wird durch das System befuellt", "Wird durch tiktoken berechnet", "Wird durch Web Scraper...", "Wird durch ChatGPT API...", "Timestamp wird gesetzt, wenn Website Rohtext/Zusammenfassung geschrieben werden.", "Ergebnis der Schaetzung durch das trainierte ML-Modell.", "Vom Skript berechneter Wert, priorisiert Wiki > CRM...", "Vom Skript berechneter Wert, priorisiert Wiki > CRM...", "Timestamp wird gesetzt, wenn Wiki-Verifikation (S-U) durchgefuehrt wurde.", "Timestamp wird gesetzt, nachdem versucht wurde, eine fehlende Wiki-URL ueber SerpAPI zu finden."] ] # Stellen Sie sicher, dass die Anzahl der Spalten in allen Header-Zeilen gleich ist num_cols = len(new_headers[0]) if not all(len(row) == num_cols for row in new_headers): + # Logge einen kritischen Fehler, aber versuche trotzdem zu schreiben logger.critical(f"FEHLER in alignment_demo: Die Anzahl der Spalten in den Header-Zeilen ist nicht konsistent! Erwartet {num_cols} pro Zeile.") - # Versuchen Sie trotzdem zu schreiben, aber der Fehler ist schwerwiegend. - # Finden Sie die maximale Anzahl Spalten, um den Bereich zu bestimmen + # Finden Sie die maximale Anzahl Spalten, um den Bereich zu bestimmen, falls inkonsistent num_cols = max(len(row) for row in new_headers) @@ -6968,50 +2367,7842 @@ def alignment_demo(sheet): string = chr(65 + remainder) + string return string - # Berechnen Sie den Bereich für das Update (z.B. A1:AY5) + # Berechnen Sie den Bereich fuer das Update (z.B. A1:AY5) end_col_letter = colnum_string(num_cols) header_range = f"A1:{end_col_letter}{len(new_headers)}" logger.info(f"Schreibe Alignment-Demo Header in Bereich {header_range}...") try: - # Führen Sie das Update durch - # Verwenden Sie batch_update für Effizienz, obwohl es nur ein einzelner großer Bereich ist - # oder sheet.update direkt. sheet.update ist einfacher für einen einzelnen Bereich. + # Fuehren Sie das Update durch + # sheet.update direkt ist einfacher fuer einen einzelnen grossen Bereich. + # value_input_option='USER_ENTERED' stellt sicher, dass Werte wie sie sind eingetragen werden. sheet.update(values=new_headers, range_name=header_range, value_input_option='USER_ENTERED') logger.info(f"Alignment-Demo Header erfolgreich geschrieben in Bereich {header_range}.") except Exception as e: + # Fange Fehler beim Schreiben ab und logge sie logger.error(f"FEHLER beim Schreiben der Alignment-Demo Header in Bereich {header_range}: {e}") + # Logge den Traceback fuer detaillierte Fehlerinformationen logger.debug(traceback.format_exc()) # ============================================================================== -# 8. MAIN FUNCTION (HAUPTEINSTIEGSPUNKT & UI DISPATCHER) +# Ende Alignment Demo Funktion Block # ============================================================================== +# ============================================================================== +# 4. HANDLER CLASSES (Google Sheet & Wikipedia) +# ============================================================================== + +# --- GOOGLE SHEET HANDLER CLASS --- +# Kapselt die Interaktionen mit dem Google Sheet. +# Nutzt globale Helfer: retry_on_failure, Config, CREDENTIALS_FILE, logger, +# gspread, ServiceAccountCredentials, os, datetime, time, traceback. +class GoogleSheetHandler: + """ + Kapselt die Interaktionen mit dem Google Sheet, inklusive Verbindung, + Daten laden und Batch-Updates. Nutzt den retry_on_failure Decorator. + """ + def __init__(self): + """ + Initialisiert den Handler, stellt die Verbindung her und laedt die Daten. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Initialisieren Sie die Attribute + self.sheet = None + # Daten werden hier als Instanzvariable gespeichert, um nicht bei jedem Zugriff neu laden zu muessen + self.sheet_values = [] + # header_rows sind fix, aber wir koennen sie hier zur Klarheit definieren + self._header_rows = 5 # Annahme: Die ersten 5 Zeilen sind Header + + logger.info("Initialisiere GoogleSheetHandler...") + try: + # Verbindung wird bei der Initialisierung aufgebaut + # Der _connect Aufruf ist mit retry_on_failure dekoriert. + # Wenn _connect eine Exception wirft (auch nach Retries), wird diese hier gefangen. + self._connect() + # Daten werden ebenfalls bei der Initialisierung geladen, nur wenn die Verbindung erfolgreich war + if self.sheet: + # Der load_data Aufruf ist mit retry_on_failure dekoriert. + # Wenn load_data eine Exception wirft (auch nach Retries), wird diese hier gefangen. + self.load_data() # Erste Datenladung nach erfolgreicher Verbindung + else: + # Wenn die Verbindung fehlschlug (sheet ist None), aber keine Exception geworfen wurde (sollte nicht passieren) + logger.critical("GoogleSheetHandler Init FEHLER: Verbindung konnte nicht hergestellt werden (sheet ist None).") + # Werfen Sie eine spezifische Exception + raise ConnectionError("Google Sheet Handler Init failed: Verbindung konnte nicht hergestellt werden.") + + except Exception as e: + # Fehler bei der Initialisierung (entweder von _connect oder load_data nach Retries) + # werden hier gefangen und erneut geworfen, damit die main-Funktion (Block 34) entsprechend reagieren kann. + logger.critical(f"FATAL: Fehler bei Initialisierung von GoogleSheetHandler: {type(e).__name__} - {e}") + # Loggen Sie den Traceback fuer detailliertere Fehlerinformationen + logger.debug(traceback.format_exc()) + # Werfen Sie eine aussagekraeftige Exception + raise ConnectionError(f"Google Sheet Handler Init failed: {e}") # Signalisiert Verbindungsproblem + + + @retry_on_failure # Wende den Decorator an, da es externe Calls macht + def _connect(self): + """Stellt Verbindung zum Google Sheet her.""" + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + self.sheet = None # Setze sheet vor dem Versuch auf None + logger.info("Versuche Verbindung mit Google Sheets herstellen...") + try: + # Stellen Sie sicher, dass CREDENTIALS_FILE korrekt ist + if not os.path.exists(CREDENTIALS_FILE): + # Werfen Sie FileNotFoundError, dies wird vom retry_on_failure als permanent behandelt. + raise FileNotFoundError(f"Credential-Datei nicht gefunden: {CREDENTIALS_FILE}") + + # Definieren Sie den Scope fuer den Zugriff auf Google Sheets + scope = ["https://www.googleapis.com/auth/spreadsheets"] + # Laden Sie die Credentials aus der JSON-Datei + creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope) + # Autorisiere die Verbindung mit den Credentials + gc = gspread.authorize(creds) + # Oeffne das Sheet ueber die URL aus Config + # Dieser Aufruf kann gspread.exceptions.SpreadsheetNotFound werfen, was vom Decorator behandelt wird. + # Er kann auch andere gspread.exceptions.APIError oder requests.exceptions.RequestException werfen. + sh = gc.open_by_url(Config.SHEET_URL) # Nutzt die URL aus Config (Block 1) + + # Greife auf das erste Blatt zu (Index 0, ueblicherweise "Tabelle1") + # Dieser Aufruf kann gspread.exceptions.WorksheetNotFound werfen, wenn das erste Blatt fehlt. + # Wenn das erste Blatt fehlt, ist das ein Konfigurationsproblem und sollte eine Exception werfen. + self.sheet = sh.sheet1 # Greift auf das erste Blatt zu (Index 0) + + logger.info("Verbindung zu Google Sheets erfolgreich.") + # Die Methode gibt implizit None zurueck, wenn keine Exception geworfen wird. + + + # Spezifische Fehlerbehandlung fuer gspread/requests Fehler, die vom Decorator behandelt werden. + # Wenn hier eine Exception durchkommt, hat der Decorator (nach Retries oder weil permanent) aufgegeben. + except (gspread.exceptions.APIError, requests.exceptions.RequestException, FileNotFoundError) as e: + # Der Decorator hat diesen Fehler bereits geloggt und (falls nicht permanent) Retries versucht. + # Werfen Sie den Fehler erneut, damit der Aufrufer (die __init__ Methode) ihn fangen kann. + raise e + + except Exception as e: + # Fangen Sie andere unerwartete Verbindungsfehler ab (z.B. Probleme mit der Bibliothek selbst) + logger.error(f"FEHLER bei der Google Sheets Verbindung: {type(e).__name__} - {e}") + # Loggen Sie den Traceback fuer detaillierte Fehlerinformationen + logger.debug(traceback.format_exc()) + # Werfen Sie die Exception erneut, damit der Aufrufer sie behandeln kann + raise e + + + @retry_on_failure # Wende den Decorator an, da es externe Calls macht + def load_data(self): + """ + Laedt alle Daten aus dem Sheet und aktualisiert self.sheet_values. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Pruefen Sie, ob eine Sheet-Verbindung vorhanden ist + if not self.sheet: + logger.error("Fehler: Keine Sheet-Verbindung zum Laden der Daten.") + self.sheet_values = [] # Stelle sicher, dass die Datenliste leer ist + return False # Signalisiert Fehler beim Laden + + logger.info("Lade Daten aus Google Sheet...") + try: + # Lade alle Werte aus dem Sheet. Dieser Aufruf kann Exceptions werfen. + # get_all_values() laedt standardmaessig das erste Blatt ("Tabelle1"). + # Wenn das Blatt umbenannt wurde oder fehlt, kann dies einen Fehler werfen. + self.sheet_values = self.sheet.get_all_values() + + # Wenn get_all_values eine leere Liste zurueckgibt (z.B. Sheet ist leer) + if not self.sheet_values: + logger.warning("Google Sheet scheint leer zu sein oder get_all_values() lieferte keine Daten.") + # Wenn die erste Zeile nicht geladen werden kann (z.B. leeres Sheet), headers ist leer + self.headers = [] # Setzen Sie die Header-Liste auf leer + return True # Ladevorgang war technisch erfolgreich (keine Exception), aber keine Daten + + + # Logge die Anzahl der Zeilen und Spalten, die geladen wurden + num_rows = len(self.sheet_values) + num_cols = len(self.sheet_values[0]) if num_rows > 0 else 0 + logger.info(f"Daten neu geladen: {num_rows} Zeilen, {num_cols} Spalten.") + + # Optional: Ueberpruefen Sie, ob die Anzahl der Spalten mindestens dem hoechsten Index in COLUMN_MAP entspricht + try: + # Finden Sie den hoechsten Index in COLUMN_MAP (Block 1) + max_col_idx_in_map = max(COLUMN_MAP.values()) + # Ueberpruefen Sie, ob die Anzahl der geladenen Spalten ausreicht + if num_cols <= max_col_idx_in_map: # Verwenden Sie <= weil Indizes 0-basiert sind + # Logge eine Warnung, wenn das Mapping auf Spalten zeigt, die nicht geladen wurden + logger.warning(f"Geladenes Sheet hat {num_cols} Spalten, erwartet werden aber mindestens {max_col_idx_in_map + 1} basierend auf COLUMN_MAP. Das COLUMN_MAP passt moeglicherweise nicht zum Sheet!") + except ValueError: # Tritt auf, wenn COLUMN_MAP leer ist + logger.warning("COLUMN_MAP scheint leer zu sein oder enthaelt keine Werte. Kann Spaltenanzahl nicht pruefen.") + except Exception as e: + # Fangen Sie andere unerwartete Fehler bei der Pruefung ab + logger.error(f"Fehler bei der Pruefung der Spaltenanzahl gegen COLUMN_MAP: {e}") + + + # Speichere die erste Zeile als Header-Namen (optional, kann fuer spaetere Zuordnung nuetzlich sein) + # Dies sollte erst nach Pruefung auf leere sheet_values geschehen + if num_rows > 0: + self.headers = self.sheet_values[0] + else: + self.headers = [] + + + return True # Signalisiert erfolgreiches Laden (auch wenn keine Daten da sind) + + # Spezifische Fehlerbehandlung fuer gspread/requests Fehler, die vom Decorator behandelt werden. + # Wenn hier eine Exception durchkommt, hat der Decorator (nach Retries) aufgegeben. + except (gspread.exceptions.APIError, requests.exceptions.RequestException) as e: + # Der Decorator hat diesen Fehler bereits geloggt und Retries versucht. + # Werfen Sie den Fehler erneut, damit der Aufrufer (die __init__ Methode) ihn fangen kann. + raise e + + except Exception as e: + # Fangen Sie andere unerwartete Ladefehler ab + logger.error(f"Allgemeiner Fehler beim Laden der Google Sheet Daten: {type(e).__name__} - {e}") + # Loggen Sie den Traceback + logger.debug(traceback.format_exc()) + # Werfen Sie die Exception erneut, damit der Aufrufer sie behandeln kann + raise e + + + def get_data(self): + """ + Gibt die aktuell im Handler gespeicherten Datenzeilen zurueck + (ohne die ersten N Header-Zeilen). + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Pruefen Sie, ob Daten geladen wurden und ob genuegend Zeilen fuer Header vorhanden sind + if not self.sheet_values or len(self.sheet_values) <= self._header_rows: + # Logge nur auf Debug, da dies oft passiert, wenn das Sheet leer ist oder nur Header enthaelt + logger.debug(f"get_data: Keine Datenzeilen verfuegbar (geladen: {len(self.sheet_values) if self.sheet_values else 0} Zeilen, {self._header_rows} Header).") + return [] # Gebe eine leere Liste zurueck, wenn keine Datenzeilen vorhanden sind + + # Gibt eine Slice der Liste zurueck (Kopie, um unbeabsichtigte Aenderungen am Original zu vermeiden) + return self.sheet_values[self._header_rows:].copy() + + + def get_all_data_with_headers(self): + """Gibt alle aktuell im Handler gespeicherten Daten inklusive Header zurueck.""" + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Pruefen Sie, ob Daten geladen wurden + if not self.sheet_values: + logger.debug("get_all_data_with_headers: Keine Daten im Handler gespeichert.") + return [] # Gebe eine leere Liste zurueck, wenn keine Daten geladen wurden + + # Geben Sie eine Kopie der gesamten Datenliste zurueck + return self.sheet_values.copy() + + + def _get_col_letter(self, col_idx_1_based): + """ + Konvertiert einen 1-basierten Spaltenindex in den entsprechenden + Google Sheets Spaltenbuchstaben (A, B, ..., Z, AA, ...). + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Pruefen Sie, ob der Index ein gueltiger Integer und positiv ist + if not isinstance(col_idx_1_based, int) or col_idx_1_based < 1: + # Logge den Fehler auf Error-Level + logger.error(f"Ungueltiger Spaltenindex ({col_idx_1_based}) fuer _get_col_letter erhalten.") + return None # Gebe None zurueck bei ungueltigem Index + + # Implementierung zur Konvertierung von Zahl zu Buchstaben + string = "" + n = col_idx_1_based + while n > 0: + n, remainder = divmod(n - 1, 26) + string = chr(65 + remainder) + string + return string # Gebe den berechneten Spaltenbuchstaben zurueck + + + def get_start_row_index(self, check_column_key, min_sheet_row=7): + """ + Findet den 0-basierten Index in der DATENliste (ohne Header), + ab einer Mindestzeilennummer im Sheet, in der der Wert in der + Spalte (definiert durch check_column_key) EXAKT LEER ("") ist. + Laedt die Daten vor der Pruefung neu. + + Args: + check_column_key (str): Der Schluessel in COLUMN_MAP fuer die zu pruefende Spalte. + min_sheet_row (int): Die 1-basierte Zeilennummer im Sheet, ab der gesucht werden soll. + Standardmaessig ab Zeile 7 (erste Zeile nach 5 Headern und einer leeren). + + Returns: + int: Der 0-basierte Index in der Datenliste (ohne Header) der ersten Zeile + mit einem EXAKT leeren Wert in der Zielspalte innerhalb des Suchbereichs. + Gibt -1 zurueck bei schwerwiegenden Fehlern (z.B. Schluessel fehlt in COLUMN_MAP). + Gibt die Laenge der Datenliste zurueck, wenn keine leere Zelle im Suchbereich gefunden wurde. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Daten neu laden, um sicherzustellen, dass sie aktuell sind + if not self.load_data(): + logger.error("Fehler beim Laden der Daten fuer get_start_row_index.") + return -1 # Signalisiert Fehler beim Laden + + data_rows = self.get_data() # Datenzeilen ohne Header (Kopie) + # Wenn keine Datenzeilen vorhanden sind + if not data_rows: + logger.info("Keine Datenzeilen im Sheet gefunden. Startindex fuer leere Zelle ist 0.") + return 0 # Die erste Datenzeile (Index 0) waere der Start + + + # Ermitteln Sie den Index der zu pruefenden Spalte aus COLUMN_MAP (Block 1) + check_column_index = COLUMN_MAP.get(check_column_key) + if check_column_index is None: + logger.critical(f"FEHLER: Schluessel '{check_column_key}' nicht in COLUMN_MAP gefunden fuer get_start_row_index!") + return -1 # Signalisiert Fehler (Schluessel fehlt) + + # Ermitteln Sie den Spaltenbuchstaben fuer Logging + actual_col_letter = self._get_col_letter(check_column_index + 1) + # Wenn Spaltenbuchstabe nicht ermittelt werden konnte (ungueltiger Index), loggen wir es. + if actual_col_letter is None: + logger.error(f"FEHLER: Konnte Spaltenbuchstaben fuer Index {check_column_index + 1} nicht ermitteln.") + # Gehen Sie weiter, aber die Logs werden weniger informativ sein. + actual_col_letter = f"Index_{check_column_index + 1}" + + + # Berechne den Startindex in der 0-basierten 'data_rows' Liste + # min_sheet_row (1-basiert) -> 0-basierten Index in all_data -> 0-basierten Index in data_rows + # 1-basierte Sheet-Zeilennummer minus 1 ergibt den 0-basierten Index in der Gesamtliste (all_data) + # Den Index der Header-Zeilen subtrahieren ergibt den 0-basierten Index in der Datenliste (data_rows) + search_start_index_in_data = max(0, (min_sheet_row - 1) - self._header_rows) + + + logger.info(f"get_start_row_index: Suche ab Daten-Index {search_start_index_in_data} (Sheet-Zeile {search_start_index_in_data + self._header_rows + 1}) nach EXAKT LEEREM Wert (=='') in Spalte '{check_column_key}' ({actual_col_letter})...") + + # Pruefen Sie, ob der Start-Suchindex ausserhalb der Datenliste liegt + if search_start_index_in_data >= len(data_rows): + # Wenn der Startindex groesser oder gleich der Laenge der Datenliste ist, + # bedeutet dies, dass der gesamte Suchbereich ausserhalb der vorhandenen Daten liegt. + logger.warning(f"Start-Suchindex in Daten ({search_start_index_in_data}) liegt hinter der letzten Datenzeile ({len(data_rows)}). Keine leere Zelle gefunden im Suchbereich.") + # Rueckgabe der Laenge der Datenliste signalisiert, dass keine leere Zelle im Suchbereich gefunden wurde. + return len(data_rows) + + # Iteriere ueber die Datenzeilen ab dem berechneten Startindex + for i in range(search_start_index_in_data, len(data_rows)): + row = data_rows[i] # Die aktuelle Zeile in der Datenliste (0-basiert) + current_sheet_row = i + self._header_rows + 1 # 1-basierte Sheet-Zeilennummer fuer Logging + + cell_value = ""; is_exactly_empty = True + # Ueberpruefe, ob die Zeile lang genug ist, um auf die Spalte zuzugreifen + if len(row) > check_column_index: + cell_value = row[check_column_index] # Hole den Wert in der Zielspalte + # Pruefen Sie, ob der Wert EXAKT ein leerer String ist + if cell_value != "": is_exactly_empty = False + else: + # Wenn die Zeile nicht lang genug ist, gilt die Zelle in der Zielspalte als leer + is_exactly_empty = True + + # Logge die ersten paar Zeilen, jede 1000. Zeile oder wenn eine leere Zelle gefunden wird + log_debug = (i < search_start_index_in_data + 5) or (i % 1000 == 0) or is_exactly_empty + if log_debug: + # Logge den Wert und den Status der Pruefung + logger.debug(f" -> Pruefe Daten-Index {i} (Sheet {current_sheet_row}): Wert in {actual_col_letter}='{str(cell_value).strip()}' (Roh='{cell_value}' Typ: {type(cell_value)}). Ist exakt leer ('')? {is_exactly_empty}") + + # Wenn eine exakt leere Zelle gefunden wurde + if is_exactly_empty: + logger.info(f"Erste Zeile ab Sheet-Zeile {min_sheet_row} mit EXAKT LEEREM Wert in Spalte {actual_col_letter} gefunden: Zeile {current_sheet_row} (Daten-Index {i})") + return i # Gebe den 0-basierten Index in der Datenliste zurueck (dies ist der Startindex fuer die Verarbeitung) + + # Wenn die Schleife durchlaeuft, ohne eine exakt leere Zelle im Suchbereich zu finden + last_data_index = len(data_rows) # Der Index nach der letzten Datenzeile + logger.info(f"Alle Zeilen ab Daten-Index {search_start_index_in_data} im Suchbereich haben einen nicht-leeren Wert in Spalte {actual_col_letter}. Naechster Daten-Index waere {last_data_index}.") + # Rueckgabe der Laenge der Datenliste signalisiert, dass keine leere Zelle gefunden wurde. + return last_data_index + + + @retry_on_failure # Wende den Decorator an, da es externe Calls macht + def batch_update_cells(self, update_data): + """ + Fuehrt ein Batch-Update im Google Sheet durch. Beinhaltet robustere + Fehlerbehandlung. + + Args: + update_data (list): Eine Liste von Dictionaries, jedes mit 'range' (str) + und 'values' (list of lists). + z.B. [{'range': 'A1', 'values': [['Wert']]}, ...] + + Returns: + bool: True bei Erfolg (nach allen Retries), False bei endgueltigem Fehler. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Pruefen Sie, ob eine Sheet-Verbindung vorhanden ist + if not self.sheet: + logger.error("FEHLER: Keine Sheet-Verbindung fuer Batch-Update.") + return False # Signalisiert Fehler + + # Wenn keine Update-Daten vorhanden sind + if not update_data: + # logger.debug("Keine Daten fuer Batch-Update vorhanden.") # Zu viel Laerm im Debug + return True # Nichts zu tun ist technisch ein Erfolg + + + # Die retry_on_failure Logik kuemmert sich um die Wiederholung und das Werfen + # der Exception im Fehlerfall. Wir muessen hier nur den Aufruf machen und + # das Ergebnis (oder die Exception) weitergeben. + + try: + # Schaetze die Anzahl der zu aktualisierenden Zellen fuer Logging + total_cells_to_update = sum(len(row) for item in update_data for row in item.get('values', [])) + logger.debug(f" -> Versuche sheet.batch_update mit {len(update_data)} Anfragen ({total_cells_to_update} Zellen)...") + + # Fuehren Sie das Batch-Update durch. gspread.batch_update wirft bei Fehlern Exceptions, + # die vom @retry_on_failure Decorator gefangen werden. + # value_input_option='USER_ENTERED' interpretiert die Eingaben wie ein Nutzer. + self.sheet.batch_update(update_data, value_input_option='USER_ENTERED') + + # Wenn keine Exception aufgetreten ist, war der Aufruf (ggf. nach Retries) erfolgreich. + # logger.debug(f" -> sheet.batch_update erfolgreich abgeschlossen.") # Zu viel Laerm im Debug + return True # Signalisiert Erfolg + + # Exceptions werden vom retry_on_failure gefangen und (im Fehlerfall) neu geworfen. + # Wenn eine Exception hier durchkommt, hat retry_on_failure aufgegeben. + except Exception as e: + # Der endgueltige Fehler wurde bereits vom Decorator geloggt. + # Wir fangen ihn hier nur, um False zurueckzugeben, wie in der Signatur versprochen. + # Das re-raising im Decorator sorgt dafuer, dass wir hier landen, wenn der Decorator aufgibt. + logger.error(f"Endgueltiger Fehler beim Batch-Update nach Retries. Kann {len(update_data)} Operationen nicht durchfuehren.") + # Der Traceback wurde bereits vom Decorator (im except Exception Fall) geloggt. + return False # Signalisiert endgueltigen Fehler + + +# --- WIKIPEDIA SCRAPER CLASS --- +# Handhabt das Suchen und Extrahieren von Daten aus Wikipedia. +# Nutzt globale Helfer: wikipedia, requests, BeautifulSoup, Config, getattr, logger, +# retry_on_failure, simple_normalize_url, normalize_company_name, +# extract_numeric_value, clean_text, unquote, time, traceback. +class WikipediaScraper: + """ + Handhabt das Suchen von Wikipedia-Artikeln und das Extrahieren relevanter + Unternehmensdaten. Beinhaltet Validierungslogik fuer Artikel. + Nutzt die wikipedia-Bibliothek und Requests fuer direktes HTML-Scraping. + """ + def __init__(self, user_agent=None): + """ + Initialisiert den Scraper mit einer Requests-Session und konfigurierter + Wikipedia-Bibliothek. + + Args: + user_agent (str, optional): Der User-Agent fuer Requests. + Defaults to Config.USER_AGENT. + """ + # Erhalten Sie eine Logger-Instanz fuer diese Klasse + self.logger = logging.getLogger(__name__ + ".WikipediaScraper") + self.logger.debug("WikipediaScraper initialisiert.") + + # User-Agent fuer Requests (nutzt Config, Fallback wenn nicht gesetzt) + self.user_agent = user_agent or getattr(Config, 'USER_AGENT', 'Mozilla/5.0 (compatible; UnternehmenSkript/1.0; +http://www.example.com/bot)') # Beispiel URL anpassen + self.session = requests.Session() # Nutzt eine Requests Session fuer bessere Performance bei mehreren Anfragen + self.session.headers.update({'User-Agent': self.user_agent}) # Setzt den User-Agent Header + self.logger.debug(f"Requests Session mit User-Agent '{self.user_agent}' initialisiert.") + + # Keywords fuer die Infobox-Extraktion + self.keywords_map = { + 'branche': ['branche', 'wirtschaftszweig', 'industry', 'taetigkeit', 'sektor', 'produkte', 'leistungen'], # Umlaute vermeiden + 'umsatz': ['umsatz', 'erloes', 'revenue', 'jahresumsatz', 'konzernumsatz', 'ergebnis'], # Umlaute vermeiden + 'mitarbeiter': ['mitarbeiter', 'mitarbeiterzahl', 'beschaeftigte', 'employees', 'number of employees', 'personal', 'belegschaft'] # Umlaute vermeiden + } + + # Konfiguriere die wikipedia-Bibliothek + try: + wiki_lang = getattr(Config, 'LANG', 'de') # Sprache aus Config holen + wikipedia.set_lang(wiki_lang) # Setzt die Sprache fuer die wikipedia Bibliothek + # Aktivieren Sie Rate Limiting, um die Wikipedia-API nicht zu ueberlasten + wikipedia.set_rate_limiting(True, min_wait=0.1) # Minimum 0.1 Sekunden warten zwischen API calls + self.logger.info(f"Wikipedia library language set to '{wiki_lang}'. Rate limiting enabled (min_wait=0.1).") + except Exception as e: + # Logge Fehler bei der Konfiguration der wikipedia Bibliothek + self.logger.warning(f"Fehler beim Setzen der Wikipedia-Sprache oder Rate Limiting: {e}") + + + # --- Interne Helfermethoden --- + # Diese Methoden sind nur fuer die interne Nutzung der WikipediaScraper Klasse gedacht. + + def _get_full_domain(self, website): + """Extrahiert die normalisierte Domain (ohne www, ohne Pfad) aus einer URL.""" + # Diese Funktion nutzt die globale simple_normalize_url, was besser ist als eine duplizierte Implementierung. + return simple_normalize_url(website) # Nutzt die globale Funktion (Block 4) + + + def _generate_search_terms(self, company_name, website): + """ + Generiert eine Liste von Suchbegriffen fuer die Wikipedia-Suche, + inklusive normalisiertem Namen, Kurzformen und Domain. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + if not company_name: return [] # Gebe leere Liste zurueck, wenn kein Firmenname da ist + terms = set() # Nutzt ein Set, um Duplikate automatisch zu behandeln + + # Fuegen Sie den originalen Namen hinzu + original_name_cleaned = str(company_name).strip() + if original_name_cleaned: + terms.add(original_name_cleaned) + + # Fuegen Sie den normalisierten Namen und Teile davon hinzu (nutzt globale Funktion) + normalized_name = normalize_company_name(company_name) # Nutzt die globale Funktion (Block 4) + if normalized_name: + terms.add(normalized_name) + name_parts = normalized_name.split() + if len(name_parts) > 0: terms.add(name_parts[0]) # Erstes Wort + if len(name_parts) > 1: terms.add(" ".join(name_parts[:2])) # Erste zwei Woerter + + # Fuegen Sie die Domain hinzu (nutzt interne Methode, die simple_normalize_url nutzt) + full_domain = self._get_full_domain(website) + # Wenn die Domain gueltig ist (nicht "k.A.") + if full_domain != "k.A.": + terms.add(full_domain) + + + # Entferne leere Strings aus dem Set und konvertiere zu einer Liste. + # Limitiere die Anzahl der Begriffe auf die konfigurierte Anzahl der Suchergebnisse. + final_terms = [term for term in list(terms) if term][:getattr(Config, 'WIKIPEDIA_SEARCH_RESULTS', 5)] # Limitiere auf Anzahl der Suchergebnisse (Config, Block 1) + # Logge die generierten Suchbegriffe auf Debug-Level + self.logger.debug(f"Generierte Suchbegriffe fuer '{company_name[:100]}...': {final_terms}") # Gekuerzt loggen + return final_terms + + + @retry_on_failure # Wende den Decorator an, da es externe Calls macht (Requests) + def _get_page_soup(self, url): + """ + Holt HTML von einer URL (requests) und gibt ein BeautifulSoup-Objekt zurueck. + Dies wird fuer die manuelle Extraktion von Infoboxen und anderen Details benoetigt, + die nicht direkt ueber die wikipedia Bibliothek verfuegbar sind. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Pruefen Sie auf ungueltige oder leere URLs + if not url or not isinstance(url, str) or not url.lower().startswith(("http://", "https://")): + self.logger.warning(f"_get_page_soup: Ungueltige URL '{url[:100]}...'.") # Gekuerzt loggen + return None # Gebe None zurueck bei ungueltigen Eingaben + + + try: + # Logge den Versuch, die URL abzurufen + self.logger.debug(f"_get_page_soup: Rufe URL ab: {url[:100]}...") # Gekuerzt loggen + # Führen Sie die GET-Anfrage mit der Instanz Session durch (behaelt Cookies etc.). + # Timeout sollte aus Config kommen. + # Die raise_for_status() und die RequestsExceptions werden vom retry_on_failure Decorator behandelt. + response = self.session.get(url, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) + response.raise_for_status() # Wirft HTTPError fuer 4xx/5xx Antworten. + + # Versuchen Sie, das Encoding aus dem Header oder dem Content zu erraten + response.encoding = response.apparent_encoding + + # Parsen Sie den HTML-Inhalt mit BeautifulSoup. + # Nutzt den konfigurierten Parser aus Config oder einen Fallback. + soup = BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser')) + + # Logge den Erfolg des Parsens + self.logger.debug(f"_get_page_soup: Parsen von {url[:100]}... erfolgreich.") # Gekuerzt loggen + return soup # Gebe das Soup-Objekt zurueck + + # Exceptions (wie RequestsErrors) werden vom retry_on_failure Decorator behandelt. + # Wenn eine Exception hier durchkommt, hat der Decorator aufgegeben. + except Exception as e: # Fangen Sie alle verbleibenden Exceptions ab + # Logge den Fehler auf Error-Level + self.logger.error(f"_get_page_soup: Fehler beim Abrufen oder Parsen von HTML von {url[:100]}...: {type(e).__name__} - {e}") # Gekuerzt loggen + # Die Exception wurde bereits vom retry_on_failure Decorator als finaler Fehler geloggt. + # Werfen Sie die Exception erneut, damit der Aufrufer dies behandeln kann (z.B. extract_company_data). + raise e # Leite die Exception weiter + + + # --- Überarbeitete Validierungsmethode --- + # Validiert, ob ein Wikipedia-Artikel zum Unternehmen passt. + # Nutzt interne Methoden und globale Helfer. + def _validate_article(self, page, company_name, website): + """ + Validiert, ob ein Wikipedia-Artikel (represented by the Page object) + zum Unternehmen passt. Prueft Titelaehnlichkeit (gewichtet Anfangsworte), + Domain-Match in Links und passt Schwellenwerte dynamisch an. + + Args: + page (wikipedia.WikipediaPage): Das geladene Wikipedia Page Objekt. + company_name (str): Der Name des Unternehmens (CRM Name). + website (str): Die Website des Unternehmens (CRM Website). + + Returns: + bool: True, wenn der Artikel validiert wurde, sonst False. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + if not page or not company_name: return False # Grundlegende Pruefung + # page.title ist der Titel des Wikipedia-Artikels + self.logger.debug(f"Validiere Artikel '{page.title[:100]}...' (URL: {page.url[:100]}...) fuer Firma '{company_name[:100]}' (Website: {website[:100]})...") # Gekuerzt loggen + + + # Normalisiere Namen (nutzt globale Funktion Block 4) + normalized_company = normalize_company_name(company_name) + normalized_title = normalize_company_name(page.title) + + # Wenn Normalisierung fehlschlaegt oder zu leeren Strings fuehrt + if not normalized_company or not normalized_title: + self.logger.warning("Validierung nicht moeglich, da Normalisierung eines Namens fehlschlug.") + return False + + # Basisschwelle fuer Aehnlichkeit aus Config (Block 1) + standard_threshold = getattr(Config, 'SIMILARITY_THRESHOLD', 0.65) + + # 1. Titelaehnlichkeit (Gesamt) + # Berechne Aehnlichkeit zwischen den normalisierten Namen (nutzt globale Helfer Block 4) + similarity = fuzzy_similarity(normalized_title, normalized_company) + self.logger.debug(f" -> Gesamt-Aehnlichkeit (normalized): {similarity:.2f} ('{normalized_title[:50]}...' vs '{normalized_company[:50]}...')") # Gekuerzt loggen + + # 2. Aehnlichkeit der ersten Worte (Normalisiert) + company_tokens = normalized_company.split() + title_tokens = normalized_title.split() + first_word_match = False + first_two_words_match = False + + if len(company_tokens) > 0 and len(title_tokens) > 0: + # Pruefe das erste Wort (case-sensitive nach Normalisierung) + if company_tokens[0] == title_tokens[0]: + first_word_match = True + # self.logger.debug(" -> Erstes normalisiertes Wort stimmt ueberein.") # Zu viel Laerm im Debug + # Wenn das erste Wort uebereinstimmt, pruefe das zweite Wort + if len(company_tokens) > 1 and len(title_tokens) > 1: + # Pruefe das zweite Wort (case-sensitive nach Normalisierung) + if company_tokens[1] == title_tokens[1]: + first_two_words_match = True + # self.logger.debug(" -> Erste zwei normalisierte Worte stimmen ueberein.") # Zu viel Laerm im Debug + + # 3. Link-Pruefung (Domain-Match in externen Links des Artikels) + domain_found = False + # Extrahieren Sie die normalisierte Domain aus der Website-URL (nutzt interne Methode, die simple_normalize_url nutzt) + full_domain = self._get_full_domain(website) + # Nur weitermachen, wenn eine gueltige Domain extrahiert wurde + if full_domain != "k.A.": + self.logger.debug(f" -> Suche nach Domain '{full_domain}' in externen Links des Artikels...") + try: + # Direkte Abfrage des HTML ueber page.html() kann schneller sein als erneuter Requests Call + # page.html() kann Exceptions werfen. + article_html = page.html() + if article_html: + # Parsen Sie den HTML-Inhalt des Artikels + soup = BeautifulSoup(article_html, getattr(Config, 'HTML_PARSER', 'html.parser')) + # Suche nach externen Links (href, die mit http starten) + external_links = soup.select('a[href^="http"]') # Suche nach allen A-Tags mit href, der mit "http" beginnt + + # Filtere Links: Muss die gesuchte Domain enthalten UND darf kein Wikipedia-eigener Link sein. + relevant_links = [] + for link in external_links: + href = link.get('href', '') # Hole das href-Attribut + # Pruefe, ob href gueltig ist und die Domain im normalisierten Link enthalten ist + if href and isinstance(href, str) and full_domain in simple_normalize_url(href): + # Pruefe, ob der Link NICHT auf eine Wikipedia-eigene Seite verweist + if not any(exclude in href.lower() for exclude in ['wikipedia.org', 'wikimedia.org', 'wikidata.org', 'archive.org', 'webcitation.org']): + relevant_links.append(link) # Fuege den relevanten Link zur Liste hinzu + + # Wenn relevante Links gefunden wurden + if relevant_links: + # Optional: Pruefe, ob der Link in der Infobox ist oder typischen Text hat (Komplexitaet vs Nutzen) + # Fuer eine einfachere Implementierung reicht es, wenn der Link existiert. + domain_found = True + # logger.debug(f" -> Domain '{full_domain}' in {len(relevant_links)} externen Links gefunden.") # Zu viel Laerm im Debug + else: + # logger.debug(f" -> Domain '{full_domain}' nicht in externen Links gefunden.") # Zu viel Laerm im Debug + pass # domain_found bleibt False + + else: + self.logger.warning(" -> Konnte HTML fuer Link-Pruefung nicht abrufen (page.html() leer).") + + except Exception as e_link_check: + # Logge Fehler waehrend der Domain-Link-Pruefung, aber fahre fort + self.logger.error(f"Fehler waehrend der Domain-Link-Pruefung fuer '{page.title[:100]}...': {type(e_link_check).__name__} - {e_link_check}") # Gekuerzt loggen + # Fehler beim Link-Check sollte die Validierung nicht blockieren, nur beeinflussen. + pass # domain_found bleibt False oder sein aktueller Wert. + + else: + self.logger.debug(" -> Keine Website-Domain fuer Link-Pruefung vorhanden oder ungueltig ('k.A.').") + + + # 4. Dynamische Schwellenwert-Entscheidung (Bewertung des Artikels) + is_valid = False # Initialisierung des Validierungs-Flags + reason = "Keine Validierungsregel traf zu" # Default Grund fuer das Ergebnis + + # Pruefe die Validierungsbedingungen in absteigender Reihenfolge ihrer Stärke / Relevanz + # Die erste Bedingung, die True ist, bestimmt das Ergebnis. + if similarity >= standard_threshold: + is_valid = True + reason = f"Gesamt-Aehnlichkeit ({similarity:.2f}) >= Standard-Schwelle ({standard_threshold:.2f})" + elif domain_found and first_two_words_match: # Staerkste Kombination von Indikatoren + is_valid = True + reason = f"Domain gefunden UND erste 2 normalisierte Worte stimmen ueberein (Sim={similarity:.2f})" + elif domain_found and first_word_match and similarity >= 0.40: # Domain + Erstes Wort + Moderate Aehnlichkeit + is_valid = True + reason = f"Domain gefunden UND erstes normalisiertes Wort stimmt ueberein UND Aehnlichkeit >= 0.40 (Sim={similarity:.2f})" + elif first_two_words_match and similarity >= 0.45: # Erste zwei Worte + Moderate Aehnlichkeit (auch ohne Domain) + is_valid = True + reason = f"Erste zwei normalisierte Worte stimmen ueberein UND Aehnlichkeit >= 0.45 (Sim={similarity:.2f})" + elif domain_found and similarity >= 0.50: # Nur Domain + Etwas hoehere Aehnlichkeit + is_valid = True + reason = f"Domain gefunden UND Aehnlichkeit >= 0.50 (Sim={similarity:.2f})" + elif first_word_match and similarity >= 0.55: # Nur Erstes Wort + Etwas hoehere Aehnlichkeit + is_valid = True + reason = f"Erstes normalisiertes Wort stimmt ueberein UND Aehnlichkeit >= 0.55 (Sim={similarity:.2f})" + # Weitere optionale, weniger strenge Schwellen koennten hier hinzugefuegt werden, wenn gewuenscht. + # z.B. elif domain_found and similarity >= 0.30: + + + # Logge das Ergebnis der Validierung (INFO wenn validiert, DEBUG sonst) + log_level = logging.INFO if is_valid else logging.DEBUG + self.logger.log(log_level, f" => Artikel '{page.title[:100]}...' {'VALIDIERT' if is_valid else 'NICHT validiert'} (Grund: {reason}. Details: Sim={similarity:.2f}, Domain? {domain_found}, 1stWord? {first_word_match}, 2ndWord? {first_two_words_match})") # Gekuerzt loggen + + return is_valid # Gebe das Ergebnis der Validierung zurueck + + + # --- Extraktionsmethoden --- + # Methoden zum Extrahieren spezifischer Daten aus einem Wikipedia Soup-Objekt. + + def _extract_first_paragraph_from_soup(self, soup): + """ + Extrahiert den ersten aussagekraeftigen Absatz aus dem Soup-Objekt eines Wikipedia-Artikels. + Entfernt Referenzen und unerwuenschte Elemente. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + if not soup: return "k.A." # Gebe "k.A." zurueck, wenn kein Soup-Objekt da ist + paragraph_text = "k.A." # Initialisiere mit "k.A." + try: + # Finden Sie den Hauptinhalt-Div (mw-parser-output ist Standard auf moderner Wikipedia) + content_div = soup.find('div', class_='mw-parser-output') + # Wenn der Haupt-Div gefunden wurde, suchen Sie darin, sonst im gesamten Soup + search_area = content_div if content_div else soup + + # Suchen Sie die ersten p-Tags direkt unter diesem Div (recursive=False) oder im gesamten Suchbereich. + # Oft ist der erste relevante Absatz ein direktes Kind des Haupt-Divs. + paragraphs = search_area.find_all('p', recursive=False) + # Fallback, falls keine direkten p-Tags gefunden werden, suche rekursiv im gesamten Suchbereich. + if not paragraphs: paragraphs = search_area.find_all('p') + + + # Gehe durch die gefundenen Absaetze, um den ersten geeigneten zu finden + # logger.debug(f"Suche ersten Absatz in {len(paragraphs)} gefundenen <p>-Tags...") # Zu viel Laerm im Debug + for p in paragraphs: + # Entferne Stoerende Elemente wie Referenzen ([1]), versteckte Spans oder Koordinaten innerhalb des p-Tags, BEVOR der Text extrahiert wird. + for sup in p.find_all('sup', class_='reference'): sup.decompose() # Referenz-Tags + for span in p.find_all('span', style=lambda value: value and 'display:none' in value): span.decompose() # Versteckte Spans (z.B. fuer Sortierschluessel) + for span in p.find_all('span', id='coordinates'): span.decompose() # Koordinaten-Span + + + # Extrahiere und bereinige den Text des Absatzes (nutzt globale Funktion clean_text Block 4) + text = clean_text(p.get_text(separator=' ', strip=True)) + + # Pruefe, ob der Text nach der Bereinigung gueltig und lang genug ist. + # Ein sehr kurzer Text ist oft nur eine Bildunterschrift oder aehnliches. + if text != "k.A." and len(text) > 50: # Mindestlaenge 50 Zeichen (kann angepasst werden) + # Pruefe auf gaengige unerwuenschte Anfange (z.B. nach Infoboxen oder Listen) + # Vermeide Absaetze, die mit "Datei:", "Abbildung:", "Siehe auch:", "Einzelnachweise" etc. beginnen. + if not re.match(r'^(Datei:|Abbildung:|Siehe auch:|Einzelnachweise|Siehe auch|Literatur|Einzelnachweise)', text, re.IGNORECASE): + # Wenn der Absatz relevant aussieht, nimm ihn und begrenze die Laenge. + paragraph_text = text[:1500] # Begrenze die Laenge des extrahierten Absatzes (z.B. auf 1500 Zeichen) + # logger.debug(f" -> Ersten gueltigen Absatz gefunden: {paragraph_text[:100]}...") # Logge den Anfang des Absatzes (gekuerzt) + break # Hoere beim ersten guten Absatz auf + + + # Wenn die Schleife durchlaeuft und kein passender Absatz gefunden wurde + if paragraph_text == "k.A.": + self.logger.debug("Kein passender erster Absatz gefunden nach Pruefung der <p>-Tags.") + + except Exception as e: + # Fange unerwartete Fehler waehrend der Extraktion ab + self.logger.error(f"Fehler beim Extrahieren des ersten Absatzes: {type(e).__name__} - {e}") + # Logge den Traceback + self.logger.debug(traceback.format_exc()) + # paragraph_text bleibt "k.A." oder sein Initialwert + + return paragraph_text # Gebe den extrahierten Text oder "k.A." zurueck + + + def extract_categories(self, soup): + """ + Extrahiert Wikipedia-Kategorien aus dem Soup-Objekt. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + if not soup: return "k.A." # Gebe "k.A." zurueck, wenn kein Soup-Objekt da ist + cats_filtered = [] # Liste zum Sammeln der bereinigten Kategorien + try: + # Kategorien sind normalerweise in einem div mit id="mw-normal-catlinks" + cat_div = soup.find('div', id="mw-normal-catlinks") + # Wenn der Kategorien-Div gefunden wurde + if cat_div: + # Die Kategorien sind innerhalb eines ul-Tags unter diesem div + ul = cat_div.find('ul') + # Wenn das ul-Tag gefunden wurde + if ul: + # Jede Kategorie ist ein li-Element innerhalb des ul. + # Extrahieren Sie den Text und bereinigen Sie ihn (clean_text nutzt globale Funktion Block 4). + cats = [clean_text(li.get_text()) for li in ul.find_all('li')] + # Filtere leere oder unerwuenschte Eintraege (wie "Kategorien:") aus der Liste + cats_filtered = [c for c in cats if c and isinstance(c, str) and c.strip() and "kategorien:" not in c.lower()] + # Logge die gefundenen Kategorien auf Debug-Level + self.logger.debug(f"Kategorien gefunden: {cats_filtered}") + else: + # Wenn kein ul-Tag im Kategorien-Div gefunden wurde + self.logger.debug("Kein 'ul' Tag in 'mw-normal-catlinks' gefunden.") + else: + # Wenn der Kategorien-Div nicht gefunden wurde + self.logger.debug("Kein 'div#mw-normal-catlinks' gefunden.") + + except Exception as e: + # Fange unerwartete Fehler waehrend der Extraktion ab + self.logger.error(f"Fehler beim Extrahieren der Kategorien: {type(e).__name__} - {e}") + # Logge den Traceback + self.logger.debug(traceback.format_exc()) + + # Verbinde die bereinigten Kategorien mit Komma und Leerzeichen, oder gebe "k.A." zurueck, wenn keine gefunden wurden. + return ", ".join(cats_filtered) if cats_filtered else "k.A." + + + def _extract_infobox_value(self, soup, target): + """ + Extrahiert gezielt Branche, Umsatz oder Mitarbeiter aus der Infobox + eines Wikipedia-Artikels Soup-Objekts. + Beruecksichtigt Header in <th> oder fett formatierten <td>. + Nutzt interne Keywords-Map und globale Helfer (clean_text, extract_numeric_value, logger, re). + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + self.logger.debug(f"--- Entering _extract_infobox_value for target '{target}' ---") + # Pruefen Sie, ob ein Soup-Objekt vorhanden ist und das Ziel in der Keywords-Map existiert + if not soup or target not in self.keywords_map: + self.logger.debug(f"_extract_infobox_value: Ungueltiger Input (Soup: {soup is not None}, Target: {target})") + return "k.A." # Gebe "k.A." zurueck bei ungueltigen Eingaben + + + keywords = self.keywords_map[target] # Holen Sie die Keywords fuer das Ziel + self.logger.debug(f"_extract_infobox_value: Suche nach '{target}' mit Keywords: {keywords}") + + # Finden Sie die Infobox (verschiedene Klassen sind moeglich) + # Nutzt select_one, um das erste passende Element zu finden + infobox = soup.select_one('table[class*="infobox"]') # Suche nach table mit class, die "infobox" enthaelt + + # Wenn keine Infobox gefunden wurde + if not infobox: + self.logger.debug(" -> KEINE Infobox via select_one 'table[class*=\"infobox\"]' gefunden.") + return "k.A." # Gebe "k.A." zurueck, wenn keine Infobox gefunden wurde + + # Logge, dass eine Infobox gefunden wurde + self.logger.debug(f" -> Infobox gefunden.") + value_found = "k.A." # Initialisiere das Ergebnis mit "k.A." + + try: + # Iteriere durch die Zeilen (tr) der Infobox + rows = infobox.find_all('tr') + # logger.debug(f" -> Analysiere {len(rows)} Zeilen in der Infobox.") # Zu viel Laerm im Debug + + # Durchsuche jede Zeile nach einem Header-Zelle und einer Wert-Zelle + for idx, row in enumerate(rows): + # logger.debug(f" --- Pruefe Roh-HTML Zeile {idx}: {str(row)[:150]}...") # Zu viel Laerm im Debug + + # Suche nach TH und TD Elementen direkt unter TR (recursive=False) + cells = row.find_all(['th', 'td'], recursive=False) + header_text = None # Initialisiere Header-Text und Wert-Zelle + value_cell = None + + # Gaengigste Struktur: TH (Header) gefolgt von TD (Wert) + if len(cells) >= 2 and cells[0].name == 'th' and cells[1].name == 'td': + header_text = cells[0].get_text(strip=True) # Extrahiere Text aus TH + value_cell = cells[1] # TD ist die Wert-Zelle + # logger.debug(f" -> Zeile {idx}: Struktur TH + TD erkannt.") # Zu viel Laerm im Debug + + # Alternative Struktur: TD (Header-aehnlich, z.B. fett) gefolgt von TD (Wert) + # Hier ist Vorsicht geboten, um nicht regulaere Datenzellen zu erfassen. + elif len(cells) >= 2 and cells[0].name == 'td' and cells[1].name == 'td': + first_cell_is_header_like = False + # Pruefe auf Style-Attribut mit font-weight bold + style = cells[0].get('style', '').lower() + if 'font-weight' in style and ('bold' in style or '700' in style): + first_cell_is_header_like = True + # Pruefe auf fettgedruckten Inhalt (<b> oder <strong>) + elif cells[0].find(['b', 'strong'], recursive=False): + first_cell_is_header_like = True + + if first_cell_is_header_like: + header_text = cells[0].get_text(strip=True) # Extrahiere Text aus dem TD, das wie ein Header aussieht + value_cell = cells[1] # Das zweite TD ist die Wert-Zelle + # logger.debug(f" -> Zeile {idx}: Struktur TD(Header-like) + TD erkannt.") # Zu viel Laerm im Debug + # else: + # logger.debug(f" -> Zeile {idx}: Struktur TD + TD, aber erstes TD nicht als Header erkannt.") # Zu viel Laerm im Debug + + # Wenn eine passende Struktur (Header + Wert-Zelle) gefunden wurde + if header_text is not None and value_cell is not None: + # logger.debug(f" -> Verarbeite Zeile {idx} mit Header='{header_text}'") # Zu viel Laerm im Debug + header_text_lower = header_text.lower() + matched_keyword = None # Initialisiere das gefundene Keyword + + # Pruefe, ob ein gesuchtes Keyword im Header-Text (kleingeschrieben) vorkommt + for kw in keywords: + if kw in header_text_lower: + matched_keyword = kw # Speichere das gefundene Keyword + break # Hoere auf, sobald ein Keyword gefunden wurde + + # Wenn ein Keyword gefunden wurde, extrahiere den Wert aus der Wert-Zelle + if matched_keyword: + # logger.debug(f" --> Keyword '{matched_keyword}' gefunden in Header '{header_text}'!") # Logge das gefundene Keyword + + # Entferne stoerende Elemente wie Referenz-Tags ([1]) oder versteckte Spans innerhalb der Value-Zelle, BEVOR der Text extrahiert wird. + for sup in value_cell.find_all(['sup', 'span']): + # Pruefe, ob es ein Referenz-Tag ODER ein Span mit display:none Style ist. + if (sup.name == 'sup' and sup.has_attr('class') and 'reference' in sup['class']) or \ + (sup.name == 'span' and sup.get('style') and 'display:none' in sup['style']): + # logger.debug(f" -> Entferne stoerendes Element: {sup.get_text(strip=True)[:50]}...") # Logge Entfernung (gekuerzt) + sup.decompose() # Entferne das Element aus dem Baum + + # Extrahiere den Rohtext aus der bereinigten Value-Zelle + # Verwenden Sie Leerzeichen als Trenner zwischen Elementen im Text + raw_value_text = value_cell.get_text(separator=' ', strip=True) + # logger.debug(f" -> Roher TD/Value-Text nach Decompose: '{raw_value_text[:100]}...'") # Logge Rohtext (gekuerzt) + + + # Bereinige und konvertiere den Wert basierend auf dem Zieltyp (Branche, Umsatz, Mitarbeiter) + if target == 'branche': + # Branche: Bereinigen, Klammern entfernen, nur erste Zeile nehmen + clean_val = clean_text(raw_value_text) # Nutzt globale Funktion clean_text (Block 4) + clean_val = re.sub(r'\s*\([^)]*\)', '', clean_val).strip() # Entferne Text in runden Klammern (z.B. "(Stand 2022)") + clean_val = clean_val.split('\n')[0].strip() # Nimm nur die erste Zeile (falls es mehrere Zeilen gibt) + # Setze den gefundenen Wert oder "k.A." wenn er leer ist + value_found = clean_val if clean_val else "k.A." + self.logger.info(f" --> Branche extrahiert: '{value_found}'") # Logge das Ergebnis + + elif target == 'umsatz': + # Umsatz: Numerische Extraktion (nutzt globale Funktion extract_numeric_value Block 5) + # extract_numeric_value gibt einen String zurueck ("k.A." oder Zahl) + numeric_val_str = extract_numeric_value(raw_value_text, is_umsatz=True) + value_found = numeric_val_str # Setze den gefundenen Wert (String) + self.logger.info(f" --> Umsatz extrahiert (aus '{raw_value_text[:50]}...'): '{value_found}'") # Logge Ergebnis (gekuerzt) + + elif target == 'mitarbeiter': + # Mitarbeiter: Numerische Extraktion (nutzt globale Funktion extract_numeric_value Block 5) + # extract_numeric_value gibt einen String zurueck ("k.A." oder Zahl) + numeric_val_str = extract_numeric_value(raw_value_text, is_umsatz=False) + value_found = numeric_val_str # Setze den gefundenen Wert (String) + self.logger.info(f" --> Mitarbeiter extrahiert (aus '{raw_value_text[:50]}...'): '{value_found}'") # Logge Ergebnis (gekuerzt) + + # Da wir den Wert fuer das gesuchte Ziel gefunden haben, koennen wir die Schleife ueber die Zeilen abbrechen + break + + # Wenn die Schleife durchlaeuft und kein passendes Keyword gefunden wurde, bleibt value_found "k.A." + if value_found != "k.A.": + self.logger.debug(f" -> Finaler Wert fuer '{target}' gefunden: '{value_found}'") + else: + self.logger.debug(f" -> Kein passender Eintrag fuer '{target}' in der gesamten Infobox gefunden.") + + + except Exception as e: + # Fange jeden unerwarteten Fehler ab, der waehrend der Infobox-Verarbeitung auftritt + self.logger.exception(f"Fehler beim Durchlaufen der Infobox-Zeilen fuer '{target}': {e}") # Logge den Fehler und Traceback + return "k.A. (Fehler Extraktion)" # Gebe einen Fehlerwert zurueck bei Fehler + + return value_found # Gebe den extrahierten Wert oder "k.A."/"Fehler" zurueck + + + # --- Hauptmethoden der Klasse --- + # Methoden zum Suchen und Extrahieren von Wikipedia-Daten. + + @retry_on_failure # Wende den Decorator an, da es externe Calls macht (wikipedia Bibliothek nutzt Requests/API) + def search_company_article(self, company_name, website=None): + """ + Sucht einen passenden Wikipedia-Artikel fuer das Unternehmen und gibt das + wikipedia.WikipediaPage Objekt zurueck, wenn ein relevanter und validierter + Artikel gefunden wird. Behandelt explizit Begriffsklaerungsseiten. + + Args: + company_name (str): Der Name des Unternehmens (CRM Name). + website (str, optional): Die Website des Unternehmens (CRM Website, fuer Kontext). Defaults to None. + + Returns: + wikipedia.WikipediaPage: Das validierte Page Objekt oder None. + Wirft Exception bei API/Netzwerk-Fehlern nach Retries. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Pruefe, ob ein Firmenname da ist + if not company_name or str(company_name).strip() == "": + self.logger.warning("Wikipedia search skipped: No company name provided.") + # Werfen Sie einen ValueError + raise ValueError("Kein Firmenname fuer Wikipedia Suche angegeben.") + + + # Generiere Suchbegriffe (nutzt interne Methode, die globale Helfer nutzt) + search_terms = self._generate_search_terms(company_name, website) + if not search_terms: + self.logger.warning(f"Keine Suchbegriffe fuer '{company_name[:100]}...' generiert.") # Gekuerzt loggen + return None # Gebe None zurueck, wenn keine Suchbegriffe generiert werden konnten + + # Logge den Start der Suche + self.logger.info(f"Starte Wikipedia-Suche fuer '{company_name[:100]}...' (Website: {website[:100]}...) mit Begriffen: {search_terms}") # Gekuerzt loggen + + # Menge der bereits geprueften Titel, um Redundanzen zu vermeiden und Endlosschleifen bei Weiterleitungen/Begriffs-k. zu verhindern. + processed_titles = set() + + # --- Innere Helferfunktion zum Pruefen eines einzelnen Titels --- + # Diese Funktion ist rekursiv (kann sich selbst aufrufen, z.B. bei Begriffsklaerungen). + def check_page(title_to_check): + """Laedt einen potenziellen Wikipedia-Artikel und validiert ihn.""" + # Pruefen, ob der Titel bereits verarbeitet wurde (innerhalb dieses search_company_article Aufrufs) + if title_to_check in processed_titles: + # self.logger.debug(f" -> Titel '{title_to_check[:100]}...' bereits geprueft, ueberspringe.") # Zu viel Laerm im Debug (gekuerzt loggen) + return None # Titel wurde bereits geprüft, ueberspringe + + # Fuege den Titel zur Menge der verarbeiteten hinzu, BEVOR er geladen wird. + processed_titles.add(title_to_check) + self.logger.debug(f" -> Pruefe potenziellen Artikel: '{title_to_check[:100]}...'") # Gekuerzt loggen + + try: + # Lade die Seite ueber die wikipedia Bibliothek. + # auto_suggest=False deaktiviert automatische Titelkorrektur. + # preload=True laedt den Inhalt und die Infobox gleich mit (nuetzlich fuer Validierung/Extraktion). + # Dieser Aufruf kann verschiedene wikipedia.exceptions (PageError, DisambiguationError) oder RequestsExceptions werfen. + page = wikipedia.page(title_to_check, auto_suggest=False, preload=True) + + # Pruefe, ob es sich um eine Begriffsklaerungsseite handelt (wird von wikipedia.page selbst als Exception geworfen) + # oder ob unsere Validierung fehlschlaegt + # Rufen Sie die interne Validierungsmethode auf + if self._validate_article(page, company_name, website): + # Wenn der Artikel validiert wurde, geben Sie das Page-Objekt zurueck + self.logger.info(f" -> Titel '{page.title[:100]}...' erfolgreich validiert!") # Gekuerzt loggen + return page # Gebe das validierte Page-Objekt zurueck + else: + # Wenn der Artikel nicht validiert wurde + self.logger.debug(f" -> Titel '{title_to_check[:100]}...' nicht validiert.") # Gekuerzt loggen + return None # Gebe None zurueck, wenn nicht validiert + + # Spezifische Behandlung von wikipedia.exceptions + except wikipedia.exceptions.PageError: + # Titel existiert nicht auf Wikipedia (404 aehnlich) + self.logger.debug(f" -> Seite '{title_to_check[:100]}...' nicht gefunden (PageError).") # Gekuerzt loggen + return None # Gebe None zurueck, Seite nicht gefunden + + except wikipedia.exceptions.DisambiguationError as e_inner: + # Titel fuehrt zu einer Begriffsklaerungsseite + self.logger.info(f" -> Begriffsklaerung '{title_to_check[:100]}...' gefunden. Pruefe Optionen: {str(e_inner.options)[:100]}...") # Logge Optionen (gekuerzt) + best_option_page = None # Initialisiere mit None + + # Gehe durch die Optionen der Begriffsklaerungsseite + for option in e_inner.options: + option_lower = option.lower() + # Filtere Optionen, die wahrscheinlich keine Unternehmensartikel sind (z.B. Personen, Orte, Listen). + # Fuegen Sie hier weitere Filter oder eine verbesserte Heuristik hinzu. + if any(exclude_word in option_lower for exclude_word in ["(person)", "(ort)", "(geographie)", "liste ", "liste)"]): + # logger.debug(f" -> Option uebersprungen (wahrscheinlich keine Firma): '{option[:100]}...'") # Zu viel Laerm im Debug (gekuerzt loggen) + continue # Ueberspringe diese Option + + # Checken Sie die Option rekursiv mit check_page. + # Dies wird die Option laden und validieren. + validated_option_page = check_page(option) + + # Wenn eine Option validiert wurde, nehmen Sie die erste als besten Treffer (oder implementieren Sie eine Ranking-Logik) + if validated_option_page: + self.logger.info(f" -> Option '{option[:100]}...' aus Begriffsklaerung erfolgreich validiert!") # Gekuerzt loggen + # Wir koennten hier aufhoeren (return validated_option_page) oder weiter nach dem BESTEN Treffer suchen. + # Fuer den Anfang nehmen wir den ersten validierten Treffer. + return validated_option_page # Gebe das validierte Page-Objekt zurueck + + # Wenn keine Option aus der Begriffsklaerung validiert wurde + self.logger.debug(f" -> Keine passende/validierte Unternehmens-Option in Begriffsklaerung '{title_to_check[:100]}...' gefunden.") # Gekuerzt loggen + return None # Gebe None zurueck, keine passende Option gefunden + + # Fangen Sie Netzwerkfehler oder Wikipedia-spezifische API-Fehler ab. + # Diese werden vom @retry_on_failure Decorator (auf search_company_article) behandelt. + # Wenn sie innerhalb von check_page auftreten, brechen sie NUR die Pruefung dieses EINEN Titels ab. + # Wir wollen, dass der Hauptaufruf (search_company_article) retried wird, nicht check_page. + # Also loggen wir hier den Fehler auf Warning und geben None zurueck, OHNE die Exception weiterzuwerfen. + except (requests.exceptions.RequestException, wikipedia.exceptions.WikipediaException) as e_req: + self.logger.warning(f" -> Netzwerk/API-Fehler beim Laden/Validieren von '{title_to_check[:100]}...': {type(e_req).__name__} - {e_req}. Ueberspringe diesen Titel.") # Gekuerzt loggen + # Optional: Kleine Pause bei Netzwerkfehlern, um API nicht weiter zu reizen + # time.sleep(0.5) + return None # Gebe None zurueck, diesen Titel ueberspringen und naechsten versuchen + + except Exception as e_page: + # Fangen Sie andere unerwartete Fehler bei der Seitenverarbeitung ab + self.logger.error(f" -> Unerwarteter Fehler bei Verarbeitung von Titel '{title_to_check[:100]}...': {type(e_page).__name__} - {e_page}") # Gekuerzt loggen + # Logge Traceback fuer unerwartete Fehler + self.logger.debug(traceback.format_exc()) + return None # Gebe None zurueck, diesen Titel ueberspringen + + + # --- Haupt-Suchlogik (Iteriere durch Suchbegriffe und Ergebnisse) --- + self.logger.debug(f" -> Versuche direkten Match fuer '{company_name[:100]}...'...") # Gekuerzt loggen + # Versuche zuerst den exakten Firmennamen als Titel zu laden und zu validieren + # Rufe die innere Helferfunktion check_page auf + validated_page = check_page(company_name) + if validated_page: + return validated_page # Direkter, validierter Treffer gefunden! + + + self.logger.debug(f" -> Kein direkter Treffer/validiert. Starte Suche mit generierten Begriffen: {search_terms}") + # Wenn kein direkter Treffer, fuehre eine Suche mit den generierten Begriffen durch + for term in search_terms: + try: + self.logger.debug(f" -> Suche mit Begriff: '{term[:100]}...'...") # Gekuerzt loggen + # Fuehre die Suche ueber die wikipedia-Bibliothek durch + # wikipedia.search kann Exceptions werfen (z.B. PageError), die vom retry_on_failure im Decorator gefangen werden. + # results=getattr(Config, 'WIKIPEDIA_SEARCH_RESULTS', 5) holt die Anzahl aus Config (Block 1) + search_results = wikipedia.search(term, results=getattr(Config, 'WIKIPEDIA_SEARCH_RESULTS', 5)) + self.logger.debug(f" -> Suchergebnisse fuer '{term[:100]}...': {search_results}") # Gekuerzt loggen + + # Wenn keine Suchergebnisse fuer diesen Begriff gefunden wurden + if not search_results: + self.logger.debug(f" -> Keine Suchergebnisse fuer '{term[:100]}...'.") # Gekuerzt loggen + continue # Springe zum naechsten Suchbegriff + + # Pruefe jeden Titel in den Suchergebnissen + for title in search_results: + # Rufe die innere Helferfunktion check_page auf, um den Artikel zu laden und zu validieren + validated_page = check_page(title) + # Wenn ein validierter Artikel gefunden wurde + if validated_page: + return validated_page # Gebe den ersten validierten Artikel zurueck! + + # Kleine Pause zwischen dem Pruefen einzelner Suchergebnisse + # time.sleep(0.05) # Sehr kurz, optional + + # Fangen Sie Exceptions, die waehrend wikipedia.search auftreten (z.B. Netzwerkfehler, API-Fehler). + # Diese werden vom @retry_on_failure Decorator der search_company_article Methode behandelt. + # Werfen Sie die Exception erneut, damit der Decorator sie fangen kann. + except Exception as e_search: + self.logger.error(f"Fehler waehrend Wikipedia-Suche fuer '{term[:100]}...': {type(e_search).__name__} - {e_search}") # Gekuerzt loggen + # Werfen Sie die Exception erneut + raise e_search + + + # Wenn alle Suchbegriffe und alle Ergebnisse geprueft wurden und kein validierter Artikel gefunden wurde + self.logger.warning(f"Kein passender & validierter Wikipedia-Artikel fuer '{company_name[:100]}...' gefunden nach Pruefung aller Begriffe und Optionen.") # Gekuerzt loggen + return None # Gebe None zurueck, signalisiert, dass kein passender Artikel gefunden wurde + + + @retry_on_failure # Wende den Decorator an, da es externe Calls macht (Requests/BeautifulSoup) + def extract_company_data(self, page_url): + """ + Extrahiert Firmendaten (erster Absatz, Infobox-Werte, Kategorien) + von einer gegebenen Wikipedia-Artikel-URL. + + Args: + page_url (str): Die URL des Wikipedia-Artikels. + + Returns: + dict: Ein Dictionary mit den extrahierten Daten oder Default-Werten ('k.A.'). + Formate fuer Umsatz/Mitarbeiter sind Strings ("123" oder "k.A."). + Wirft Exception bei API/Netzwerk-Fehlern nach Retries (von _get_page_soup). + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Default-Ergebnis im Fehlerfall oder bei ungueltiger URL + default_result = {'url': page_url if page_url else 'k.A.', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'} + + # Grundlegende URL-Pruefung + # Stelle sicher, dass es ein String ist und wie eine Wikipedia-URL aussieht. + if not page_url or not isinstance(page_url, str) or "wikipedia.org/wiki/" not in page_url.lower(): + self.logger.warning(f"extract_company_data: Ungueltige oder keine Wikipedia-URL '{page_url[:100]}...'.") # Gekuerzt loggen + return default_result # Gebe Default-Ergebnis zurueck + + + self.logger.info(f"Extrahiere Daten fuer Wiki-URL: {page_url[:100]}...") # Logge den Start der Extraktion (gekuerzt) + + # Hole das Soup-Objekt der Seite (nutzt interne Methode mit Retry). + # _get_page_soup wirft Exception bei endgueltigem Fehler, die hier nicht gefangen wird (weitergereicht). + soup = self._get_page_soup(page_url) + # Wenn _get_page_soup None zurueckgegeben hat (z.B. wegen ungueltiger URL, die am Anfang nicht gefiltert wurde) + if not soup: + self.logger.error(f" -> Fehler: Konnte Seite {page_url[:100]}... nicht laden oder parsen.") # Gekuerzt loggen + # Das default_result enthaelt bereits die URL und k.A. fuer Daten. + return default_result # Gebe Default-Ergebnis zurueck + + + # --- Extrahiere die einzelnen Datenpunkte aus dem Soup-Objekt --- + self.logger.debug(" -> Extrahiere erster Absatz...") + first_paragraph = self._extract_first_paragraph_from_soup(soup) # Nutzt interne Methode + + self.logger.debug(" -> Extrahiere Kategorien...") + categories_val = self.extract_categories(soup) # Nutzt interne Methode + + self.logger.debug(" -> Extrahiere Branche aus Infobox...") + # Nutzt interne Methode _extract_infobox_value, die globale extract_numeric_value nutzt (Block 5) + branche_val = self._extract_infobox_value(soup, 'branche') + + self.logger.debug(" -> Extrahiere Umsatz aus Infobox...") + # Nutzt interne Methode _extract_infobox_value, die globale extract_numeric_value nutzt (Block 5) + umsatz_val = self._extract_infobox_value(soup, 'umsatz') + + self.logger.debug(" -> Extrahiere Mitarbeiter aus Infobox...") + # Nutzt interne Methode _extract_infobox_value, die globale extract_numeric_value nutzt (Block 5) + mitarbeiter_val = self._extract_infobox_value(soup, 'mitarbeiter') + + + # Baue das Ergebnis-Dictionary zusammen + result = { + 'url': page_url, # Die URL, von der extrahiert wurde + 'first_paragraph': first_paragraph, + 'branche': branche_val, + 'umsatz': umsatz_val, + 'mitarbeiter': mitarbeiter_val, + 'categories': categories_val + } + + # Loggen Sie eine Zusammenfassung der extrahierten Daten (gekuerzt) + self.logger.info(f" -> Extrahierte Daten: P='{first_paragraph[:50]}...', B='{branche_val}', U='{umsatz_val}', M='{mitarbeiter_val}', C='{categories_val[:50]}...'") + + return result # Gebe das Dictionary mit den extrahierten Daten zurueck + + +# ============================================================================== +# Ende Handler Klassen Block +# ============================================================================== + +# ============================================================================== +# 5. DATA PROCESSOR CLASS (PART 1: Start & Init & Basis Status-Checker) +# ============================================================================== + +class DataProcessor: + """ + Zentrale Klasse zur Orchestrierung und Verarbeitung von Unternehmensdaten + aus dem Google Sheet. Enthält die Logik fuer die Verarbeitung einzelner + Zeilen sowie die Steuerung verschiedener Batch-Modi und Dienstprogramme. + Nutzt Instanzen von Handler-Klassen (Sheet, Wiki etc.) als Worker. + """ + def __init__(self, sheet_handler, wiki_scraper): # Akzeptiert benoetigte Worker-Instanzen + """ + Initialisiert den DataProcessor mit Instanzen von Handler-Klassen. + + Args: + sheet_handler (GoogleSheetHandler): Eine initialisierte Instanz. + wiki_scraper (WikipediaScraper): Eine initialisierte Instanz. + # Fuegen Sie hier weitere benoetigte Handler/Worker hinzu, falls noetig + # (z.B. OpenAIHandler, SerpAPIHandler), falls diese als eigene Klassen ausgelagert werden. + """ + # Erhalten Sie eine Logger-Instanz fuer diese Klasse + self.logger = logging.getLogger(__name__ + ".DataProcessor") + self.logger.info("Initialisiere DataProcessor...") + + # Ueberpruefen Sie, ob gueltige Handler-Instanzen uebergeben wurden + if not isinstance(sheet_handler, GoogleSheetHandler): + # Logge einen kritischen Fehler und werfe eine Exception + self.logger.critical("DataProcessor Init FEHLER: Kein gueltiger GoogleSheetHandler uebergeben!") + raise ValueError("DataProcessor benoetigt eine gueltige GoogleSheetHandler Instanz.") + if not isinstance(wiki_scraper, WikipediaScraper): + # Logge einen kritischen Fehler und werfe eine Exception + self.logger.critical("DataProcessor Init FEHLER: Kein gueltiger WikipediaScraper uebergeben!") + raise ValueError("DataProcessor benoetigt eine gueltige WikipediaScraper Instanz.") + + # Speichern Sie die Handler-Instanzen als Attribute der Instanz + self.sheet_handler = sheet_handler + self.wiki_scraper = wiki_scraper + # self.openai_handler = openai_handler # Beispiel, falls ausgelagert + # self.serpapi_handler = serpapi_handler # Beispiel, falls ausgelagert + + + # Attribute fuer ML-Modellierung (werden beim ersten Bedarf geladen in _load_ml_model) + # Initialisieren Sie diese mit None + self.model = None + self.imputer = None + self._expected_features = None # Liste der erwarteten Feature-Spalten fuer Vorhersage + + + self.logger.info("DataProcessor initialisiert mit Handlern.") + + + # Definieren Sie hier (oder als Klassenattribut) die Zuordnung von Schritt-Typen + # zu den relevanten Spaltenschluesseln fuer die Statuspruefung. + # Diese werden von _should_run_based_on_status (Block 18) verwendet. + # HINWEIS: Die Logik, ob ein Schritt ausgefuehrt werden soll, ist komplexer + # als nur ein Timestamp (z.B. 'find_wiki_serp' braucht auch leere M und Groesse; + # 'summarize_website' braucht gefuellt AR). Die Methode _should_run_based_on_status + # wird hauptsaechlich fuer die sequentielle Verarbeitung (_process_single_row) + # und den Re-Eval Modus verfeinert. Batch-Modi haben oft ihre eigene spezifische + # Logik zur Zeilenauswahl, die im jeweiligen Batch-Methoden implementiert ist. + # Diese Map ist primaer fuer die Logik in _should_run_based_on_status (Block 18) relevant. + self._step_status_map = { + 'wiki_verify': "Wiki Verif. Timestamp", # AX - Trigger fuer Wiki-Verifikation (S-Y) + 'website_scrape': "Website Scrape Timestamp", # AT - Trigger fuer Website-Scraping (AR, AS) + 'summarize_website': "Website Scrape Timestamp", # AT - Trigger fuer Website-Summarization (AS) + 'branch_eval': "Timestamp letzte Pruefung", # AO - Trigger fuer Branchen-Evaluation (W-Y) + 'find_wiki_serp': "SerpAPI Wiki Search Timestamp", # AY - Trigger fuer SerpAPI Wiki Search (M, AY) + 'contact_search': "Contact Search Timestamp", # AM - Trigger fuer LinkedIn Kontakt Suche (AI-AL, AM) + 'wiki_updates_from_chatgpt': "Chat Wiki Konsistenzpruefung", # S - Trigger fuer U->M Uebernahme (S) + # 'wiki_extract': "Wikipedia Timestamp", # AN - Trigger fuer Wiki-Extraktion (M-R, AN) - Wird in _process_single_row speziell geprueft + # 'ml_predict': "Geschaetzter Techniker Bucket" # AU - Trigger fuer ML Schaetzung (AU) - Wird in _process_single_row oder separater Methode speziell geprueft + } + + + # --- Interne Hilfsmethode zur Statuspruefung einer Zelle --- + # Dient zum sicheren Abrufen von Werten aus einer Zeile unter Verwendung von COLUMN_MAP. + # Nutzt globale Helfer: COLUMN_MAP, logger. + def _get_cell_value_safe(self, row, column_key): + """ + Hilfsfunktion fuer sicheren Zellenzugriff anhand des COLUMN_MAP Schluessels. + Gibt leeren String zurueck, wenn Index nicht existiert oder Zeile zu kurz ist. + + Args: + row (list): Die Listendaten fuer die Zeile. + column_key (str): Der Schluessel in COLUMN_MAP fuer die zu pruefende Spalte. + + Returns: + str: Der Wert der Zelle als String, oder '' wenn nicht verfuegbar. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Ermitteln Sie den Index der Spalte aus COLUMN_MAP (Block 1) + idx = COLUMN_MAP.get(column_key) + + # Pruefen Sie, ob der Schluessel in COLUMN_MAP gefunden wurde + if idx is None: + # Logge einen Fehler, aber geben Sie einen leeren String zurueck, um Abstuerze zu vermeiden + self.logger.error(f"_get_cell_value_safe: Schluessel '{column_key}' nicht in COLUMN_MAP gefunden.") + return '' # Gebe leeren String zurueck, wenn Schluessel fehlt + + + # Pruefen Sie, ob die Zeile lang genug ist, um auf diesen Index zuzugreifen + if len(row) > idx: + # Rueckgabe des Wertes, sicherstellen, dass es nicht None ist + return row[idx] if row[idx] is not None else '' + else: + # Logge auf Debug-Level, wenn der Index existiert, aber die Zeile zu kurz ist. + # Dies kann auf inkonsistente Zeilenlaengen im Sheet hindeuten. + self.logger.debug(f"_get_cell_value_safe: Index {idx} fuer '{column_key}' ist gueltig, aber Zeile ist zu kurz (Laenge {len(row)}). Gebe leeren String zurueck.") + return '' # Gebe leeren String zurueck, wenn die Zeile zu kurz ist + + # Der Code sollte niemals hierher gelangen. + # return '' # Fallback Rueckgabe + + +# ============================================================================== +# Ende DataProcessor Klasse Start & Init & Basis Status-Checker Block +# ============================================================================== + + + # --- Interne Hilfsmethoden zur Pruefung, ob ein Schritt ausgefuehrt werden soll --- + # Diese Methoden kapseln die Logik zur Entscheidung, ob ein Schritt basierend + # auf dem Zeilenstatus (Timestamps, Flags) und dem force_reeval Flag ausgefuehrt werden soll. + # Sie werden von _process_single_row (Block 19) aufgerufen. + # Nutzt interne Methode: _get_cell_value_safe. + # Nutzt globale Helfer: COLUMN_MAP, logger. + + # HINWEIS: Die Logik hier bezieht sich auf die "Gruppen" von Schritten, + # die in _process_single_row zusammengefasst sind ('web', 'wiki', 'chat'). + # Spezifischere Batch-Modi (Block 26-32) haben oft ihre eigene Zeilenauswahl-Logik, + # die nicht unbedingt diese Methoden verwendet. + + + def _needs_website_processing(self, row_data, force_reeval): + """ + Prueft, ob Website-Scraping/Summarization fuer diese Zeile noetig ist. + Nötig, wenn force_reeval True ist ODER wenn der Website Scrape Timestamp (AT) leer ist. + + Args: + row_data (list): Die Listendaten fuer die Zeile. + force_reeval (bool): True, wenn eine Re-Evaluation erzwungen wird (ignoriert Timestamps). + + Returns: + bool: True, wenn Website-Verarbeitung noetig ist. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + if force_reeval: + # self.logger.debug(" -> Website-Schritt noetig (force_reeval=True)") # Zu viel Laerm im Debug + return True # Wenn Re-Eval erzwungen wird, ist der Schritt noetig + + + # Pruefe, ob der Website Scrape Timestamp (AT) leer ist (nutzt interne Helfer) + at_value = self._get_cell_value_safe(row_data, "Website Scrape Timestamp").strip() + # Wenn der Timestamp leer ist, ist der Schritt noetig + if not at_value: + # self.logger.debug(" -> Website-Schritt noetig (AT leer)") # Zu viel Laerm im Debug + return True + + # Wenn der Timestamp gesetzt ist und kein Re-Eval erzwungen wird, ist der Schritt nicht noetig + # self.logger.debug(f" -> Website-Schritt nicht noetig (AT='{at_value}')") # Zu viel Laerm im Debug + return False + + + def _needs_wiki_processing(self, row_data, force_reeval): + """ + Prueft, ob Wikipedia-Suche/Extraktion fuer diese Zeile noetig ist. + Nötig, wenn force_reeval True ist ODER wenn der Wikipedia Timestamp (AN) + leer ist ODER wenn Status S 'X (URL Copied)' ist (signalisiert neue URL). + + Args: + row_data (list): Die Listendaten fuer die Zeile. + force_reeval (bool): True, wenn eine Re-Evaluation erzwungen wird. + + Returns: + bool: True, wenn Wiki-Suche/Extraktion noetig ist. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + if force_reeval: + # self.logger.debug(" -> Wiki-Extraktion/-Suche noetig (force_reeval=True)") # Zu viel Laerm im Debug + return True # Wenn Re-Eval erzwungen wird, ist der Schritt noetig + + + # Pruefe, ob der Wikipedia Timestamp (AN) leer ist (nutzt interne Helfer) + an_value = self._get_cell_value_safe(row_data, "Wikipedia Timestamp").strip() + # Wenn der Timestamp leer ist, ist der Schritt noetig + if not an_value: + # self.logger.debug(" -> Wiki-Extraktion/-Suche noetig (AN leer)") # Zu viel Laerm im Debug + return True + + # Pruefe, ob Status S 'X (URL Copied)' ist (signalisiert, dass eine neue URL durch den Update-Modus kopiert wurde und neu extrahiert werden muss) + s_value = self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzpruefung").strip().upper() + # Wenn Status S "X (URL COPIED)" ist, ist der Schritt noetig + if s_value == "X (URL COPIED)": + # self.logger.debug(" -> Wiki-Extraktion/-Suche noetig (S='X (URL Copied)')") # Zu viel Laerm im Debug + return True + + + # Wenn AN gesetzt ist, S nicht "X (URL COPIED)" ist und kein Re-Eval erzwungen wird, ist der Schritt nicht noetig + # self.logger.debug(f" -> Wiki-Extraktion/-Suche nicht noetig (AN='{an_value}', S='{s_value}')") # Zu viel Laerm im Debug + return False + + + def _needs_wiki_verification(self, row_data, force_reeval): + """ + Prueft, ob Wikipedia-Verifizierung (S-U) fuer diese Zeile noetig ist. + Nötig, wenn force_reeval True ist ODER wenn der Wiki Verif. Timestamp (AX) leer ist + UND eine Wiki URL (M) vorhanden ist. + + Args: + row_data (list): Die Listendaten fuer die Zeile. + force_reeval (bool): True, wenn eine Re-Evaluation erzwungen wird. + + Returns: + bool: True, wenn Wiki-Verifizierung noetig ist. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + if force_reeval: + # self.logger.debug(" -> Wiki-Verifizierung noetig (force_reeval=True)") # Zu viel Laerm im Debug + return True # Wenn Re-Eval erzwungen wird, ist der Schritt noetig + + + # Pruefe, ob der Wiki Verif. Timestamp (AX) leer ist (nutzt interne Helfer) + ax_value = self._get_cell_value_safe(row_data, "Wiki Verif. Timestamp").strip() + # Wenn der Timestamp leer ist + if not ax_value: + # Pruefe ZUSAETZLICH, ob eine Wiki URL (M) vorhanden ist und gueltig aussieht, da Verifizierung sonst sinnlos ist. + # Nutzt interne Helfer + m_value = self._get_cell_value_safe(row_data, "Wiki URL").strip() + # Wenn M vorhanden ist und nicht "k.A." oder ein Fehlereintrag + if m_value and m_value.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"]: # Fuege "http:" hinzu + # self.logger.debug(" -> Wiki-Verifizierung noetig (AX leer UND M gueltig)") # Zu viel Laerm im Debug + return True # Wenn AX leer und M gueltig, ist der Schritt noetig + # else: self.logger.debug(" -> Wiki-Verifizierung nicht noetig (AX leer, aber M leer/k.A.)") # Zu viel Laerm im Debug + + # Wenn AX gesetzt ist und kein Re-Eval erzwungen wird, ist der Schritt nicht noetig + # self.logger.debug(f" -> Wiki-Verifizierung nicht noetig (AX='{ax_value}')") # Zu viel Laerm im Debug + return False + + + def _needs_chat_evaluations(self, row_data, force_reeval, wiki_data_just_updated): + """ + Prueft, ob ChatGPT-Evaluationen (Branch, FSM etc.) fuer diese Zeile noetig sind. + Nötig, wenn force_reeval True ist ODER wenn der Timestamp letzte Pruefung (AO) + leer ist ODER wenn Wiki-Daten in diesem Lauf gerade aktualisiert wurden (Flag von _process_single_row). + + Args: + row_data (list): Die Listendaten fuer die Zeile. + force_reeval (bool): True, wenn eine Re-Evaluation erzwungen wird. + wiki_data_just_updated (bool): True, wenn die Wiki-Daten (M-R, AN) gerade in diesem Lauf aktualisiert wurden. + + Returns: + bool: True, wenn Chat-Evaluationen noetig sind. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + if force_reeval: + # self.logger.debug(" -> Chat-Evaluationen noetig (force_reeval=True)") # Zu viel Laerm im Debug + return True # Wenn Re-Eval erzwungen wird, ist der Schritt noetig + + + # Pruefe, ob der Timestamp letzte Pruefung (AO) leer ist (nutzt interne Helfer) + ao_value = self._get_cell_value_safe(row_data, "Timestamp letzte Pruefung").strip() + # Wenn der Timestamp leer ist, ist der Schritt noetig + if not ao_value: + # self.logger.debug(" -> Chat-Evaluationen noetig (AO leer)") # Zu viel Laerm im Debug + return True + + # Pruefe, ob Wiki-Daten in diesem Lauf gerade aktualisiert wurden (Flag aus _process_single_row) + # Wenn Wiki-Daten aktualisiert wurden, sollen die Chat-Evaluationen erneut laufen, auch wenn AO gesetzt ist. + if wiki_data_just_updated: + # self.logger.debug(" -> Chat-Evaluationen noetig (Wiki-Daten gerade aktualisiert)") # Zu viel Laerm im Debug + return True + + + # Wenn AO gesetzt ist, Wiki-Daten nicht aktualisiert wurden und kein Re-Eval erzwungen wird, ist der Schritt nicht noetig + # self.logger.debug(f" -> Chat-Evaluationen nicht noetig (AO='{ao_value}' und Wiki-Daten nicht aktualisiert)") # Zu viel Laerm im Debug + return False + + + def _needs_ml_prediction(self, row_data, force_reeval, chat_eval_just_ran): + """ + Prueft, ob die ML-Schaetzung (AU) fuer diese Zeile noetig ist. + Nötig, wenn force_reeval True ist ODER wenn der AU Bucket leer ist + UND (entweder AO gerade gesetzt wurde ODER konsolidierter Umsatz/Mitarbeiter vorhanden ist). + + Args: + row_data (list): Die Listendaten fuer die Zeile. + force_reeval (bool): True, wenn eine Re-Evaluation erzwungen wird. + chat_eval_just_ran (bool): True, wenn die ChatGPT-Evaluationen (AO) gerade in diesem Lauf liefen. + + Returns: + bool: True, wenn ML-Schaetzung noetig ist. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + if force_reeval: + # self.logger.debug(" -> ML-Schaetzung noetig (force_reeval=True)") # Zu viel Laerm im Debug + return True # Wenn Re-Eval erzwungen wird, ist der Schritt noetig + + + # Pruefe, ob der Geschaetzte Techniker Bucket (AU) leer ist (nutzt interne Helfer) + au_value = self._get_cell_value_safe(row_data, "Geschaetzter Techniker Bucket").strip() + # Wenn der Bucket leer ist oder einen Fehlerwert enthaelt + if not au_value or au_value.lower() in ["k.a.", "fehler schaetzung"]: + # self.logger.debug(f" -> ML-Schaetzung noetig (AU leer/Fehler)") # Zu viel Laerm im Debug + + # Pruefe ZUSAETZLICH, ob die Daten fuer die Schaetzung vorhanden/aktualisiert sind. + # Die Schaetzung ist sinnvoll, wenn A) Chat-Evaluationen gerade liefen (setzen AO und Konsolidierung AV/AW) + # ODER B) Konsolidierter Umsatz/Mitarbeiter (AV/AW) explizit vorhanden ist (auch wenn AO alt ist). + + # Pruefe, ob Chat-Evaluationen gerade liefen + if chat_eval_just_ran: + # self.logger.debug(" -> ML-Schaetzung noetig (Chat-Evaluationen gerade liefen)") # Zu viel Laerm im Debug + return True # Wenn Chat-Evaluationen gerade liefen, ist der Schritt noetig + + + # Pruefe, ob Konsolidierter Umsatz/Mitarbeiter (AV/AW) vorhanden ist (nutzt interne Helfer) + # Diese Werte werden normalerweise von _process_single_row gesetzt, wenn Chat lief, oder manuell gepflegt. + 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() + + # Wenn AV und AW vorhanden sind (nicht "k.A." oder Fehlerwerte) + if av_value != "k.A." and not av_value.startswith("FEHLER") and aw_value != "k.A." and not aw_value.startswith("FEHLER"): + # self.logger.debug(" -> ML-Schaetzung noetig (AV/AW vorhanden)") # Zu viel Laerm im Debug + return True # Wenn AV/AW vorhanden, ist der Schritt noetig + # else: self.logger.debug(" -> ML-Schaetzung nicht noetig (AV/AW fehlen)") # Zu viel Laerm im Debug + + + # Wenn AU gesetzt ist oder ein gueltiger Wert enthaelt, und kein Re-Eval erzwungen wird, ist der Schritt nicht noetig + # self.logger.debug(f" -> ML-Schaetzung nicht noetig (AU='{au_value}')") # Zu viel Laerm im Debug + return False + + +# ============================================================================== +# Ende DataProcessor Klasse Status-Checker Helpers Block +# ============================================================================== + + # --- Methode: Verarbeitung einer einzelnen Zeile --- + # Diese Methode ist das Herzstueck der Zeilen-basierten Verarbeitung. + # Sie fuehrt die einzelnen Anreicherungs- und Evaluationsschritte durch, + # basierend auf ausgewaehlten Schritten, Timestamps/Status, und dem force_reeval Flag. + # Nutzt interne Helfer: _get_cell_value_safe, _needs_... Methoden. + # Nutzt globale Helfer: COLUMN_MAP, logger, datetime, time, traceback. + # @retry_on_failure # Nicht sinnvoll auf dieser Orchestrierungsebene. Retries sind in den einzelnen Schritten. + 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, basierend auf Timestamps/Status + oder dem force_reeval Flag. Sammelt und schreibt Ergebnisse zurueck. + + Args: + row_num_in_sheet (int): Die 1-basierte Zeilennummer im Google Sheet. + row_data (list): Die rohen Listendaten fuer diese Zeile (Liste von Strings). + steps_to_run (set/list): Menge oder Liste von Schluesseln der Schritt-Gruppen, + die in diesem Lauf beruecksichtigt werden sollen + (z.B. {'wiki', 'web', 'chat', 'ml_predict'}). + force_reeval (bool, optional): Wenn True, werden Timestamps/Status ignoriert + und die Ausfuehrung der in steps_to_run + enthaltenen Schritte erzwungen. Defaults to False. + clear_x_flag (bool, optional): Wenn True UND force_reeval ist True, wird das 'x'-Flag + in Spalte A geloescht, wenn die Zeile verarbeitet wurde. + Defaults to False. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + self.logger.info(f"--- Starte Verarbeitung fuer Zeile {row_num_in_sheet} {'(Re-Eval)' if force_reeval else ''} (Schritte: {', '.join(steps_to_run) if steps_to_run else 'Keine ausgewählt'}) ---") + + # Liste zur Sammlung von Sheet-Updates fuer diese Zeile + # Updates sind Dictionaries: {'range': 'A1', 'values': [['Wert']]} + updates = [] + + # Aktueller Zeitstempel fuer die Timestamp-Spalten + now_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # Flag, ob IRGENDEINE Verarbeitung in dieser Zeile stattgefunden hat + any_processing_done = False + + # Flags, die den Zustand der Zeile waehrend DIESES Laufs verfolgen + wiki_data_updated_in_this_run = False # Wurden Wiki-Daten (M-R, AN) gerade aktualisiert? (Trigger fuer Chat) + chat_eval_just_ran = False # Liefen die ChatGPT-Evaluationen (AO) gerade? (Trigger fuer ML) + + + # --- Initiale Werte lesen --- + # Lesen Sie die Werte aus den Spalten, die fuer mehrere Schritte benoetigt werden. + # Nutzt die interne Helferfunktion _get_cell_value_safe, um sicher auf Zellen zuzugreifen. + # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind. + + # CRM Daten + company_name = self._get_cell_value_safe(row_data, "CRM Name").strip() + website_url = self._get_cell_value_safe(row_data, "CRM Website").strip() # Arbeitskopie der URL (kann sich aendern) + original_website_url_in_sheet = website_url # Originalwert aus Sheet behalten, fuer Website Lookup Logik + + crm_kurzform = self._get_cell_value_safe(row_data, "CRM Kurzform").strip() # Benoetigt fuer Contact Search + crm_branche = self._get_cell_value_safe(row_data, "CRM Branche").strip() + crm_beschreibung = self._get_cell_value_safe(row_data, "CRM Beschreibung").strip() + # CRM Umsatz und Mitarbeiter werden direkt aus row_data geholt, wenn benoetigt (z.B. fuer Konsolidierung) + + + # Vorhandene Wikipedia-Daten (aus dem Sheet) + # Dies sind die Daten, die am ANFANG im Sheet stehen. Sie werden im Wiki-Schritt (Block 21) + # durch extrahierte Daten ersetzt, wenn der Wiki-Schritt laeuft. + # Initialisiere mit "k.A." wenn die Zelle leer ist. + current_wiki_data = { + 'url': self._get_cell_value_safe(row_data, "Wiki URL") 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.' + } + # Arbeitskopie fuer die Wiki-Daten. Wird im Wiki-Schritt aktualisiert. + # Die Chat-Evaluationen (Block 22) und ML-Schaetzung (Block 23) nutzen diese finalen Daten. + final_wiki_data = current_wiki_data.copy() + + + # Vorhandener Website-Rohtext und Zusammenfassung (aus dem Sheet) + # Dies sind die Daten, die am ANFANG im Sheet stehen. Sie werden im Website-Schritt (Block 20) + # durch gescrapte Daten ersetzt, wenn der Website-Schritt laeuft. + # Initialisiere mit "k.A." wenn die Zelle leer ist. + current_website_raw = self._get_cell_value_safe(row_data, "Website Rohtext") or 'k.A.' + current_website_summary = self._get_cell_value_safe(row_data, "Website Zusammenfassung") or 'k.A.' + # Arbeitskopien fuer die Website-Daten. Werden im Website-Schritt aktualisiert. + # Die Chat-Evaluationen (Block 22) und ML-Schaetzung (Block 23) nutzen diese finalen Daten. + website_raw = current_website_raw # Kann im Website-Schritt aktualisiert werden + website_summary = current_website_summary # Kann im Website-Schritt aktualisiert werden + + + # --- Die Logik fuer die einzelnen Verarbeitungsschritte folgt in den naechsten Bloecken --- + # Jeder Schritt prueft, ob er in steps_to_run enthalten ist UND (ob er laut Status noetig ist ODER force_reeval True ist). + + # Website Handling (Block 20) folgt... + # Wikipedia Handling (Block 21) folgt... + # ChatGPT Evaluationen (Block 22) folgt... + # ML Prediction (Block 23) folgt... + # Finalisierung & Write (Block 23) folgt... + + # ====================================================================== + # === Verarbeitungsschritte innerhalb von _process_single_row ========== + # ====================================================================== + + # --- 1. Website Handling (Lookup, Scraping, Summarization) --- + # Dieser Schritt wird ausgefuehrt, wenn 'web' in steps_to_run enthalten ist UND + # (_needs_website_processing True ist ODER force_reeval True ist). + # _needs_website_processing prueft AT. Die Lookup-Logik (D leer) ist separat. + # Die Website-Verarbeitung umfasst Lookup (optional), Scraping und Summarization. + # Nutzt interne Helfer: _get_cell_value_safe, _needs_website_processing. + # Nutzt globale Helfer: COLUMN_MAP, logger, serp_website_lookup, get_website_raw, summarize_website_content, datetime, time. + + # Pruefen Sie, ob der Website-Schritt im aktuellen Lauf angefordert wurde + run_website_step = 'web' in steps_to_run + # Pruefen Sie, ob der Website-Schritt laut Status oder Re-Eval noetig ist + website_processing_needed_based_on_status = self._needs_website_processing(row_data, force_reeval) + + # Wenn der Website-Schritt angefordert wurde UND laut Status/Re-Eval noetig ist + if run_website_step and website_processing_needed_based_on_status: + any_processing_done = True # Markiere, dass in dieser Zeile etwas getan wird + + # Bestimme den Grund fuer die Ausfuehrung dieses Schritts fuer das Logging + grund_message_parts = [] + if force_reeval: grund_message_parts.append('Re-Eval') + if not self._get_cell_value_safe(row_data, "Website Scrape Timestamp").strip(): grund_message_parts.append('AT leer') + grund_message = ", ".join(grund_message_parts) + + self.logger.info(f"Zeile {row_num_in_sheet}: Fuehre WEBSITE Schritte aus (Grund: {grund_message})...") + + # Website Lookup nur, wenn die URL in Spalte D (CRM Website) leer oder "k.A." ist + # Nutzt die lokal gespeicherte Kopie der URL, die ggf. im Lookup ueberschrieben wird. + if not website_url or website_url.lower() == "k.a.": + self.logger.debug(" -> Website URL (D) leer oder k.A., suche ueber SERP...") + # Annahme: serp_website_lookup global definiert (Block 10) und nutzt logging/retry + try: + # Der serp_website_lookup Aufruf ist mit retry_on_failure dekoriert. + # Wenn er nach Retries fehlschlaegt, wirft er eine Exception. + new_website = serp_website_lookup(company_name) # Nutzt globalen Helfer (Block 10) + # Wenn eine neue Website gefunden wurde + if new_website != "k.A.": + # Ueberschreibe die lokale Variable website_url fuer den weiteren Schritt (Scraping) + website_url = new_website + # Fuegen Sie das Update fuer Spalte D zur Liste der Sheet-Updates hinzu + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["CRM Website"] + 1)}{row_num_in_sheet}', 'values': [[website_url]]}) + self.logger.info(f" -> Neue Website gefunden und fuer Update D:{row_num_in_sheet} vorgemerkt: {website_url[:100]}...") # Gekuerzt loggen + else: + # Wenn keine neue Website gefunden wurde + self.logger.warning(f" -> Keine neue Website ueber SERP gefunden fuer '{company_name[:100]}...'.") # Gekuerzt loggen + # website_url bleibt leer oder k.A. in diesem Fall. + + except Exception as e_serp_lookup: + # Wenn serp_website_lookup eine Exception wirft (nach Retries) + self.logger.error(f"FEHLER bei SERP Website Lookup fuer '{company_name[:100]}...': {e_serp_lookup}") # Gekuerzt loggen + # Bei Fehler bleibt website_url unveraendert (leer oder k.A.). Fahren Sie fort. + pass # Fahren Sie fort, falls eine URL im Sheet war oder gefunden wurde + + # Führen Sie Scraping und Zusammenfassung nur durch, wenn eine gueltige Website URL vorhanden ist (lokale Variable website_url) + # Ueberpruefen Sie auf nicht-leere website_url und ungleich "k.A." oder Fehlerwerten. + if website_url and website_url.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"]: + self.logger.debug(f" -> Scrape Rohtext von {website_url[:100]}...") # Gekuerzt loggen + # Annahme: get_website_raw global definiert (Block 11) und nutzt logging/retry + try: + # Der get_website_raw Aufruf ist mit retry_on_failure dekoriert. + # Wenn er nach Retries fehlschlaegt, wirft er eine Exception oder gibt einen Fehlerwert zurueck. + new_website_raw = get_website_raw(website_url) # Nutzt globalen Helfer (Block 11) + website_raw = new_website_raw # Aktualisiere die lokale Variable (AR Wert) + + # Zusammenfassung nur, wenn gueltiger Rohtext extrahiert wurde. + # Pruefen Sie auf nicht-leeren raw_text und ungleich Standard-Fehlerwerten. + if website_raw and str(website_raw).strip().lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]: + self.logger.debug(f" -> Fasse Rohtext zusammen (Laenge: {len(str(website_raw))})...") + # Annahme: summarize_website_content global definiert (Block 9) und nutzt logging/retry + try: + # Der summarize_website_content Aufruf ist mit retry_on_failure dekoriert. + # Wenn er nach Retries fehlschlaegt, wirft er eine Exception oder gibt einen Fehlerwert zurueck. + new_website_summary = summarize_website_content(website_raw) # Nutzt globalen Helfer (Block 9) + # Aktualisiere die lokale Variable (AS Wert). Wenn das Ergebnis leer ist, setze "k.A.". + website_summary = new_website_summary if new_website_summary and new_website_summary.strip() else "k.A. (Keine Zusammenfassung erhalten)" + # Fuegen Sie das Update fuer Spalte AS (Website Zusammenfassung) zur Liste hinzu + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[website_summary]]}) + + except Exception as e_summary: + # Wenn summarize_website_content eine Exception wirft (nach Retries) + self.logger.error(f"FEHLER bei Website Zusammenfassung fuer '{company_name[:100]}...': {e_summary}") # Gekuerzt loggen + # Setze die lokale Variable auf einen Fehlerwert + website_summary = f"k.A. (Fehler Zusammenfassung: {str(e)[:100]}...)" + # Fuegen Sie ein Update mit dem Fehlerwert fuer Spalte AS hinzu + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[website_summary]]}) + pass # Fahren Sie fort + + else: + # Wenn kein gueltiger Rohtext zum Zusammenfassen vorhanden war + self.logger.debug(" -> Kein gueltiger Rohtext zum Zusammenfassen vorhanden. Zusammenfassung uebersprungen.") + # Stellen Sie sicher, dass die lokale Variable korrekt gesetzt ist, falls nicht zusammengefasst + website_summary = "k.A." + # Fuege 'k.A.' Update fuer AS hinzu (nur wenn es vorher nicht k.A. war?) + # Oder immer setzen, wenn der Schritt lief und keine Zusammenfassung erstellt wurde. + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]}) + + + # Fuegen Sie das Update fuer Spalte AR (Website Rohtext) zur Liste hinzu (auch wenn es ein Fehlerwert ist) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [[website_raw]]}) + + except Exception as e_scrape: + # Wenn get_website_raw eine Exception wirft (nach Retries) + self.logger.error(f"FEHLER beim Website Scraping fuer '{company_name[:100]}' ({website_url[:100]}...): {e_scrape}") # Gekuerzt loggen + # Setze die lokalen Variablen auf Fehlerwerte + website_raw = f"k.A. (Fehler Scraping: {str(e)[:100]}...)" + website_summary = "k.A. (Fehler Zusammenfassung)" # Zusammenfassung fehlschlaegt auch + # Fuegen Sie Updates mit Fehlerwerten fuer AR und AS hinzu + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [[website_raw]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[website_summary]]}) + pass # Fahren Sie fort + + else: + # Wenn keine gueltige Website URL vorhanden/gefunden wurde + self.logger.debug(f" -> Keine gueltige Website URL vorhanden/gefunden fuer '{company_name[:100]}...'. Website Verarbeitung uebersprungen.") # Gekuerzt loggen + # Stellen Sie sicher, dass AR und AS auf k.A. gesetzt werden, wenn der Schritt lief, aber keine URL da war. + # Die lokalen Variablen behalten ihre initialen Werte (current_...) wenn der Schritt uebersprungen wurde, + # aber wenn der Schritt lief, aber keine URL da war, sollten sie auf k.A. gesetzt werden. + # Setzen Sie explizit auf k.A. oder Fehlerwerte, falls der Schritt lief aber fehlschlug wegen URL. + website_raw = "k.A." + website_summary = "k.A." + # Fuegen Sie Updates fuer AR und AS hinzu, falls noetig (Vermeidung von doppelten k.A. Updates) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]}) + + + # Setzen Sie den Website Scrape Timestamp (AT), da der Website-Schritt lief (auch wenn fehlerhaft) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Scrape Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) + + # else if run_website_step: + # Der Website Schritt war angefordert, aber nicht noetig basierend auf Status/Re-Eval. + # Die lokalen Variablen website_raw und website_summary behalten ihre initialen Werte (current_...). + # self.logger.debug(f"Zeile {row_num_in_sheet}: Ueberspringe WEBSITE Schritte (AT vorhanden und kein Re-Eval).") # Zu viel Laerm im Debug + + + # --- Der Code fuer den naechsten Verarbeitungsschritt (Wikipedia) folgt im naechsten Block --- + # Definition der Methode _process_single_row wird in der naechsten Nachricht fortgesetzt. + + # --- 2. Wikipedia Handling (Search, Extraction, Status Reset) --- + # Dieser Schritt wird ausgefuehrt, wenn 'wiki' in steps_to_run enthalten ist UND + # (_needs_wiki_processing True ist ODER force_reeval True ist). + # _needs_wiki_processing prueft AN und S='X (URL Copied)'. + # Die Logik fuer S='X (URL Copied)' dient dazu, eine URL, die durch die Wiki-Update + # Funktion in M kopiert wurde, sofort neu extrahieren zu lassen. + # Nutzt interne Helfer: _get_cell_value_safe, _needs_wiki_processing. + # Nutzt globale Helfer: COLUMN_MAP, logger, wikipedia, wikipedia.exceptions, + # retry_on_failure, unquote, time, traceback. + # Nutzt die uebergebene wiki_scraper Instanz. + + # Pruefen Sie, ob der Wiki-Schritt im aktuellen Lauf angefordert wurde + run_wiki_step = 'wiki' in steps_to_run + # Pruefen Sie, ob der Wiki-Schritt laut Status oder Re-Eval noetig ist + wiki_processing_needed_based_on_status = self._needs_wiki_processing(row_data, force_reeval) + + + # Wenn der Wiki-Schritt angefordert wurde UND laut Status/Re-Eval noetig ist + if run_wiki_step and wiki_processing_needed_based_on_status: + any_processing_done = True # Markiere, dass in dieser Zeile etwas getan wird + + # Bestimme den Grund fuer die Ausfuehrung dieses Schritts fuer das Logging + grund_message_parts = [] + if force_reeval: grund_message_parts.append('Re-Eval') + # Pruefe, ob der Timestamp AN leer ist (nutzt interne Helfer) + if not self._get_cell_value_safe(row_data, "Wikipedia Timestamp").strip(): grund_message_parts.append('AN leer') + # Pruefe, ob Status S "X (URL Copied)" ist (nutzt interne Helfer) + if self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzpruefung").strip().upper() == "X (URL COPIED)": grund_message_parts.append("S='X (URL Copied)'") + grund_message = ", ".join(grund_message_parts) + + self.logger.info(f"Zeile {row_num_in_sheet}: Fuehre WIKI Suche/Extraktion aus (Grund: {grund_message})...") + + # Holen Sie die aktuelle Wiki URL aus Spalte M (nutzt interne Helfer) + url_in_m = self._get_cell_value_safe(row_data, "Wiki URL").strip() + url_to_extract = None # Die URL, von der wir am Ende Daten extrahieren werden + search_was_needed = False # Flag, ob eine neue Suche durchgefuehrt wurde + + + # --- Logik zur Bestimmung der URL, die verwendet werden soll --- + # Prioritaet (bei Ausfuehrung des Wiki-Schritts): + # 1. Wenn Status S == "X (URL Copied)": Ignoriere URL in M, fuehre neue Suche aus. + # 2. Wenn force_reeval True: Nimm URL in M, WENN gueltig aussehend. Sonst neue Suche. + # 3. Wenn AN leer (und kein S="X(URL Copied)", kein Re-Eval): Nimm URL in M, WENN valide. Sonst neue Suche. + + status_s_indicates_reparse = self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzpruefung").strip().upper() == "X (URL COPIED)" + an_value = self._get_cell_value_safe(row_data, "Wikipedia Timestamp").strip() + # Pruefe, ob die URL in M existiert und wie eine Wikipedia-URL aussieht (ignoriert "k.A." und Fehlerwerte) + m_url_exists_and_looks_valid = url_in_m and isinstance(url_in_m, str) and "wikipedia.org/wiki/" in url_in_m.lower() and url_in_m.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"] # Fuege "http:" hinzu + + + # Bestimmen Sie, ob eine neue Suche notwendig ist + if status_s_indicates_reparse: + # Wenn Status S signalisiert, dass eine neu kopierte URL extrahiert werden soll, fuehre immer eine Suche aus. + self.logger.warning(f" -> Status S ist 'X (URL Copied)', ignoriere URL '{url_in_m[:100]}...' in M und starte neue Suche...") # Gekuerzt loggen + search_was_needed = True # Suche ist noetig + + elif force_reeval: + # Wenn Re-Eval erzwungen wird + self.logger.debug(" -> Re-Eval Modus aktiv fuer Wiki-Schritt.") + # Wenn die URL in M existiert und gueltig aussieht + if m_url_exists_and_looks_valid: + # Im Re-Eval Modus nehmen wir die URL aus M an, OHNE erneute Validierung oder Suche (Vertrauen auf M!). + self.logger.info(f" -> Re-Eval: Nutze vorhandene URL aus Spalte M direkt: {url_in_m[:100]}...") # Gekuerzt loggen + url_to_extract = url_in_m # Verwende die URL aus M direkt + else: + # Wenn M leer/ungueltig ist, auch im Re-Eval Modus neu suchen + self.logger.debug(f" -> Re-Eval: Spalte M ist leer oder ungueltig ('{url_in_m[:100]}...'). Starte neue Suche...") # Gekuerzt loggen + search_was_needed = True # Suche ist noetig + + elif not an_value: + # Wenn AN leer ist (und kein S="X(Copied)" oder Re-Eval) + # Wenn die URL in M existiert und gueltig aussieht + if m_url_exists_and_looks_valid: + # Wenn AN fehlt und M gefuellt ist, pruefen wir die Validitaet der M-URL ueber die wikipedia Bibliothek. + self.logger.debug(f" -> AN fehlt, pruefe Validitaet der URL aus M: {url_in_m[:100]}...") # Gekuerzt loggen + try: + # Extrahieren des Titels aus der URL fuer wikipedia.page (nutzt globale Helfer) + # Dieser Aufruf kann Exceptions werfen (PageError, DisambiguationError). + title_from_url_part = url_in_m.split('/wiki/', 1)[1].split('#')[0] # Titelteil nach /wiki/, Anker entfernen + title_from_url = unquote(title_from_url_part).replace('_', ' ') # Dekodieren und Unterstriche ersetzen + + # Laden des Page Objekts, um es mit _validate_article zu pruefen. + # wikipedia.page nutzt intern Requests und API calls, die fehlschlagen koennen. + # wikipedia.page selbst kann wikipedia.exceptions werfen. + # Wir fangen diese spezifischen wikipedia.exceptions hier ab, aber andere RequestsExceptions + # werden vom retry_on_failure auf extract_company_data gefangen (spaeter). + page_from_m = wikipedia.page(title_from_url, auto_suggest=False, preload=True) + + # Validierung des Artikels mit der Scraper-Methode (nutzt interne Methode) + # _validate_article kann interne Fehler haben (z.B. bei HTML Parsing), aber faengt sie. + if self.wiki_scraper._validate_article(page_from_m, company_name, website_url): + url_to_extract = page_from_m.url # Die URL ist valide und wird verwendet + self.logger.info(f" -> Vorhandene URL aus M '{url_to_extract[:100]}...' ist valide und wird verwendet.") # Gekuerzt loggen + else: + # Wenn der Artikel aus M nicht validiert wird + self.logger.warning(f" -> Vorhandene URL aus M '{page_from_m.title[:100]}...' ist NICHT valide. Starte neue Suche...") # Gekuerzt loggen + search_was_needed = True # Suche ist noetig + + except (wikipedia.exceptions.PageError, wikipedia.exceptions.DisambiguationError) as e_wiki_m: + # Wenn die URL in M zu einem nicht existierenden Artikel oder einer Begriffsklaerung fuehrt + self.logger.warning(f" -> Vorhandene URL aus M '{url_in_m[:100]}...' fuehrt zu Fehler ({type(e_wiki_m).__name__}). Starte neue Suche...") # Gekuerzt loggen + # Logge die Disambiguation Optionen auf Debug, falls vorhanden + if isinstance(e_wiki_m, wikipedia.exceptions.DisambiguationError): + self.logger.debug(f" -> Disambiguation Optionen: {str(e_wiki_m.options)[:100]}...") # Gekuerzt loggen + search_was_needed = True # Suche ist noetig + pass # Faert fort + + except Exception as e_val_m: + # Fange andere unerwartete Fehler beim Pruefen der URL aus M ab (z.B. URL-Parsing-Fehler vor wikipedia.page) + self.logger.exception(f" -> Unerwarteter Fehler beim Pruefen der URL aus M '{url_in_m[:100]}...': {e_val_m}. Starte neue Suche...") # Gekuerzt loggen + search_was_needed = True # Suche ist noetig + pass # Faert fort + + else: + # M ist leer/ungueltig und AN fehlt -> Suche starten + self.logger.debug(f" -> AN fehlt und M leer/ungueltig ('{url_in_m[:100]}...'). Starte Wikipedia-Suche fuer '{company_name[:100]}...'...") # Gekuerzt loggen + search_was_needed = True # Suche ist noetig + + # --- Führe die Suche aus, wenn search_was_needed True ist --- + if search_was_needed: + self.logger.debug(f" -> Fuehre Wikipedia Suche ueber scraper durch...") + try: + # Rufe die search_company_article Methode des Scrapers auf. + # search_company_article ist mit retry_on_failure dekoriert und wirft bei endgueltigem Fehler eine Exception. + # Nutzt die ggf. neue Website URL fuer Kontext im search_company_article. + validated_page = self.wiki_scraper.search_company_article(company_name, website_url) # Nutzt die uebergebene scraper Instanz + + if validated_page: + # Wenn ein validierter Artikel gefunden wurde, setze die URL, von der extrahiert werden soll. + url_to_extract = validated_page.url + self.logger.info(f" -> Suche erfolgreich, validierte URL: {url_to_extract[:100]}...") # Gekuerzt loggen + else: + # Wenn die Suche keinen validierten Artikel fand + self.logger.debug(f" -> Suche fand keinen validierten Artikel fuer '{company_name[:100]}...'.") # Gekuerzt loggen + url_to_extract = 'Kein Artikel gefunden' # Signalisiert kein Artikel gefunden + + except Exception as e_wiki_search: + # Wenn search_company_article eine Exception wirft (nach Retries) + # Der Fehler wird bereits vom retry_on_failure Decorator geloggt. + self.logger.error(f"FEHLER bei Wikipedia Suche fuer '{company_name[:100]}...': {e_wiki_search}") # Gekuerzt loggen + url_to_extract = f"FEHLER bei Suche: {str(e_wiki_search)[:50]}..." # Signalisiert Fehler bei Suche (gekuerzt) + # Pass, faert fort, um zumindest den Status zu setzen. + pass + + + # --- Datenextraktion, wenn eine URL bestimmt wurde, von der extrahiert werden soll --- + # Extrahiere Daten, wenn url_to_extract einen Wert hat, der NICHT "Kein Artikel gefunden" oder ein Fehlerstring ist. + if url_to_extract and isinstance(url_to_extract, str) and url_to_extract.lower() not in ['kein artikel gefunden'] and not url_to_extract.startswith("FEHLER"): + self.logger.debug(f" -> Extrahiere Daten von URL: {url_to_extract[:100]}...") # Gekuerzt loggen + try: + # Rufe die extract_company_data Methode des Scrapers auf. + # extract_company_data ist mit retry_on_failure dekoriert und wirft bei endgueltigem Fehler eine Exception. + extracted_data = self.wiki_scraper.extract_company_data(url_to_extract) # Nutzt die uebergebene scraper Instanz + + # Pruefen Sie, ob die Extraktion erfolgreich war (nicht None oder mit Fehlerwert) + if extracted_data and isinstance(extracted_data, dict) and extracted_data.get('url') != 'k.A.': # Pruefe auf gueltige Extraktion + final_wiki_data = extracted_data # Aktualisiere die Arbeitskopie der Wiki-Daten mit den extrahierten Daten. + wiki_data_updated_in_this_run = True # Markieren, dass extrahierte Daten da sind (Trigger fuer Chat). + self.logger.info(f" -> Datenextraktion von {url_to_extract[:100]}... erfolgreich.") # Gekuerzt loggen + else: + # Wenn extrahierte Daten leer oder ungueltig sind (z.B. parse Fehler intern) + self.logger.error(f" -> Fehler bei Datenextraktion von {url_to_extract[:100]}... oder Extraktion war leer. Setze Daten auf 'k.A.'") # Gekuerzt loggen + # Behalte die URL, aber setze alle anderen Felder auf k.A. oder Fehler. + final_wiki_data = {'url': url_to_extract, 'first_paragraph': 'k.A. (Extraktion fehlgeschlagen)', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'} + wiki_data_updated_in_this_run = True # Markieren, dass die Daten ueberschrieben werden. + + except Exception as e_wiki_extract: + # Wenn extract_company_data eine Exception wirft (nach Retries) + self.logger.error(f"FEHLER bei Wikipedia Datenextraktion von {url_to_extract[:100]}...: {e_wiki_extract}") # Gekuerzt loggen + # Setze Daten auf k.A., behalte aber die URL, von der extrahiert werden sollte + final_wiki_data = {'url': url_to_extract, 'first_paragraph': f'k.A. (FEHLER Extraktion: {str(e)[:50]}...)', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'} + wiki_data_updated_in_this_run = True # Markieren, dass die Daten ueberschrieben werden. + pass # Faert fort + + else: + # Wenn keine gueltige URL zum Extrahieren bestimmt wurde (z.B. Suche fand nichts oder Fehler bei Suche) + self.logger.debug(f" -> Keine gueltige URL zum Extrahieren bestimmt ('{url_to_extract}'). Wiki-Daten nicht extrahiert.") + # final_wiki_data behaelt die current_wiki_data Werte (initial geladen) oder wurde oben bei Suche auf "Kein Artikel gefunden"/"FEHLER" gesetzt. + # Stelle sicher, dass final_wiki_data die richtige URL enthaelt, auch wenn keine Extraktion stattfand. + if url_to_extract in ['Kein Artikel gefunden', 'FEHLER bei Suche']: + final_wiki_data['url'] = url_to_extract # Update nur die URL im Ergebnis + + # --- Sheet Updates fuer M-R und AN --- + # Diese Updates werden immer hinzugefuegt, WENN der WIKI-Schritt lief (run_wiki_step and wiki_processing_needed_based_on_status war True). + # Auch wenn die Suche/Extraktion fehlschlug (dann werden k.A. oder Fehlermeldungen geschrieben). + # Aktualisiere die Spalten M-R mit den finalen Daten im final_wiki_data Dictionary. + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki URL"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('url', 'k.A.')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Absatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('first_paragraph', 'k.A.')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Branche"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('branche', 'k.A.')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('umsatz', 'k.A.')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('mitarbeiter', 'k.A.')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Kategorien"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('categories', 'k.A.')]]}) + + # Setze den Wikipedia Timestamp (AN), da der Wiki-Schritt lief (auch wenn fehlerhaft) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wikipedia Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) + + + # --- Setze S ('Chat Wiki Konsistenzpruefung') und AX ('Wiki Verif. Timestamp') zurueck, wenn Neubewertung noetig ist --- + # Eine Neubewertung (Zuruecksetzen von S und AX) ist noetig, wenn: + # - force_reeval True ist (immer bei Re-Eval des Wiki-Schritts) + # - Status S zuvor "X (URL Copied)" war (der Trigger fuer die Re-Extraktion) + # - Die neue URL in M (final_wiki_data['url']) anders ist als die urspruengliche URL aus M (url_in_m), UND die neue URL gueltig ist. + + status_s_indicates_reparse = self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzpruefung").strip().upper() == "X (URL COPIED)" + # Pruefe, ob die FINAL_wiki_data URL (nach Suche/Extraktion) anders ist als die URSPRUENGLICHE URL in M im Sheet. + # UND stelle sicher, dass die neue URL eine gueltige URL ist (nicht "k.A." oder Fehlerstring). + url_changed_and_valid = (url_in_m != final_wiki_data.get('url')) and isinstance(final_wiki_data.get('url'), str) and final_wiki_data.get('url').lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche"] + + # Bestimme, ob S und AX zurueckgesetzt werden sollen + if force_reeval or status_s_indicates_reparse or url_changed_and_valid: + s_idx = COLUMN_MAP.get("Chat Wiki Konsistenzpruefung") + ax_idx = COLUMN_MAP.get("Wiki Verif. Timestamp") + if s_idx is not None and ax_idx is not None: + s_let = self.sheet_handler._get_col_letter(s_idx + 1) + ax_let = self.sheet_handler._get_col_letter(ax_idx + 1) + + # Fuegen Sie die Updates zum Zuruecksetzen von S und AX hinzu + # S wird auf '?' gesetzt, um anzuzeigen, dass eine Verifizierung aussteht + updates.append({'range': f'{s_let}{row_num_in_sheet}', 'values': [["?"]]}) + # AX wird geleert, um die Batch-Verifizierung zu triggern + updates.append({'range': f'{ax_let}{row_num_in_sheet}', 'values': [[""]]}) + + # Bestimme den Grund-String fuer das Logging + grund_message_parts = [] + if force_reeval: grund_message_parts.append('Re-Eval') + if status_s_indicates_reparse: grund_message_parts.append("S='X (URL Copied)'") + if url_changed_and_valid: grund_message_parts.append('URL geaendert und gueltig') + grund_message_s_reset = ", ".join(grund_message_parts) + + self.logger.info(f" -> Status S zurueckgesetzt auf '?' und Timestamp AX geleert fuer erneute Verifikation (Grund: {grund_message_s_reset}).") + else: + # Logge Fehler, wenn Spaltenindizes fehlen + self.logger.error("FEHLER: Konnte Spaltenbuchstaben fuer S oder AX nicht ermitteln. Zuruecksetzen uebersprungen.") + + + # else if run_wiki_step: + # Der Wiki Schritt war angefordert, aber nicht noetig basierend auf Status/Re-Eval. + # Die lokalen Variablen final_wiki_data behaelt die initialen Werte (current_...). + # self.logger.debug(f"Zeile {row_num_in_sheet}: Ueberspringe WIKI Suche/Extraktion (AN vorhanden, S nicht 'X (URL Copied)' und kein Re-Eval).") # Zu viel Laerm im Debug + + + # --- Der Code fuer den naechsten Verarbeitungsschritt (ChatGPT Evaluationen) folgt im naechsten Block --- + # Definition der Methode _process_single_row wird in der naechsten Nachricht fortgesetzt. + + # --- 3. ChatGPT Evaluationen (Branch, FSM, Emp, Umsatz Schaetzungen etc.) --- + # Dieser Schritt wird ausgefuehrt, wenn 'chat' in steps_to_run enthalten ist UND + # (_needs_chat_evaluations True ist ODER force_reeval True ist). + # _needs_chat_evaluations prueft AO oder ob Wiki-Daten in diesem Lauf gerade aktualisiert wurden. + # Nutzt interne Helfer: _get_cell_value_safe, _needs_chat_evaluations. + # Nutzt globale Helfer: COLUMN_MAP, logger, datetime, time, + # evaluate_branche_chatgpt (Block 10), + # (Optional: evaluate_fsm_suitability, evaluate_employee_chatgpt, evaluate_umsatz_chatgpt - muessen implementiert werden). + # Nutzt lokale Variablen: crm_branche, crm_beschreibung, final_wiki_data, website_summary, wiki_data_updated_in_this_run. + + # Pruefen Sie, ob die Chat-Schritte im aktuellen Lauf angefordert wurden + run_chat_step = 'chat' in steps_to_run + # Pruefen Sie, ob die Chat-Schritte laut Status, Re-Eval oder Wiki-Update noetig sind. + # wiki_data_just_updated_in_this_run ist ein Flag aus dem vorherigen Wiki-Schritt (Block 19). + chat_processing_needed_based_on_status = self._needs_chat_evaluations(row_data, force_reeval, wiki_data_updated_in_this_run) + + + # Wenn die Chat-Schritte angefordert wurden UND laut Status/Re-Eval noetig sind + if run_chat_step and chat_processing_needed_based_on_status: + any_processing_done = True # Markiere, dass in dieser Zeile etwas getan wird + + # Setzen Sie das Flag, dass Chat-Evaluationen liefen (koennte ML ausloesen Block 23) + chat_eval_just_ran = True + + # Bestimme den Grund fuer die Ausfuehrung dieses Schritts fuer das Logging + grund_message_parts = [] + if force_reeval: grund_message_parts.append('Re-Eval') + # Pruefe, ob der Timestamp AO leer ist (nutzt interne Helfer) + if not self._get_cell_value_safe(row_data, "Timestamp letzte Pruefung").strip(): grund_message_parts.append('AO leer') + # Pruefe, ob Wiki-Daten gerade aktualisiert wurden (Flag aus Block 19) + if wiki_data_updated_in_this_run: grund_message_parts.append('Wiki Daten gerade aktualisiert') + grund_message = ", ".join(grund_message_parts) + + self.logger.info(f"Zeile {row_num_in_sheet}: Fuehre CHATGPT Evaluationen aus (Grund: {grund_message})...") + + # Hole die notwendigen Daten fuer die ChatGPT-Calls. + # Nutzt die initial geladenen CRM-Daten und die finalen Daten aus den vorherigen Schritten (Wiki, Website). + # crm_branche, crm_beschreibung wurden initial geladen (Block 17). + # final_wiki_data wurde im Wiki-Schritt (Block 19) aktualisiert oder behaelt alte Werte. + # website_summary wurde im Website-Schritt (Block 18) aktualisiert oder behaelt alte Werte. + + # --- 3a. Branchen-Einstufung (W, X, Y) --- + self.logger.debug(" -> Starte Branchen-Einstufung ueber ChatGPT...") + try: + # Annahme: evaluate_branche_chatgpt global definiert (Block 10) und nutzt logging/retry + # evaluate_branche_chatgpt braucht Zugriff auf globale ALLOWED_TARGET_BRANCHES und TARGET_SCHEMA_STRING (Block 7) + # Der Aufruf ist mit retry_on_failure dekoriert und wirft bei endgueltigem Fehler eine Exception. + branch_result = evaluate_branche_chatgpt( + crm_branche, # Nutzt initial geladenen CRM Wert + crm_beschreibung, # Nutzt initial geladenen CRM Wert + final_wiki_data.get('branche', 'k.A.'), # Nutzt ggf. neue Wiki-Branche aus Block 19 + final_wiki_data.get('categories', 'k.A.'), # Nutzt ggf. neue Wiki-Kategorien aus Block 19 + website_summary # Nutzt ggf. neue Website-Zusammenfassung aus Block 18 + ) + # Sammle Updates fuer die Branchen-Spalten (W, X, Y) (nutzt interne Helfer) + # Stellen Sie sicher, dass die Schluessel im Ergebnis-Dict vorhanden sind, Fallback auf Standard-Fehlerwerte. + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get("branch", "FEHLER")]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenz Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get("consistency", "error")]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung Abweichung Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get("justification", "Keine Begruendung")]]}) + + except Exception as e_branch_eval: + # Wenn evaluate_branche_chatgpt eine Exception wirft (nach Retries) + # Der Fehler wird bereits vom retry_on_failure Decorator oder evaluate_branche_chatgpt geloggt. + self.logger.error(f"FEHLER bei Branchen-Einstufung ueber ChatGPT fuer Zeile {row_num_in_sheet}: {e_branch_eval}") + # Fuegen Sie Updates mit Fehlerwerten hinzu, um den Fehler im Sheet zu dokumentieren. + error_msg = f"Fehler: {str(e_branch_eval)[:100]}..." # Kuerze Fehlermeldung + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER']]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenz Branche"] + 1)}{row_num_in_sheet}', 'values': [['error']]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung Abweichung Branche"] + 1)}{row_num_in_sheet}', 'values': [[error_msg]]}) + pass # Fahren Sie fort mit den naechsten Schritten, auch wenn Branchenevaluation fehlschlug + + + # --- 3b. FSM Relevanz Bewertung (Z, AA) --- + # TODO: Implementieren Sie die Logik und den Aufruf der Funktion evaluate_fsm_suitability + self.logger.debug(" -> Starte FSM Relevanz Bewertung (Platzhalter)...") + # Beispielaufruf (angenommen, evaluate_fsm_suitability existiert global): + # try: + # fsm_result = evaluate_fsm_suitability( + # company_name, # Nutzt initial geladenen CRM Namen + # {'crm_desc': crm_beschreibung, 'wiki_paragraph': final_wiki_data.get('first_paragraph', 'k.A.'), 'web_summary': website_summary} + # ) + # # Sammle Updates fuer FSM Spalten (Z, AA) + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Pruefung FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[fsm_result.get('suitability', 'k.A.')]]}) + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung fuer FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[fsm_result.get('justification', 'k.A.')]]}) + # except Exception as e_fsm_eval: + # self.logger.error(f"FEHLER bei FSM Relevanz Bewertung fuer Zeile {row_num_in_sheet}: {e_fsm_eval}") + # # Fuege Fehler-Updates hinzu + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Pruefung FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER']]}) + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung fuer FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[f'Fehler: {str(e_fsm_eval)[:100]}...']]}) + # pass # Faert fort + + + # --- 3c. Mitarbeiterzahl Schaetzung (AB, AC, AD) --- + # TODO: Implementieren Sie die Logik und den Aufruf der Funktion evaluate_employee_chatgpt + self.logger.debug(" -> Starte Mitarbeiterzahl Schaetzung (Platzhalter)...") + # Beispielaufruf (angenommen, evaluate_employee_chatgpt existiert global): + # try: + # emp_estimate_result = evaluate_employee_chatgpt( + # company_name, # Nutzt initial geladenen CRM Namen + # {'crm_emp': self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter"), 'wiki_emp': final_wiki_data.get('mitarbeiter', 'k.A.'), 'wiki_paragraph': final_wiki_data.get('first_paragraph', 'k.A.'), 'web_summary': website_summary} + # ) + # # Sammle Updates fuer Mitarbeiter Schaetzspalten (AB, AC, AD) + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schaetzung Anzahl Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[emp_estimate_result.get('estimate', 'k.A.')]]}) + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenzpruefung Mitarbeiterzahl"] + 1)}{row_num_in_sheet}', 'values': [[emp_estimate_result.get('consistency', 'k.A.')]]}) + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung Abweichung Mitarbeiterzahl"] + 1)}{row_num_in_sheet}', 'values': [[emp_estimate_result.get('justification', 'k.A.')]]}) + # except Exception as e_emp_eval: + # self.logger.error(f"FEHLER bei Mitarbeiterzahl Schaetzung fuer Zeile {row_num_in_sheet}: {e_emp_eval}") + # # Fuege Fehler-Updates hinzu + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schaetzung Anzahl Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER']]}) + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenzpruefung Mitarbeiterzahl"] + 1)}{row_num_in_sheet}', 'values': [['error']]}) + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung Abweichung Mitarbeiterzahl"] + 1)}{row_num_in_sheet}', 'values': [[f'Fehler: {str(e_emp_eval)[:100]}...']]}) + # pass # Faert fort + + + # --- 3d. Umsatz Schaetzung (AG, AH) --- + # TODO: Implementieren Sie die Logik und den Aufruf der Funktion evaluate_umsatz_chatgpt + self.logger.debug(" -> Starte Umsatz Schaetzung (Platzhalter)...") + # Beispielaufruf (angenommen, evaluate_umsatz_chatgpt existiert global): + # try: + # umsatz_estimate_result = evaluate_umsatz_chatgpt( + # company_name, # Nutzt initial geladenen CRM Namen + # {'crm_umsatz': self._get_cell_value_safe(row_data, "CRM Umsatz"), 'wiki_umsatz': final_wiki_data.get('umsatz', 'k.A.'), 'wiki_paragraph': final_wiki_data.get('first_paragraph', 'k.A.'), 'web_summary': website_summary} + # ) + # # Sammle Updates fuer Umsatz Schaetzspalten (AG, AH) + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schaetzung Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[umsatz_estimate_result.get('estimate', 'k.A.')]]}) + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung Abweichung Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[umsatz_estimate_result.get('justification', 'k.A.')]]}) + # except Exception as e_umsatz_eval: + # self.logger.error(f"FEHLER bei Umsatz Schaetzung fuer Zeile {row_num_in_sheet}: {e_umsatz_eval}") + # # Fuege Fehler-Updates hinzu + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schaetzung Umsatz"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER']]}) + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung Abweichung Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[f'Fehler: {str(e_umsatz_eval)[:100]}...']]}) + # pass # Faert fort + + + # --- 3e. Konsolidierung Umsatz/Mitarbeiter (AV, AW) --- + # Diese Logik wurde bisher in prepare_data_for_modeling (Block 31) verwendet, + # kann aber auch hier nach jeder Zeilenverarbeitung durchgefuehrt und + # ins Sheet geschrieben werden, um die konsolidierten Werte aktuell zu halten. + self.logger.debug(" -> Konsolidiere Umsatz (AV) und Mitarbeiter (AW) (Wiki > CRM Logik)...") + try: + # Nutzt globale Funktion get_valid_numeric (Block 5) + # Hole die Werte aus den entsprechenden Spalten (CRM und finale Wiki-Daten) + crm_umsatz_val = self._get_cell_value_safe(row_data, "CRM Umsatz") + wiki_umsatz_val = final_wiki_data.get('umsatz', 'k.A.') # Nutzt finalen Wiki-Wert aus Block 19 + + crm_ma_val = self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter") + wiki_ma_val = final_wiki_data.get('mitarbeiter', 'k.A.') # Nutzt finalen Wiki-Wert aus Block 19 + + # Konvertiere die Werte zu Numerisch (Float/Int oder NaN) mit get_valid_numeric + num_crm_umsatz = get_valid_numeric(crm_umsatz_val) + num_wiki_umsatz = get_valid_numeric(wiki_umsatz_val) + + num_crm_ma = get_valid_numeric(crm_ma_val) + num_wiki_ma = get_valid_numeric(wiki_ma_val) + + # Konsolidierung: Wiki hat Prioritaet vor CRM. Wenn beide NaN sind, Ergebnis NaN. + final_num_umsatz = num_wiki_umsatz if pd.notna(num_wiki_umsatz) else num_crm_umsatz + final_num_ma = num_wiki_ma if pd.notna(num_wiki_ma) else num_crm_ma + + # Konvertiere das finale numerische Ergebnis zurueck zu einem String ("Zahl" oder "k.A.") + # Runden Sie Umsatz auf ganze Millionen und Mitarbeiter auf ganze Zahlen. + final_umsatz_str = str(int(round(final_num_umsatz))) if pd.notna(final_num_umsatz) and final_num_umsatz > 0 else 'k.A.' + final_ma_str = str(int(round(final_num_ma))) if pd.notna(final_num_ma) and final_num_ma > 0 else 'k.A.' + + + # Sammle Updates fuer die Konsolidierungsspalten (AV, AW) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Umsatz (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [[final_umsatz_str]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Mitarbeiter (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [[final_ma_str]]}) + self.logger.debug(f" -> Konsolidiert: Umsatz={final_umsatz_str}, MA={final_ma_str}") + + + except Exception as e_consolidate: + # Fange Fehler bei der Konsolidierung ab und logge sie + self.logger.error(f"FEHLER bei Konsolidierung Umsatz/Mitarbeiter fuer Zeile {row_num_in_sheet}: {e_consolidate}") + # Logge den Traceback + self.logger.debug(traceback.format_exc()) + # Fuege Fehler-Updates hinzu, um den Fehler im Sheet zu dokumentieren + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Umsatz (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER']]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Mitarbeiter (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER']]}) + pass # Faert fort + + + # Setze den Timestamp letzte Pruefung (AO), da die ChatGPT-Evaluationen liefen (auch wenn fehlerhaft) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Timestamp letzte Pruefung"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) + + + # else if run_chat_step: + # Die Chat-Schritte waren angefordert, aber nicht noetig basierend auf Status/Re-Eval/Wiki-Update. + # Die lokalen Variablen final_wiki_data und website_summary behalten ihre initialen Werte (current_...). + # chat_eval_just_ran bleibt False. + # self.logger.debug(f"Zeile {row_num_in_sheet}: Ueberspringe CHATGPT Evaluationen (AO vorhanden, Wiki nicht aktualisiert und kein Re-Eval).") # Zu viel Laerm im Debug + + + # --- Der Code fuer den naechsten Verarbeitungsschritt (ML Prediction) folgt im naechsten Block --- + # Definition der Methode _process_single_row wird in der naechsten Nachricht fortgesetzt. + + # --- 3. ChatGPT Evaluationen (Branch, FSM, Emp, Umsatz Schaetzungen etc.) --- + # Dieser Schritt wird ausgefuehrt, wenn 'chat' in steps_to_run enthalten ist UND + # (_needs_chat_evaluations True ist ODER force_reeval True ist). + # _needs_chat_evaluations prueft AO oder ob Wiki-Daten in diesem Lauf gerade aktualisiert wurden. + # Nutzt interne Helfer: _get_cell_value_safe, _needs_chat_evaluations. + # Nutzt globale Helfer: COLUMN_MAP, logger, datetime, time, + # evaluate_branche_chatgpt (Block 10), + # get_valid_numeric (Block 5). + # (Optional: evaluate_fsm_suitability, evaluate_employee_chatgpt, evaluate_umsatz_chatgpt - muessen implementiert werden). + # Nutzt lokale Variablen: crm_branche, crm_beschreibung, final_wiki_data, website_summary, wiki_data_updated_in_this_run. + + # Pruefen Sie, ob die Chat-Schritte im aktuellen Lauf angefordert wurden + run_chat_step = 'chat' in steps_to_run + # Pruefen Sie, ob die Chat-Schritte laut Status, Re-Eval oder Wiki-Update noetig sind. + # wiki_data_just_updated_in_this_run ist ein Flag aus dem vorherigen Wiki-Schritt (Block 19). + chat_processing_needed_based_on_status = self._needs_chat_evaluations(row_data, force_reeval, wiki_data_updated_in_this_run) + + + # Wenn die Chat-Schritte angefordert wurden UND laut Status/Re-Eval noetig sind + if run_chat_step and chat_processing_needed_based_on_status: + any_processing_done = True # Markiere, dass in dieser Zeile etwas getan wird + + # Setzen Sie das Flag, dass Chat-Evaluationen liefen (koennte ML ausloesen Block 23) + chat_eval_just_ran = True + + # Bestimme den Grund fuer die Ausfuehrung dieses Schritts fuer das Logging + grund_message_parts = [] + if force_reeval: grund_message_parts.append('Re-Eval') + # Pruefe, ob der Timestamp AO leer ist (nutzt interne Helfer) + if not self._get_cell_value_safe(row_data, "Timestamp letzte Pruefung").strip(): grund_message_parts.append('AO leer') + # Pruefe, ob Wiki-Daten gerade aktualisiert wurden (Flag aus Block 19) + if wiki_data_updated_in_this_run: grund_message_parts.append('Wiki Daten gerade aktualisiert') + grund_message = ", ".join(grund_message_parts) + + self.logger.info(f"Zeile {row_num_in_sheet}: Fuehre CHATGPT Evaluationen aus (Grund: {grund_message})...") + + # Hole die notwendigen Daten fuer die ChatGPT-Calls. + # Nutzt die initial geladenen CRM-Daten und die finalen Daten aus den vorherigen Schritten (Wiki, Website). + # crm_branche, crm_beschreibung wurden initial geladen (Block 17). + # final_wiki_data wurde im Wiki-Schritt (Block 19) aktualisiert oder behaelt alte Werte. + # website_summary wurde im Website-Schritt (Block 18) aktualisiert oder behaelt alte Werte. + + # --- 3a. Branchen-Einstufung (W, X, Y) --- + self.logger.debug(" -> Starte Branchen-Einstufung ueber ChatGPT...") + try: + # Annahme: evaluate_branche_chatgpt global definiert (Block 10) und nutzt logging/retry + # evaluate_branche_chatgpt braucht Zugriff auf globale ALLOWED_TARGET_BRANCHES und TARGET_SCHEMA_STRING (Block 6) + # Der Aufruf ist mit retry_on_failure dekoriert und wirft bei endgueltigem Fehler eine Exception. + branch_result = evaluate_branche_chatgpt( # Nutzt globalen Helfer (Block 10) + crm_branche, # Nutzt initial geladenen CRM Wert + crm_beschreibung, # Nutzt initial geladenen CRM Wert + final_wiki_data.get('branche', 'k.A.'), # Nutzt ggf. neue Wiki-Branche aus Block 19 + final_wiki_data.get('categories', 'k.A.'), # Nutzt ggf. neue Wiki-Kategorien aus Block 19 + website_summary # Nutzt ggf. neue Website-Zusammenfassung aus Block 18 + ) + # Sammle Updates fuer die Branchen-Spalten (W, X, Y) (nutzt interne Helfer) + # Stellen Sie sicher, dass die Schluessel im Ergebnis-Dict vorhanden sind, Fallback auf Standard-Fehlerwerte. + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get("branch", "FEHLER")]]}) # Block 1 Column Map + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenz Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get("consistency", "error")]]}) # Block 1 Column Map + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung Abweichung Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get("justification", "Keine Begruendung")]]}) # Block 1 Column Map + + except Exception as e_branch_eval: + # Wenn evaluate_branche_chatgpt eine Exception wirft (nach Retries) + # Der Fehler wird bereits vom retry_on_failure Decorator oder evaluate_branche_chatgpt geloggt. + self.logger.error(f"FEHLER bei Branchen-Einstufung ueber ChatGPT fuer Zeile {row_num_in_sheet}: {e_branch_eval}") + # Logge den Traceback fuer detailliertere Fehlerinformationen + self.logger.debug(traceback.format_exc()) + # Fuegen Sie Updates mit Fehlerwerten hinzu, um den Fehler im Sheet zu dokumentieren. + error_msg = f"Fehler: {str(e_branch_eval)[:100]}..." # Kuerze Fehlermeldung + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER']]}) # Block 1 Column Map + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenz Branche"] + 1)}{row_num_in_sheet}', 'values': [['error']]}) # Block 1 Column Map + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung Abweichung Branche"] + 1)}{row_num_in_sheet}', 'values': [[error_msg]]}) # Block 1 Column Map + pass # Fahren Sie fort mit den naechsten Schritten, auch wenn Branchenevaluation fehlschlug + + + # --- 3b. FSM Relevanz Bewertung (Z, AA) --- + # TODO: Implementieren Sie die Logik und den Aufruf der Funktion evaluate_fsm_suitability + self.logger.debug(" -> Starte FSM Relevanz Bewertung (Platzhalter)...") + # Beispielaufruf (angenommen, evaluate_fsm_suitability existiert global): + # try: + # fsm_result = evaluate_fsm_suitability( + # company_name, # Nutzt initial geladenen CRM Namen + # {'crm_desc': crm_beschreibung, 'wiki_paragraph': final_wiki_data.get('first_paragraph', 'k.A.'), 'web_summary': website_summary} + # ) + # # Sammle Updates fuer FSM Spalten (Z, AA) (nutzt interne Helfer) + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Pruefung FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[fsm_result.get('suitability', 'k.A.')]]}) # Block 1 Column Map + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung fuer FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[fsm_result.get('justification', 'k.A.')]]}) # Block 1 Column Map + # except Exception as e_fsm_eval: + # self.logger.error(f"FEHLER bei FSM Relevanz Bewertung fuer Zeile {row_num_in_sheet}: {e_fsm_eval}") + # # Fuege Fehler-Updates hinzu + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Pruefung FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER']]}) # Block 1 Column Map + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung fuer FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[f'Fehler: {str(e_fsm_eval)[:100]}...']]}) # Block 1 Column Map + # pass # Faert fort + + + # --- 3c. Mitarbeiterzahl Schaetzung (AB, AC, AD) --- + # TODO: Implementieren Sie die Logik und den Aufruf der Funktion evaluate_employee_chatgpt + self.logger.debug(" -> Starte Mitarbeiterzahl Schaetzung (Platzhalter)...") + # Beispielaufruf (angenommen, evaluate_employee_chatgpt existiert global): + # try: + # emp_estimate_result = evaluate_employee_chatgpt( + # company_name, # Nutzt initial geladenen CRM Namen + # {'crm_emp': self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter"), 'wiki_emp': final_wiki_data.get('mitarbeiter', 'k.A.'), 'wiki_paragraph': final_wiki_data.get('first_paragraph', 'k.A.'), 'web_summary': website_summary} + # ) + # # Sammle Updates fuer Mitarbeiter Schaetzspalten (AB, AC, AD) (nutzt interne Helfer) + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schaetzung Anzahl Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[emp_estimate_result.get('estimate', 'k.A.')]]}) # Block 1 Column Map + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenzpruefung Mitarbeiterzahl"] + 1)}{row_num_in_sheet}', 'values': [[emp_estimate_result.get('consistency', 'k.A.')]]}) # Block 1 Column Map + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung Abweichung Mitarbeiterzahl"] + 1)}{row_num_in_sheet}', 'values': [[emp_estimate_result.get('justification', 'k.A.')]]}) # Block 1 Column Map + # except Exception as e_emp_eval: + # self.logger.error(f"FEHLER bei Mitarbeiterzahl Schaetzung fuer Zeile {row_num_in_sheet}: {e_emp_eval}") + # # Fuege Fehler-Updates hinzu + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schaetzung Anzahl Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER']]}) # Block 1 Column Map + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenzpruefung Mitarbeiterzahl"] + 1)}{row_num_in_sheet}', 'values': [['error']]}) # Block 1 Column Map + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung Abweichung Mitarbeiterzahl"] + 1)}{row_num_in_sheet}', 'values': [[f'Fehler: {str(e_emp_eval)[:100]}...']]}) # Block 1 Column Map + # pass # Faert fort + + + # --- 3d. Umsatz Schaetzung (AG, AH) --- + # TODO: Implementieren Sie die Logik und den Aufruf der Funktion evaluate_umsatz_chatgpt + self.logger.debug(" -> Starte Umsatz Schaetzung (Platzhalter)...") + # Beispielaufruf (angenommen, evaluate_umsatz_chatgpt existiert global): + # try: + # umsatz_estimate_result = evaluate_umsatz_chatgpt( + # company_name, # Nutzt initial geladenen CRM Namen + # {'crm_umsatz': self._get_cell_value_safe(row_data, "CRM Umsatz"), 'wiki_umsatz': final_wiki_data.get('umsatz', 'k.A.'), 'wiki_paragraph': final_wiki_data.get('first_paragraph', 'k.A.'), 'web_summary': website_summary} + # ) + # # Sammle Updates fuer Umsatz Schaetzspalten (AG, AH) (nutzt interne Helfer) + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schaetzung Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[umsatz_estimate_result.get('estimate', 'k.A.')]]}) # Block 1 Column Map + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung Abweichung Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[umsatz_estimate_result.get('justification', 'k.A.')]]}) # Block 1 Column Map + # except Exception as e_umsatz_eval: + # self.logger.error(f"FEHLER bei Umsatz Schaetzung fuer Zeile {row_num_in_sheet}: {e_umsatz_eval}") + # # Fuege Fehler-Updates hinzu + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schaetzung Umsatz"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER']]}) # Block 1 Column Map + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung Abweichung Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[f'Fehler: {str(e_umsatz_eval)[:100]}...']]}) # Block 1 Column Map + # pass # Faert fort + + + # --- 3e. Konsolidierung Umsatz/Mitarbeiter (AV, AW) --- + # Diese Logik wurde bisher in prepare_data_for_modeling (Block 31) verwendet, + # kann aber auch hier nach jeder Zeilenverarbeitung durchgefuehrt und + # ins Sheet geschrieben werden, um die konsolidierten Werte aktuell zu halten. + self.logger.debug(" -> Konsolidiere Umsatz (AV) und Mitarbeiter (AW) (Wiki > CRM Logik)...") + try: + # Nutzt globale Funktion get_valid_numeric (Block 5) + # Hole die Werte aus den entsprechenden Spalten (CRM und finale Wiki-Daten) + crm_umsatz_val = self._get_cell_value_safe(row_data, "CRM Umsatz") # Block 1 Column Map + wiki_umsatz_val = final_wiki_data.get('umsatz', 'k.A.') # Nutzt finalen Wiki-Wert aus Block 19 + + crm_ma_val = self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter") # Block 1 Column Map + wiki_ma_val = final_wiki_data.get('mitarbeiter', 'k.A.') # Nutzt finalen Wiki-Wert aus Block 19 + + # Konvertiere die Werte zu Numerisch (Float/Int oder NaN) mit get_valid_numeric + num_crm_umsatz = get_valid_numeric(crm_umsatz_val) + num_wiki_umsatz = get_valid_numeric(wiki_umsatz_val) + + num_crm_ma = get_valid_numeric(crm_ma_val) + num_wiki_ma = get_valid_numeric(wiki_ma_val) + + # Konsolidierung: Wiki hat Prioritaet vor CRM. Wenn beide NaN sind, Ergebnis NaN. + final_num_umsatz = num_wiki_umsatz if pd.notna(num_wiki_umsatz) else num_crm_umsatz + final_num_ma = num_wiki_ma if pd.notna(num_wiki_ma) else num_crm_ma + + # Konvertiere das finale numerische Ergebnis zurueck zu einem String ("Zahl" oder "k.A.") + # Runden Sie Umsatz auf ganze Millionen und Mitarbeiter auf ganze Zahlen. + # Stellen Sie sicher, dass nur positive Werte als Zahl ausgegeben werden, sonst "k.A.". + final_umsatz_str = str(int(round(final_num_umsatz))) if pd.notna(final_num_umsatz) and final_num_umsatz > 0 else 'k.A.' + final_ma_str = str(int(round(final_num_ma))) if pd.notna(final_num_ma) and final_num_ma > 0 else 'k.A.' + + + # Sammle Updates fuer die Konsolidierungsspalten (AV, AW) (nutzt interne Helfer) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Umsatz (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [[final_umsatz_str]]}) # Block 1 Column Map + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Mitarbeiter (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [[final_ma_str]]}) # Block 1 Column Map + self.logger.debug(f" -> Konsolidiert: Umsatz={final_umsatz_str}, MA={final_ma_str}") + + + except Exception as e_consolidate: + # Fange Fehler bei der Konsolidierung ab und logge sie + self.logger.error(f"FEHLER bei Konsolidierung Umsatz/Mitarbeiter fuer Zeile {row_num_in_sheet}: {e_consolidate}") + # Logge den Traceback + self.logger.debug(traceback.format_exc()) + # Fuege Fehler-Updates hinzu, um den Fehler im Sheet zu dokumentieren + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Umsatz (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER']]}) # Block 1 Column Map + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Mitarbeiter (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER']]}) # Block 1 Column Map + pass # Faert fort + + + # Setze den Timestamp letzte Pruefung (AO), da die ChatGPT-Evaluationen liefen (auch wenn fehlerhaft) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Timestamp letzte Pruefung"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # Block 1 Column Map + + + # else if run_chat_step: + # Die Chat-Schritte waren angefordert, aber nicht noetig basierend auf Status/Re-Eval/Wiki-Update. + # Die lokalen Variablen final_wiki_data und website_summary behalten ihre initialen Werte (current_...). + # chat_eval_just_ran bleibt False. + # self.logger.debug(f"Zeile {row_num_in_sheet}: Ueberspringe CHATGPT Evaluationen (AO vorhanden, Wiki nicht aktualisiert und kein Re-Eval).") # Zu viel Laerm im Debug + + + # --- Der Code fuer den naechsten Verarbeitungsschritt (ML Prediction) folgt im naechsten Block --- + # Definition der Methode _process_single_row wird in der naechsten Nachricht fortgesetzt. + + # --- 4. Servicetechniker Schaetzung (ML Modell) (AU) --- + # Dieser Schritt wird ausgefuehrt, wenn 'ml_predict' in steps_to_run enthalten ist UND + # (_needs_ml_prediction True ist ODER force_reeval True ist). + # _needs_ml_prediction (Block 16) prueft AU, AO und Konsolidierte Werte (AV/AW). + # Nutzt interne Helfer: _needs_ml_prediction, _predict_technician_bucket, _get_cell_value_safe. + # Nutzt globale Helfer: COLUMN_MAP, logger. + # Nutzt lokale Variablen: force_reeval, chat_eval_just_ran. + + # Pruefen Sie, ob der ML-Schritt im aktuellen Lauf angefordert wurde + run_ml_step = 'ml_predict' in steps_to_run + # Pruefen Sie, ob der ML-Schritt laut Status, Re-Eval oder Chat-Evaluation (Trigger) noetig ist. + # chat_eval_just_ran ist ein Flag aus dem vorherigen Chat-Schritt (Block 20). + ml_processing_needed_based_on_status = self._needs_ml_prediction(row_data, force_reeval, chat_eval_just_ran) + + + # Wenn der ML-Schritt angefordert wurde UND laut Status/Re-Eval noetig ist + if run_ml_step and ml_processing_needed_based_on_status: + any_processing_done = True # Markiere, dass in dieser Zeile etwas getan wird + + # Bestimme den Grund fuer die Ausfuehrung dieses Schritts fuer das Logging + grund_message_parts = [] + if force_reeval: grund_message_parts.append('Re-Eval') + # Wenn nicht Re-Eval, dann liegt es an _needs_ml_prediction. Logge den Grund von dort auf Debug. + if not force_reeval: + self.logger.debug(" -> ML-Schaetzung noetig (Grund laut _needs_ml_prediction).") + pass # Der spezifische Grund wird bereits in _needs_ml_prediction geloggt (auf Debug). + + self.logger.info(f"Zeile {row_num_in_sheet}: Fuehre ML-Schaetzung aus...") + + # Die ML-Schaetzung benoetigt die vorbereiteten Daten (konsolidierter Umsatz/Mitarbeiter und Branche). + # Diese Werte sind bereits in der Zeile im Sheet verfuegbar (Spalten AV, AW) + # oder wurden gerade in den vorherigen Schritten (Block 20) aktualisiert. + # Die _predict_technician_bucket Methode muss diese Werte aus den row_data holen. + + try: + # Annahme: _predict_technician_bucket Methode existiert in DataProcessor (Block 31). + # Diese Methode muss das geladene Modell/Imputer nutzen (Attribute der Klasse), + # die benoetigten Features aus den row_data extrahieren, vorbereiten und vorhersagen. + # _predict_technician_bucket wirft Exception bei Fehlern. + predicted_bucket = self._predict_technician_bucket(row_data) # Nutzt row_data fuer Feature-Extraktion (Block 17) + + # Wenn die Vorhersage erfolgreich war und ein Bucket-Label zurueckgegeben wurde + if predicted_bucket and isinstance(predicted_bucket, str) and not predicted_bucket.startswith("FEHLER"): + # Sammle Update fuer den AU Bucket (Geschaetzter Techniker Bucket) (nutzt interne Helfer) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Geschaetzter Techniker Bucket"] + 1)}{row_num_in_sheet}', 'values': [[predicted_bucket]]}) # Block 1 Column Map + self.logger.info(f" -> ML-Schaetzung erfolgreich: Bucket '{predicted_bucket}'.") + else: + # Wenn die Vorhersage fehlschlug oder kein Ergebnis lieferte + self.logger.warning(f" -> ML-Schaetzung lieferte kein gueltiges Ergebnis: '{predicted_bucket}'.") + # Setze einen Fehlerwert im Sheet + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Geschaetzter Techniker Bucket"] + 1)}{row_num_in_sheet}', 'values': [['k.A. (Schaetzung fehlgeschlagen)']]}) # Block 1 Column Map + + except Exception as e_ml: + # Wenn _predict_technician_bucket eine Exception wirft + self.logger.error(f"FEHLER bei ML-Schaetzung fuer Zeile {row_num_in_sheet}: {e_ml}") + # Logge den Traceback + self.logger.debug(traceback.format_exc()) + # Fuege Fehler-Update hinzu, um den Fehler im Sheet zu dokumentieren + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Geschaetzter Techniker Bucket"] + 1)}{row_num_in_sheet}', 'values': [[f'FEHLER Schaetzung: {str(e)[:50]}...']]}) # Block 1 Column Map + pass # Faert fort + + + # else if run_ml_step: + # Der ML-Schritt war angefordert, aber nicht noetig basierend auf Status/Re-Eval/Chat-Evaluation. + # self.logger.debug(f"Zeile {row_num_in_sheet}: Ueberspringe ML-Schaetzung (AU gesetzt oder Daten/Trigger fehlen).") # Zu viel Laerm im Debug + + + # ====================================================================== + # === Abschluss der _process_single_row Verarbeitung =================== + # ====================================================================== + + # --- 5. Abschliessende Updates (Version, Tokens) --- + + # Version (AP) wird gesetzt, wenn IRGENDEINE Verarbeitung in dieser Zeile stattgefunden hat. + if any_processing_done: + # Nutzt Config.VERSION (Block 1) und interne Helfer. + version_col_idx = COLUMN_MAP.get("Version") # Block 1 Column Map + if version_col_idx is not None: + # Fuege das Update fuer die Version zur Liste hinzu + updates.append({'range': f'{self.sheet_handler._get_col_letter(version_col_idx + 1)}{row_num_in_sheet}', 'values': [[getattr(Config, 'VERSION', 'unknown')]]}) # Block 1 Column Map + else: + self.logger.error("FEHLER: Spaltenschluessel 'Version' nicht in COLUMN_MAP gefunden.") + + # Tokens (AQ) - Hier ist die Zaehlung komplex, da mehrere OpenAI-Calls passiert sein koennten. + # Eine einfache Loesung ist, die Token-Zahl der letzten relevanten Antwort zu speichern + # oder die Token-Zahl der Prompts/Antworten waehrend des Laufs zu aggregieren. + # Eine Aggregation in den einzelnen Schritten (Web Summary, Branch Eval etc.) waere genauer. + # Wenn der Token-Count in den einzelnen OpenAI-Call-Methoden implementiert wird, + # muss er dort gesammelt und dann HIER in _process_single_row ins Update eingefuegt werden. + # Beispiel: Sie koennten ein Attribut self.current_row_token_count am Anfang von _process_single_row auf 0 setzen, + # und in jeder Methode (call_openai_chat, summarize_batch_openai), die Token nutzt, diesen Zaehler erhoehen. + # Dann hier: + # tokens_col_idx = COLUMN_MAP.get("Tokens") # Block 1 Column Map + # if tokens_col_idx is not None and hasattr(self, 'current_row_token_count') and self.current_row_token_count > 0: + # updates.append({'range': f'{self.sheet_handler._get_col_letter(tokens_col_idx + 1)}{row_num_in_sheet}', 'values': [[str(self.current_row_token_count)]]}) + # else: # logger.debug("Keine Tokens zu speichern fuer diese Zeile."); # Zu viel Laerm im Debug + # if tokens_col_idx is None: self.logger.error("FEHLER: Spaltenschluessel 'Tokens' nicht in COLUMN_MAP gefunden."); # Block 1 Column Map + pass # Token-Zaehlung Implementierung erfordert Aenderungen in OpenAI Helpers und _process_single_row Init. + + + # --- 5b. ReEval Flag (A) loeschen (nur wenn im Re-Eval Modus und gewuenscht) --- + # Dieses Update wird am Ende der _process_single_row Methode hinzugefuegt, + # wenn der Aufruf aus process_reevaluation_rows (Block 25) mit clear_x_flag=True kam. + if force_reeval and clear_x_flag: + # Ermitteln Sie den Index der ReEval Flag Spalte + reeval_col_idx = COLUMN_MAP.get("ReEval Flag") # Block 1 Column Map + if reeval_col_idx is not None: + # Ermitteln Sie den Spaltenbuchstaben + flag_col_letter = self.sheet_handler._get_col_letter(reeval_col_idx + 1) + if flag_col_letter: + # Fuegen Sie das Update zum Loeschen des 'x'-Flags zur Liste hinzu. + # Es wird nur geloescht, wenn die Zeile ansonsten erfolgreich bis hierhin kam und Updates gesammelt wurden. + # Wenn eine schwere Exception in _process_single_row auftrat, wird dieser Block nicht erreicht. + 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: + # Logge Fehler, wenn Spaltenbuchstaben nicht ermittelt werden konnten + self.logger.error(f"FEHLER: Konnte Spaltenbuchstaben fuer 'ReEval Flag' ({reeval_col_idx+1}) nicht ermitteln. Flag kann nicht geloescht werden.") + else: + # Logge Fehler, wenn Spaltenindex fehlt + self.logger.error("FEHLER: 'ReEval Flag' Spaltenindex nicht in COLUMN_MAP gefunden. Flag kann nicht geloescht werden.") + + + # --- 6. Batch Update fuer diese Zeile --- + # Fuehren Sie das Batch-Update fuer ALLE gesammelten Aenderungen dieser EINEN Zeile durch. + if updates: + # Info-Log ueber die Anzahl der Updates fuer diese spezifische Zeile + self.logger.info(f"Zeile {row_num_in_sheet}: Sende Batch-Update mit {len(updates)} Operationen fuer diese Zeile...") + # Rufe die batch_update_cells Methode des Sheet Handlers auf. + # batch_update_cells ist mit retry_on_failure dekoriert und loggt intern. + success = self.sheet_handler.batch_update_cells(updates) # Nutzt die uebergeordnete Instanz + + # Wenn der Batch-Update fehlschlaegt (nach Retries) + if not success: + # Logge einen Error + self.logger.error(f"Zeile {row_num_in_sheet}: ENDGUELTIGER FEHLER beim Batch-Update nach Retries.") + # Hier koennen Sie z.B. einen Fehlerindikator in eine spezielle Spalte schreiben + # (Dieses Update muesste separat oder im naechsten Lauf behandelt werden) + + else: + # Info-Log, wenn nichts zu tun war in dieser Zeile + if not any_processing_done: + self.logger.debug(f"Zeile {row_num_in_sheet}: Keine Updates zum Schreiben (alle relevanten Schritte uebersprungen oder nicht angefordert).") + # else: + # Dieser Fall sollte nicht eintreten, wenn updates nicht leer ist, aber any_processing_done True ist. + # self.logger.warning(f"Zeile {row_num_in_sheet}: Updates Liste war leer, aber any_processing_done=True. Pruefen Sie die Logik.") + + + # Kleine Pause nach der Verarbeitung jeder Zeile, um API-Limits zu respektieren + # und die Belastung fuer das Google Sheet zu reduzieren. + # Der Wert sollte in Config (Block 1) angepasst werden. Eine kurze Pause ist auch bei Batch-Modi sinnvoll, + # wenn _process_single_row von dort aufgerufen wird (z.B. fuer Re-Eval). + # Nutzt Config.RETRY_DELAY (Block 1), ggf. kuerzer. + pause_duration = max(0.05, getattr(Config, 'RETRY_DELAY', 5) / 20.0) # Mindestens 50ms Wartezeit + # self.logger.debug(f"Wartezeit nach Zeile {row_num_in_sheet}: {pause_duration:.2f}s") # Zu viel Laerm im Debug + time.sleep(pause_duration) + + # Logge den Abschluss der Verarbeitung fuer diese Zeile + self.logger.info(f"--- Verarbeitung fuer Zeile {row_num_in_sheet} abgeschlossen ---") + + # --- Ende der _process_single_row Methode --- + + + # Die naechste Methode der DataProcessor Klasse folgt im naechsten Block. + # Dies ist die process_rows_sequentially Methode (Block 24). + + # ========================================================================== + # === Prozess Methoden (Sequentiell) ======================================= + # ========================================================================== + + # --- Methode fuer sequentielle Verarbeitung (Modus full_run) --- + # Diese Methode verarbeitet Zeilen einzeln, nacheinander, im Gegensatz zu Batch-Modi. + # Sie ruft _process_single_row fuer jede Zeile im definierten Bereich auf. + # Nutzt interne Helfer: _process_single_row, _get_cell_value_safe. + # Nutzt globale Helfer: COLUMN_MAP, logger. + # Nutzt die uebergeordnete sheet_handler Instanz. + 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, # Neues Flag fuer ML-Schritt + # Fuegen Sie hier ggf. weitere boolsche Flags fuer andere Schrittgruppen hinzu + force_reeval_in_single_row=False): # Optionale Steuerung fuer _process_single_row + """ + Verarbeitet eine feste Anzahl von Zeilen beginnend bei einer bestimmten + Sheet-Zeilennummer sequentiell, eine nach der anderen, unter Verwendung + von _process_single_row. + + Args: + start_sheet_row (int): Die 1-basierte Zeilennummer im Sheet, ab der gestartet werden soll. + num_to_process (int): Die maximale Anzahl der zu verarbeitenden Zeilen. + process_wiki_steps (bool, optional): Soll der Wiki-Schritt in _process_single_row ausgefuehrt werden?. Defaults to True. + process_chatgpt_steps (bool, optional): Sollen ChatGPT-Schritte in _process_single_row ausgefuehrt werden?. Defaults to True. + process_website_steps (bool, optional): Soll der Website-Schritt in _process_single_row ausgefuehrt werden?. Defaults to True. + process_ml_steps (bool, optional): Soll der ML-Schritt in _process_single_row ausgefuehrt werden?. Defaults to True. # Neues Flag + # Fuegen Sie hier ggf. weitere boolsche Flags fuer andere Schrittgruppen hinzu. + force_reeval_in_single_row (bool, optional): Wenn True, wird force_reeval=True in _process_single_row + fuer alle verarbeiteten Zeilen in diesem Lauf gesetzt. Defaults to False. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar. + header_rows = self.sheet_handler._header_rows + + + # Pruefen Sie, ob num_to_process gueltig ist + 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 + + # Logge die Konfiguration des sequentiellen Laufs + self.logger.info(f"Starte sequentielle Verarbeitung von {num_to_process} Zeilen ab Sheet-Zeile {start_sheet_row}...") + # Logge die ausgewaehlten Schritte fuer diesen Lauf + 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)") # Neues Flag + # Fuegen Sie hier weitere Schritte hinzu, wenn neue Flags existieren + self.logger.info(f" Ausgewaehlte Schritte fuer sequentiellen Lauf: {', '.join(selected_steps_log) if selected_steps_log else 'Keine ausgewählt!'}") + + # Logge, ob force_reeval in _process_single_row gesetzt wird + if force_reeval_in_single_row: + self.logger.warning(" !!! force_reeval=True wird fuer alle Zeilen in _process_single_row gesetzt !!!") + + # Erstelle das Set der Schluessel fuer die Schritte, die an _process_single_row uebergeben werden + 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') # Annahme: 'chat' triggert alle ChatGPT Schritte in _process_single_row (Block 20) + if process_website_steps: steps_to_run_set.add('web') + if process_ml_steps: steps_to_run_set.add('ml_predict') # Neues Flag + # Fuegen Sie hier weitere Schluessel hinzu, wenn neue Flags verwendet werden + + + # Wenn keine Schritte ausgewaehlt wurden (trotz gueltigem num_to_process) + if not steps_to_run_set: + self.logger.warning("Keine Verarbeitungsschritte fuer sequentiellen Lauf ausgewaehlt (steps_to_run_set ist leer). Modus wird uebersprungen.") + return + + + # Lade Daten einmalig vor der Verarbeitung (nutzt die uebergeordnete Instanz) + # Der load_data Aufruf ist mit retry_on_failure dekoriert. + if not self.sheet_handler.load_data(): + self.logger.error("Fehler beim Laden der Daten fuer sequentielle Verarbeitung.") + return # Beende die Methode, wenn das Laden fehlschlaegt + + + # Holen Sie die gesamte Datenliste (inklusive Header) + all_data = self.sheet_handler.get_all_data_with_headers() + total_sheet_rows = len(all_data) + + + # Berechnen Sie den tatsaechlichen Start-Index in der all_data Liste (0-basiert) + start_index_in_all_data = start_sheet_row - 1 + + # Pruefen Sie, ob der angegebene Startindex gueltig ist + if start_index_in_all_data >= total_sheet_rows: + self.logger.warning(f"Start-Sheet-Zeile {start_sheet_row} (Index {start_index_in_all_data}) liegt ausserhalb der verfuegbaren Daten ({total_sheet_rows} Zeilen insgesamt). Keine Verarbeitung.") + return # Beende die Methode, wenn der Startindex ungueltig ist + if start_index_in_all_data < header_rows: + # Wenn der Startindex innerhalb der Header liegt, beginnen Sie nach den Headern. + self.logger.warning(f"Start-Sheet-Zeile {start_sheet_row} liegt innerhalb der Header-Zeilen ({header_rows} Header). Verarbeitung startet ab Sheet-Zeile {header_rows + 1}.") + start_index_in_all_data = header_rows # Beginnen Sie direkt nach den Headern + + + # Berechne den tatsaechlichen End-Index in der all_data Liste (exklusiv) + # Der Endindex ist der Startindex + die Anzahl der zu verarbeitenden Zeilen. + # Stellen Sie sicher, dass der Endindex die Gesamtanzahl der Zeilen nicht ueberschreitet. + end_index_in_all_data = min(start_index_in_all_data + num_to_process, total_sheet_rows) + + + # Logge den Bereich der tatsaechlich zu verarbeitenden Zeilen + self.logger.info(f"Sequentielle Verarbeitung: Verarbeitungsbereich (0-basiert Index) [{start_index_in_all_data}, {end_index_in_all_data}). Entsprechende Sheet-Zeilen (1-basiert): {start_index_in_all_data + 1} bis {end_index_in_all_data}.") + + # Pruefen Sie, ob es ueberhaupt Zeilen im berechneten Bereich gibt + if start_index_in_all_data >= end_index_in_all_data: + self.logger.info(f"Berechneter Startindex ({start_index_in_all_data}) liegt bei oder nach dem berechneten Endindex ({end_index_in_all_data}). Keine Zeilen im definierten Bereich zu verarbeiten.") + return # Beende die Methode, wenn der Bereich leer ist + + + processed_count = 0 # Zaehlt Zeilen, fuer die _process_single_row aufgerufen wurde. + # Iteriere ueber die Zeilen im definierten Bereich (0-basierter Index in all_data) + for i in range(start_index_in_all_data, end_index_in_all_data): + row_num_in_sheet = i + 1 # 1-basierte Zeilennummer im Sheet fuer _process_single_row + row_data = all_data[i] # Tatsaechliche Zeilendaten aus der Gesamtliste (0-basierter Index) + + # Ueberspringen Sie Header-Zeilen explizit, falls der Startindex faelschlicherweise <= header_rows war + if row_num_in_sheet <= header_rows: + self.logger.debug(f"Ueberspringe Header-Zeile {row_num_in_sheet}.") + continue # Springe zur naechsten Iteration + + + # Stellen Sie sicher, dass die Zeile nicht leer ist oder nur aus leeren Strings besteht + # Nutzt die interne Helferfunktion _get_cell_value_safe implizit durch Iteration oder prueft direkt + # Eine einfache Pruefung: Ist irgendeine Zelle in der Zeile nicht leer oder None? + if not any(cell and isinstance(cell, str) and cell.strip() for cell in row_data): + self.logger.debug(f"Ueberspringe scheinbar leere Zeile {row_num_in_sheet}.") + continue # Springe zur naechsten Iteration + + + try: + # Rufe die Methode zur Verarbeitung einer einzelnen Zeile auf. + # _process_single_row wird intern die Timestamps pruefen (ausser wenn force_reeval=True). + # Uebergeben Sie die ausgewaehlten Schritte und das force_reeval Flag. + self._process_single_row( + row_num_in_sheet = row_num_in_sheet, + row_data = row_data, # Uebergibt die aktuellen Rohdaten der Zeile + steps_to_run = steps_to_run_set, # <-- Uebergibt die aus CLI/Menue ausgewaehlten Schritte + force_reeval = force_reeval_in_single_row, # <-- Steuert force_reeval in _process_single_row + clear_x_flag = False # Im sequentiellen Lauf wird das 'x'-Flag normalerweise NICHT geloescht + # (Dies wird nur im 'reeval' Modus (Block 25) benoetigt) + ) + + # Zaehlen, wenn _process_single_row erfolgreich aufgerufen wurde (unabhaengig von internen Ueberspringungen in _process_single_row). + processed_count += 1 + + 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 sequentieller Verarbeitung von Zeile {row_num_in_sheet}: {e_proc}") + # Hier koennen Sie z.B. einen Fehlerindikator in eine spezielle Spalte im Sheet schreiben lassen. + # Dieses Update muesste dann separat oder im naechsten Lauf behandelt werden. + + # _process_single_row beinhaltet bereits eine kleine Pause am Ende. + # Hier ist keine zusaetzliche Pause noetig, wenn _process_single_row erfolgreich war. + # Wenn _process_single_row eine Exception wirft, kann hier eine kurze Pause sinnvoll sein. + # time.sleep(0.1) # Optional: Kurze Pause bei Fehler + + # Logge den Abschluss der sequentiellen Verarbeitung + self.logger.info(f"Sequentielle Verarbeitung abgeschlossen. {processed_count} Zeilen im Bereich [{start_sheet_row}, {end_index_in_all_data}] bearbeitet.") + + +# ============================================================================== +# Ende DataProcessor Klasse Prozess: Sequenziell Block +# ============================================================================== + + # ========================================================================== + # === Prozess Methoden (Re-Evaluation) ===================================== + # ========================================================================== + + # --- Methode fuer den Re-Eval Modus (Spalte A = 'x') --- + # Diese Methode verarbeitet nur Zeilen, die in Spalte A mit 'x' markiert sind. + # Sie ruft _process_single_row fuer jede dieser Zeilen auf mit force_reeval=True + # und uebergibt die Auswahl der Schritte und das Flag zum Loeschen des 'x'-Flags. + # Nutzt interne Helfer: _process_single_row, _get_cell_value_safe. + # Nutzt globale Helfer: COLUMN_MAP, logger. + # Nutzt die uebergeordnete sheet_handler Instanz. + 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 # Neues Flag fuer ML-Schritt + # Fuegen Sie hier ggf. weitere boolsche Flags fuer andere Schrittgruppen hinzu + ): + """ + Verarbeitet nur Zeilen, die in Spalte A mit 'x' markiert sind. + Ruft _process_single_row fuer jede dieser Zeilen auf mit force_reeval=True. + Verarbeitet maximal row_limit Zeilen. + Loescht optional das 'x'-Flag nach erfolgreicher Verarbeitung. + Erlaubt die Auswahl spezifischer Verarbeitungsschritte. + + Args: + row_limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None (Unbegrenzt). + clear_flag (bool, optional): Wenn True, wird das Flag 'x' in Spalte A + nach erfolgreicher Verarbeitung durch _process_single_row geloescht. + Defaults to True. + process_wiki_steps (bool, optional): Soll der Wiki-Schritt in _process_single_row ausgefuehrt werden?. Defaults to True. + process_chatgpt_steps (bool, optional): Sollen ChatGPT-Schritte in _process_single_row ausgefuehrt werden?. Defaults to True. + process_website_steps (bool, optional): Soll der Website-Schritt in _process_single_row ausgefuehrt werden?. Defaults to True. + process_ml_steps (bool, optional): Soll der ML-Schritt in _process_single_row ausgefuehrt werden?. Defaults to True. # Neues Flag + # Fuegen Sie hier ggf. weitere boolsche Flags fuer andere Schrittgruppen hinzu. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Logge die Konfiguration des Re-Eval Laufs + self.logger.info(f"Starte Re-Evaluierungsmodus (Spalte A = 'x'). Max. Zeilen: {row_limit if row_limit is not None else 'Unbegrenzt'}") + # Logge die ausgewaehlten Schritte fuer diesen Lauf + 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)") # Neues Flag + # Fuegen Sie hier weitere Schritte hinzu, wenn neue Flags existieren + self.logger.info(f"Ausgewaehlte Schritte fuer Re-Eval: {', '.join(selected_steps_log) if selected_steps_log else 'Keine ausgewählt!'}") + + # Erstelle das Set der Schluessel fuer die Schritte, die an _process_single_row uebergeben werden + 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') # Annahme: 'chat' triggert alle ChatGPT Schritte in _process_single_row (Block 20) + if process_website_steps: steps_to_run_set.add('web') + if process_ml_steps: steps_to_run_set.add('ml_predict') # Neues Flag + # Fuegen Sie hier weitere Schluessel hinzu, wenn neue Flags verwendet werden + + + # Wenn keine Schritte ausgewaehlt wurden + if not steps_to_run_set: + self.logger.warning("Keine Verarbeitungsschritte fuer Re-Eval ausgewaehlt (steps_to_run_set ist leer). Modus wird uebersprungen.") + return + + + # Daten neu laden vor der Verarbeitung (nutzt die uebergeordnete Instanz) + # Der load_data Aufruf ist mit retry_on_failure dekoriert. + if not self.sheet_handler.load_data(): + self.logger.error("Fehler beim Laden der Daten fuer Re-Evaluation.") + return # Beende die Methode, wenn das Laden fehlschlaegt + + + # Holen Sie die gesamte Datenliste (inklusive Header) + all_data = self.sheet_handler.get_all_data_with_headers() + # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar. + header_rows = self.sheet_handler._header_rows + # Wenn keine Daten da sind oder nur Header + if not all_data or len(all_data) <= header_rows: + self.logger.warning("Keine Datenzeilen fuer Re-Evaluation gefunden.") + return # Beende die Methode + + + # Ermitteln Sie den Index der ReEval Flag Spalte aus COLUMN_MAP (Block 1) + reeval_col_idx = COLUMN_MAP.get("ReEval Flag") + if reeval_col_idx is None: + self.logger.critical("FEHLER: 'ReEval Flag' Spaltenindex nicht in COLUMN_MAP gefunden. Kann Zeilen mit 'x' nicht finden. Breche ab.") + return # Beende die Methode bei kritischem Fehler + + + # Sammeln Sie die Zeilen, die in Spalte A mit 'x' markiert sind. + rows_to_process = [] # Liste von Dictionaries {'row_num': ..., 'data': ...} + # Iteriere ueber die Datenzeilen (ab der ersten Datenzeile) + for idx_in_list in range(header_rows, len(all_data)): + row_data = all_data[idx_in_list] # Die Rohdaten fuer diese Zeile (0-basierter Index in all_data) + row_num_in_sheet = idx_in_list + 1 # 1-basierte Zeilennummer im Sheet + + # Pruefen Sie sicher auf den Wert 'x' in Spalte A (nutzt interne Helfer) + cell_a_value = self._get_cell_value_safe(row_data, "ReEval Flag").strip().lower() + + # Wenn die Zelle in Spalte A "x" ist + if cell_a_value == "x": + # Fuegen Sie die Zeilendaten zur Liste der zu verarbeitenden Zeilen hinzu + rows_to_process.append({'row_num': row_num_in_sheet, 'data': row_data}) + + + found_count = len(rows_to_process) # Anzahl der gefundenen markierten Zeilen + self.logger.info(f"{found_count} Zeilen mit ReEval-Flag 'x' gefunden.") + # Wenn keine Zeilen zum Verarbeiten markiert sind + if found_count == 0: + self.logger.info("Keine Zeilen zur Re-Evaluation markiert.") + return # Beende die Methode + + + # Verarbeitung der markierten Zeilen + processed_count = 0 # Zaehlt Zeilen, fuer die _process_single_row aufgerufen wurde (im Rahmen des Limits). + # updates_clear_flag Liste wird NICHT mehr hier gefuellt, da _process_single_row das Update selbst hinzufuegt. + # rows_actually_processed = [] # Diese Liste wird nicht mehr benoetigt, da _process_single_row das Update selbst sendet. + + # Iteriere ueber die gefundenen markierten Zeilen + for task in rows_to_process: + # Ueberpruefen Sie das Limit fuer die zu verarbeitenden Zeilen VOR der Verarbeitung + if row_limit is not None and isinstance(row_limit, int) and row_limit > 0 and processed_count >= row_limit: + # Wenn das Limit erreicht ist und es ein positives Limit gibt + self.logger.info(f"Zeilenlimit ({row_limit}) fuer Re-Evaluation erreicht. Breche weitere Verarbeitung ab.") + break # Brich die Schleife ab + + + row_num = task['row_num'] # 1-basierte Zeilennummer + row_data = task['data'] # Die Rohdaten fuer diese Zeile + + self.logger.info(f"Bearbeite Re-Eval Zeile {row_num}...") + try: + # Rufe die Methode zur Verarbeitung einer einzelnen Zeile auf. + # In diesem Modus setzen wir immer force_reeval=True. + # Wir uebergeben die aus CLI/Menue ausgewaehlten Schritte in steps_to_run_set. + # Wir uebergeben das clear_flag, damit _process_single_row weiss, ob das 'x' geloescht werden soll. + # _process_single_row (Block 19) loggt intern, fuehrt die Schritte durch, sammelt Updates + # (inkl. 'x'-Flag Update wenn clear_x_flag=True) und sendet das Batch-Update fuer diese Zeile. + self._process_single_row( + row_num_in_sheet = row_num, + row_data = row_data, + steps_to_run = steps_to_run_set, # <-- Uebergibt die aus CLI/Menue ausgewaehlten Schritte + force_reeval = True, # <-- Erzwingt Re-Evaluation unabhaengig von Timestamps fuer die ausgewaehlten Schritte + clear_x_flag = clear_flag # <-- Uebergibt, ob das 'x'-Flag von _process_single_row geloescht werden soll + ) + + # Zaehlen, wenn _process_single_row erfolgreich aufgerufen wurde (unabhaengig von internen Ueberspringungen in _process_single_row). + processed_count += 1 + # Die Liste rows_actually_processed wird nicht mehr benoetigt. + + 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. + # Das 'x'-Flag wird in diesem Fall NICHT geloescht, da _process_single_row nicht bis zum Ende kam. + self.logger.exception(f"FEHLER bei Re-Evaluation von Zeile {row_num}: {e_proc}") + # Hier koennen Sie z.B. einen Fehlerindikator in eine spezielle Spalte im Sheet schreiben lassen. + # Dieses Update muesste dann separat oder im naechsten Lauf behandelt werden. + + # _process_single_row beinhaltet bereits eine kleine Pause am Ende. + # Hier ist keine zusaetzliche Pause noetig nach der Zeilenverarbeitung. + # 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 + + # Der Codeblock zum Loeschen der gesammelten Updates (updates_clear_flag) am Ende wurde entfernt. + + # Logge den Abschluss des Re-Eval Modus + self.logger.info(f"Re-Evaluierung abgeschlossen. {processed_count} Zeilen verarbeitet (Gefunden: {found_count}, Limit: {row_limit}).") + + +# ============================================================================== +# Ende DataProcessor Klasse Prozess: Re-Evaluation Block +# ============================================================================== + + # ========================================================================== + # === Batch Processing Methods ============================================= + # ========================================================================== + + # --- Interne Hilfsfunktion fuer Wiki-Verifizierungs-Batch (OpenAI Call) --- + # Diese Funktion verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen ueber OpenAI. + # Sie wird von process_verification_batch aufgerufen. + # Nutzt globale Helfer: call_openai_chat, logger, token_count (optional), retry_on_failure, re. + @retry_on_failure # Wende den Decorator auf den gesamten Batch-API Call an + def _process_verification_openai_batch(self, batch_data): + """ + 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']})...") + + # --- Prompt Erstellung --- + 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 <Zeilennummer>: <Antwort>\n\n" + "Moegliche Antworten:\n" + "- 'OK' (wenn der Artikel gut passt)\n" + "- 'X | Alternativer Artikel: <URL> | Begruendung: <Kurze Begruendung>' (wenn der Artikel nicht passt, aber ein besserer gefunden wurde)\n" + "- 'X | Kein passender Artikel gefunden | Begruendung: <Kurze 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 + for item in batch_data: + row_num = item['row_num'] + # Kuerze die Beschreibungen und Kategorien, um das Prompt-Limit zu reduzieren. + # Stelle sicher, dass die Werte Strings sind. + crm_desc_short = str(item.get('crm_desc', 'k.A.' or 'k.A.'))[:200] + '...' if len(str(item.get('crm_desc', ''))) > 200 else str(item.get('crm_desc', 'k.A.')) + wiki_paragraph_short = str(item.get('wiki_paragraph', 'k.A.' or 'k.A.'))[:200] + '...' if len(str(item.get('wiki_paragraph', ''))) > 200 else str(item.get('wiki_paragraph', 'k.A.')) + wiki_categories_short = str(item.get('wiki_categories', 'k.A.' or 'k.A.'))[:200] + '...' if len(str(item.get('wiki_categories', ''))) > 200 else str(item.get('wiki_categories', 'k.A.')) + + + entry_text = ( + f"Eintrag {row_num}:\n" + f" Firmenname: {str(item.get('company_name', 'k.A.'))}\n" + f" CRM-Beschreibung: {crm_desc_short}\n" + f" Wikipedia-URL: {str(item.get('wiki_url', 'k.A.' or 'k.A.'))}\n" + f" Wiki-Absatz: {wiki_paragraph_short}\n" + f" Wiki-Kategorien: {wiki_categories_short}\n" + f"----\n" + ) + aggregated_prompt += entry_text + + 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')); logger.debug(f"Geschaetzt Prompt-Tokens fuer Batch: {prompt_tokens}."); + # except Exception as e_tc: 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 summarize_batch_openai Funktion (Block 9) 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. + 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. + logger.error("call_openai_chat gab unerwarteterweise None zurueck fuer Wiki-Verifizierungs-Batch.") + # 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. + logger.error(f"Endgueltiger FEHLER beim OpenAI-Batch-Aufruf fuer Wiki-Verifizierung (innerhalb Batch Decorator): {e}") + # Logge den Traceback + self.logger.debug(traceback.format_exc()) + # 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 + lines = chat_response.strip().split('\n') + parsed_count = 0 + for line in lines: + # Matcht "Eintrag <Zeilennummer>:" 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 enthalten war + if any(item['row_num'] == row_num for item in batch_data): + answers[row_num] = answer_text + parsed_count += 1 + # else: 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(batch_data)} Zeilen erfolgreich zugeordnet.") + + # Fuegen Sie einen Fehlerwert fuer Zeilen hinzu, die nicht geparst werden konnten (z.B. falsches Antwortformat) + if parsed_count < len(batch_data): + logger.warning(f"Nicht alle Zeilen aus dem Batch ({len(batch_data)}) konnten in der OpenAI-Antwort ({len(lines)} Zeilen) geparst werden.") + # Logge den Anfang der unvollstaendigen Antwort auf Debug + logger.debug(f"Unerwartete Antwortteile (erste 500 Zeichen): {chat_response[:500]}") + for item in batch_data: + if item['row_num'] not in answers: + answers[item['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. + # Nutzt globale Helfer: COLUMN_MAP, logger, Config, datetime, time. + # Nutzt die uebergeordnete sheet_handler Instanz. + 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}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") + + # --- Daten laden --- + # 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...") + # Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AT. + # Standardmaessig ab Zeile 7 + start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Wiki Verif. Timestamp") # Block 1 Column Map + + # 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.") + return # Beende die Methode + + # Berechne die 1-basierte Sheet-Startzeile + 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}") + 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. + if not self.sheet_handler.load_data(): + self.logger.error("FEHLER beim Laden der Daten fuer process_verification_batch.") + return # Beende die Methode, wenn das Laden fehlschlaegt + + + # Holen Sie die gesamte Datenliste (inklusive Header) + all_data = self.sheet_handler.get_all_data_with_headers() + # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar. + 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}") + + # Pruefe, ob der Bereich gueltig ist + if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows: + self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") + return # Beende die Methode, wenn der Bereich leer ist + + + # --- Indizes und Buchstaben --- + # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind + required_keys = [ + "Wiki Verif. Timestamp", "Wiki URL", "Chat Wiki Konsistenzpruefung", # Pruefkriterien / Timestamp (AX, M, S) + "CRM Name", "CRM Beschreibung", "Wiki Absatz", "Wiki Kategorien", # Daten fuer Prompt (B, F, N, R) + "Chat Begruendung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", # Ergebnisspalten (T, U) + "Begruendung bei Abweichung", "Wikipedia Timestamp", "Timestamp letzte Pruefung", # Spalten zum Leeren (V, AN, AO) + "Version", "Wiki Verif. Timestamp", "SerpAPI Wiki Search Timestamp" # Weitere Spalten zum Leeren (AP, AX - aber AX wird gesetzt!, AY) + ] + # 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.") + return # Beende die Methode bei kritischem Fehler + + + # Ermitteln Sie die Spaltenbuchstaben fuer Updates und Leerung (nutzt interne Helfer) + 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 Begruendung 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"] + y_idx = col_indices["Chat Begruendung Abweichung Branche"] # Block 1 Column Map + # 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 + empty_vy_values = [''] * (y_idx - v_idx + 1) # Anzahl der Spalten = Y_Index - V_Index + 1 + + + # Timestamps AN, AO, AY und Version AP leeren. + # Diese werden von anderen Schritten gesetzt und sollen hier zurueckgesetzt werden. + an_letter = self.sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"] + 1) # AN (Wiki Extraction TS) + ao_letter = self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Pruefung"] + 1) # AO (Chat Evaluation TS) + ap_letter = self.sheet_handler._get_col_letter(col_indices["Version"] + 1) # AP (Version) + 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) + openai_batch_size = getattr(Config, 'PROCESSING_BATCH_SIZE', 20) # Nutzt dieselbe Batch-Groesse wie Scraping/Summarization + # Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1) + update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) + + + current_openai_batch_data = [] # Daten fuer den aktuellen OpenAI Batch (Liste von Dicts) + rows_in_current_openai_batch = [] # 1-basierte Zeilennummern im aktuellen OpenAI Batch + all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts) + + + processed_count = 0 # Zaehlt Zeilen, die fuer die Verarbeitung in Frage kommen und in den Batch aufgenommen werden (im Rahmen des Limits). + skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (wegen Status, fehlender Daten etc.). + skipped_no_wiki_url = 0 # Zaehlt Zeilen, die speziell wegen fehlender M-URL uebersprungen wurden. + + + # 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).") + 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 + + + # 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 + + # 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}") # Gekuerzt loggen + + + # 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 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 --- + processed_count += 1 # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im Rahmen des Limits zaehlen) + + # Pruefe das Limit fuer verarbeitete Zeilen + if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit: + # Wenn das Limit erreicht ist und es ein positives Limit gibt + self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_verification_batch erreicht. Breche weitere Zeilenpruefung ab.") + 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}) ---") + + + # 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) + wiki_confirm, alt_article, wiki_explanation = "", "", "" # Initialisiere mit leeren Strings + + # 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 | <Detail> | <Begruendung>" + parts = answer.split("|", 2) # Teile maximal in 3 Teile + wiki_confirm = "X" # Status ist X + if len(parts) > 1: + detail = parts[1].strip() # Zweiter Teil ist Detail (Alternative URL oder "Kein passender Artikel gefunden") + 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: + reason_part = parts[2].strip() # Dritter Teil ist Begruendung + 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 + + else: # Unerwartetes Format der Antwort (weder OK noch X | noch FEHLER) + wiki_confirm = "?" # Setze Status auf unbekannt + wiki_explanation = f"Unerwartetes Format: {str(answer)[:100]}..." # Speichere Anfang der Antwort in Begruendung (gekuerzt) + 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...") + # 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.") + # 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 ---") + 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}) ---") + + # 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)...") + # 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.") + # 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).") + # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt. + + +# ============================================================================== +# Ende DataProcessor Klasse Batch: Wiki Verification Block +# ============================================================================== + + # ========================================================================== + # === Prozess Methoden (Re-Evaluation) ===================================== + # ========================================================================== + + # --- Methode fuer den Re-Eval Modus (Spalte A = 'x') --- + # Diese Methode verarbeitet nur Zeilen, die in Spalte A mit 'x' markiert sind. + # Sie ruft _process_single_row fuer jede dieser Zeilen auf mit force_reeval=True + # und uebergibt die Auswahl der Schritte und das Flag zum Loeschen des 'x'-Flags. + # Nutzt interne Helfer: _process_single_row, _get_cell_value_safe. + # Nutzt globale Helfer: COLUMN_MAP, logger. + # Nutzt die uebergeordnete sheet_handler Instanz. + 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 # Neues Flag fuer ML-Schritt + # Fuegen Sie hier ggf. weitere boolsche Flags fuer andere Schrittgruppen hinzu + ): + """ + Verarbeitet nur Zeilen, die in Spalte A mit 'x' markiert sind. + Ruft _process_single_row fuer jede dieser Zeilen auf mit force_reeval=True. + Verarbeitet maximal row_limit Zeilen. + Loescht optional das 'x'-Flag nach erfolgreicher Verarbeitung (innerhalb von _process_single_row). + Erlaubt die Auswahl spezifischer Verarbeitungsschritte. + + Args: + row_limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None (Unbegrenzt). + clear_flag (bool, optional): Wenn True, wird das Flag 'x' in Spalte A + nach erfolgreicher Verarbeitung durch _process_single_row geloescht. + Defaults to True. + process_wiki_steps (bool, optional): Soll der Wiki-Schritt in _process_single_row ausgefuehrt werden?. Defaults to True. + process_chatgpt_steps (bool, optional): Sollen ChatGPT-Schritte in _process_single_row ausgefuehrt werden?. Defaults to True. + process_website_steps (bool, optional): Soll der Website-Schritt in _process_single_row ausgefuehrt werden?. Defaults to True. + process_ml_steps (bool, optional): Soll der ML-Schritt in _process_single_row ausgefuehrt werden?. Defaults to True. # Neues Flag + # Fuegen Sie hier ggf. weitere boolsche Flags fuer andere Schrittgruppen hinzu. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Logge die Konfiguration des Re-Eval Laufs + self.logger.info(f"Starte Re-Evaluierungsmodus (Spalte A = 'x'). Max. Zeilen: {row_limit if row_limit is not None else 'Unbegrenzt'}") + # Logge die ausgewaehlten Schritte fuer diesen Lauf + 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)") # Neues Flag + # Fuegen Sie hier weitere Schritte hinzu, wenn neue Flags existieren + self.logger.info(f" Ausgewaehlte Schritte fuer Re-Eval: {', '.join(selected_steps_log) if selected_steps_log else 'Keine ausgewählt!'}") + + + # Erstelle das Set der Schluessel fuer die Schritte, die an _process_single_row uebergeben werden + 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') # Annahme: 'chat' triggert alle ChatGPT Schritte in _process_single_row (Block 20) + if process_website_steps: steps_to_run_set.add('web') + if process_ml_steps: steps_to_run_set.add('ml_predict') # Neues Flag + # Fuegen Sie hier weitere Schluessel hinzu, wenn neue Flags verwendet werden + + + # Wenn keine Schritte ausgewaehlt wurden + if not steps_to_run_set: + self.logger.warning("Keine Verarbeitungsschritte fuer Re-Eval ausgewaehlt (steps_to_run_set ist leer). Modus wird uebersprungen.") + return + + + # Daten neu laden vor der Verarbeitung (nutzt die uebergeordnete Instanz) + # Der load_data Aufruf ist mit retry_on_failure dekoriert. + if not self.sheet_handler.load_data(): + self.logger.error("Fehler beim Laden der Daten fuer Re-Evaluation.") + return # Beende die Methode, wenn das Laden fehlschlaegt + + + # Holen Sie die gesamte Datenliste (inklusive Header) + all_data = self.sheet_handler.get_all_data_with_headers() + # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar. + header_rows = self.sheet_handler._header_rows + # Wenn keine Daten da sind oder nur Header + if not all_data or len(all_data) <= header_rows: + self.logger.warning("Keine Datenzeilen fuer Re-Evaluation gefunden.") + return # Beende die Methode + + + # Ermitteln Sie den Index der ReEval Flag Spalte aus COLUMN_MAP (Block 1) + reeval_col_idx = COLUMN_MAP.get("ReEval Flag") + if reeval_col_idx is None: + self.logger.critical("FEHLER: 'ReEval Flag' Spaltenindex nicht in COLUMN_MAP gefunden. Kann Zeilen mit 'x' nicht finden. Breche ab.") + return # Beende die Methode bei kritischem Fehler + + + # Sammeln Sie die Zeilen, die in Spalte A mit 'x' markiert sind. + rows_to_process = [] # Liste von Dictionaries {'row_num': ..., 'data': ...} + # Iteriere ueber die Datenzeilen (ab der ersten Datenzeile) + for idx_in_list in range(header_rows, len(all_data)): + row_data = all_data[idx_in_list] # Die Rohdaten fuer diese Zeile (0-basierter Index in all_data) + row_num_in_sheet = idx_in_list + 1 # 1-basierte Zeilennummer im Sheet + + # Pruefen Sie sicher auf den Wert 'x' in Spalte A (nutzt interne Helfer) + cell_a_value = self._get_cell_value_safe(row_data, "ReEval Flag").strip().lower() + + # Wenn die Zelle in Spalte A "x" ist + if cell_a_value == "x": + # Fuegen Sie die Zeilendaten zur Liste der zu verarbeitenden Zeilen hinzu + rows_to_process.append({'row_num': row_num_in_sheet, 'data': row_data}) + + + found_count = len(rows_to_process) # Anzahl der gefundenen markierten Zeilen + self.logger.info(f"{found_count} Zeilen mit ReEval-Flag 'x' gefunden.") + # Wenn keine Zeilen zum Verarbeiten markiert sind + if found_count == 0: + self.logger.info("Keine Zeilen zur Re-Evaluation markiert.") + return # Beende die Methode + + + # Verarbeitung der markierten Zeilen + processed_count = 0 # Zaehlt Zeilen, fuer die _process_single_row aufgerufen wurde (im Rahmen des Limits). + # Die Liste updates_clear_flag wird NICHT mehr hier gefuellt, da _process_single_row das Update selbst hinzufuegt (Block 21). + # Die Liste rows_actually_processed wird nicht mehr benoetigt. + + # Iteriere ueber die gefundenen markierten Zeilen + for task in rows_to_process: + # Ueberpruefen Sie das Limit fuer die zu verarbeitenden Zeilen VOR der Verarbeitung + if row_limit is not None and isinstance(row_limit, int) and row_limit > 0 and processed_count >= row_limit: + # Wenn das Limit erreicht ist und es ein positives Limit gibt + self.logger.info(f"Zeilenlimit ({row_limit}) fuer Re-Evaluation erreicht. Breche weitere Verarbeitung ab.") + break # Brich die Schleife ab + + + row_num = task['row_num'] # 1-basierte Zeilennummer + row_data = task['data'] # Die Rohdaten fuer diese Zeile + + self.logger.info(f"Bearbeite Re-Eval Zeile {row_num}...") + try: + # Rufe die Methode zur Verarbeitung einer einzelnen Zeile auf (_process_single_row Block 19). + # In diesem Modus setzen wir immer force_reeval=True. + # Wir uebergeben die aus CLI/Menue ausgewaehlten Schritte in steps_to_run_set. + # Wir uebergeben das clear_flag, damit _process_single_row weiss, ob das 'x' geloescht werden soll. + # _process_single_row fuehrt die Schritte durch, sammelt Updates (inkl. 'x'-Flag Update wenn clear_x_flag=True) + # und sendet das Batch-Update fuer diese Zeile. + self._process_single_row( + row_num_in_sheet = row_num, + row_data = row_data, + steps_to_run = steps_to_run_set, # <-- Uebergibt die aus CLI/Menue ausgewaehlten Schritte + force_reeval = True, # <-- Erzwingt Re-Evaluation unabhaengig von Timestamps fuer die ausgewaehlten Schritte + clear_x_flag = clear_flag # <-- Uebergibt, ob das 'x'-Flag von _process_single_row geloescht werden soll + ) + + # Zaehlen, wenn _process_single_row erfolgreich aufgerufen wurde (unabhaengig von internen Ueberspringungen in _process_single_row). + processed_count += 1 + # Die Liste rows_actually_processed wird nicht mehr benoetigt. + + 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. + # Das 'x'-Flag wird in diesem Fall NICHT geloescht, da _process_single_row nicht bis zum Ende kam. + self.logger.exception(f"FEHLER bei Re-Evaluation von Zeile {row_num}: {e_proc}") + # Hier koennen Sie z.B. einen Fehlerindikator in eine spezielle Spalte im Sheet schreiben lassen. + # Dieses Update muesste dann separat oder im naechsten Lauf behandelt werden. + + # _process_single_row beinhaltet bereits eine kleine Pause am Ende. + # Hier ist keine zusaetzliche Pause noetig nach der Zeilenverarbeitung. + # 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 + + + # Der Codeblock zum Loeschen der gesammelten Updates (updates_clear_flag) am Ende wurde entfernt. + + # Logge den Abschluss des Re-Eval Modus + self.logger.info(f"Re-Evaluierung abgeschlossen. {processed_count} Zeilen verarbeitet (Gefunden: {found_count}, Limit: {row_limit}).") + + +# ============================================================================== +# Ende DataProcessor Klasse Prozess: Re-Evaluation Block +# ============================================================================== + + # ========================================================================== + # === Batch Processing Methods ============================================= + # ========================================================================== + + # --- Interne Hilfsfunktion fuer Wiki-Verifizierungs-Batch (OpenAI Call) --- + # Diese Funktion verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen ueber OpenAI. + # Sie wird von process_verification_batch (dieselben Block) aufgerufen. + # Nutzt globale Helfer: call_openai_chat (Block 8), logger, token_count (optional Block 3), retry_on_failure (Block 2), re. + @retry_on_failure # Wende den Decorator auf den gesamten Batch-API Call an + def _process_verification_openai_batch(self, batch_data): + """ + 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'})...") # Sichere Indexierung + + # --- 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 <Zeilennummer>: <Antwort>\n\n" + "Moegliche Antworten:\n" + "- 'OK' (wenn der Artikel gut passt)\n" + "- 'X | Alternativer Artikel: <URL> | Begruendung: <Kurze Begruendung>' (wenn der Artikel nicht passt, aber ein besserer gefunden wurde)\n" + "- 'X | Kein passender Artikel gefunden | Begruendung: <Kurze 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')); logger.debug(f"Geschaetzt Prompt-Tokens fuer Batch: {prompt_tokens}."); + # except Exception as e_tc: 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 summarize_batch_openai Funktion (Block 9) 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. + logger.error("call_openai_chat gab unerwarteterweise None zurueck fuer Wiki-Verifizierungs-Batch.") + # 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. + logger.error(f"Endgueltiger FEHLER beim OpenAI-Batch-Aufruf fuer Wiki-Verifizierung (innerhalb Batch Decorator): {e}") + # Logge den Traceback + self.logger.debug(traceback.format_exc()) + # 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 <Zeilennummer>:" 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: 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.") + + # Fuegen Sie einen Fehlerwert fuer Zeilen hinzu, die nicht geparst werden konnten (z.B. falsches Antwortformat) + if parsed_count < len(original_batch_row_nums): + logger.warning(f"Nicht alle Zeilen aus dem Batch ({len(original_batch_row_nums)}) konnten in der OpenAI-Antwort ({len(lines)} Zeilen) geparst werden.") + # Logge den Anfang der unvollstaendigen Antwort auf Debug + logger.debug(f"Unerwartete Antwortteile (erste 500 Zeichen): {chat_response[:500]}") + 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 (derselbe 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'}...") + + + # --- 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...") + # 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.") + 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}") + 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.") + 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}") + + # Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen) + if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows: + self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") + return # Beende die Methode, wenn der Bereich leer ist + + + # --- Indizes und Buchstaben --- + # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind + required_keys = [ + "Wiki Verif. Timestamp", "Wiki URL", "Chat Wiki Konsistenzpruefung", # Pruefkriterien / Timestamp (AX, M, S) + "CRM Name", "CRM Beschreibung", "Wiki Absatz", "Wiki Kategorien", # Daten fuer Prompt (B, F, N, R) + "Chat Begruendung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", # Ergebnisspalten (T, U) + "Begruendung bei Abweichung", "Chat Begruendung Abweichung Branche", # Spalten V-Y zum Leeren + "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 aus COLUMN_MAP. + 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.") + 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 Begruendung 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"] + 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 + empty_vy_values = [''] * (y_idx - v_idx + 1) # Anzahl der Spalten = Y_Index - V_Index + 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) + openai_batch_size = getattr(Config, 'PROCESSING_BATCH_SIZE', 20) # Nutzt dieselbe Batch-Groesse wie Scraping/Summarization + # Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1) + update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) + + + current_openai_batch_data = [] # Daten fuer den aktuellen OpenAI Batch (Liste von Dicts) + rows_in_current_openai_batch = [] # 1-basierte Zeilennummern im aktuellen OpenAI Batch + all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts) + + + processed_count = 0 # Zaehlt Zeilen, die fuer die Verarbeitung in Frage kommen und in den Batch aufgenommen werden (im Rahmen des Limits). + skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (wegen Status, fehlender Daten etc.). + skipped_no_wiki_url = 0 # Zaehlt Zeilen, die speziell wegen fehlender M-URL uebersprungen wurden. + + + # 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).") + 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 = is_s_in_endstate = s_value_upper in s_end_states # Bugfix: variable is_s_in_endstate wurde falsch zugewiesen. + + # 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}") # Gekuerzt loggen + + + # 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 --- + processed_count += 1 # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im Rahmen des Limits zaehlen) + + # Pruefe das Limit fuer verarbeitete Zeilen + if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit: + # Wenn das Limit erreicht ist und es ein positives Limit gibt + self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_verification_batch erreicht. Breche weitere Zeilenpruefung ab.") + break # Brich die Schleife ab + + + # Sammle die benoetigten Daten fuer den OpenAI Prompt (_process_verification_openai_batch Block 26). + # 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}) ---") + + + # Rufe die interne Methode auf, die den OpenAI Call fuer den Batch macht. + # _process_verification_openai_batch (derselbe Block) 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) + wiki_confirm, alt_article, wiki_explanation = "", "", "" # Initialisiere mit leeren Strings + + # 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 | <Detail> | <Begruendung>" + parts = answer.split("|", 2) # Teile maximal in 3 Teile + wiki_confirm = "X" # Status ist X + if len(parts) > 1: + detail = parts[1].strip() # Zweiter Teil ist Detail (Alternative URL oder "Kein passender Artikel gefunden") + 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: + reason_part = parts[2].strip() # Dritter Teil ist Begruendung + 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 + + else: # Unerwartetes Format der Antwort (weder OK noch X | noch FEHLER) + wiki_confirm = "?" # Setze Status auf unbekannt + wiki_explanation = f"Unerwartetes Format: {str(answer)[:100]}..." # Speichere Anfang der Antwort in Begruendung (gekuerzt) + 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...") + # 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.") + # 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 ---") + 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}) ---") + + # 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)...") + # 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.") + # 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).") + # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt. + + +# ============================================================================== +# Ende DataProcessor Klasse Batch: Wiki Verification Block +# ============================================================================== + + # ========================================================================== + # === Batch Processing Methods ============================================= + # ========================================================================== + + # --- Interne Hilfsfunktion fuer Wiki-Verifizierungs-Batch (OpenAI Call) --- + # Diese Funktion verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen ueber OpenAI. + # Sie wird von process_verification_batch (derselben Block) aufgerufen. + # Nutzt globale Helfer: call_openai_chat (Block 8), logger, token_count (optional Block 3), retry_on_failure (Block 2), re. + @retry_on_failure # Wende den Decorator auf den gesamten Batch-API Call an + def _process_verification_openai_batch(self, batch_data): + """ + 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 ihre 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'})...") # Sichere Indexierung + + # --- 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 <Zeilennummer>: <Antwort>\n\n" + "Moegliche Antworten:\n" + "- 'OK' (wenn der Artikel gut passt)\n" + "- 'X | Alternativer Artikel: <URL> | Begruendung: <Kurze Begruendung>' (wenn der Artikel nicht passt, aber ein besserer gefunden wurde)\n" + "- 'X | Kein passender Artikel gefunden | Begruendung: <Kurze 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')); logger.debug(f"Geschaetzt Prompt-Tokens fuer Batch: {prompt_tokens}."); + # except Exception as e_tc: 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 summarize_batch_openai Funktion (Block 9) 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. + logger.error("call_openai_chat gab unerwarteterweise None zurueck fuer Wiki-Verifizierungs-Batch.") + # 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. + logger.error(f"Endgueltiger FEHLER beim OpenAI-Batch-Aufruf fuer Wiki-Verifizierung (innerhalb Batch Decorator): {e}") + # Logge den Traceback + self.logger.debug(traceback.format_exc()) + # 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 <Zeilennummer>:" 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: 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.") + + # Fuegen Sie einen Fehlerwert fuer Zeilen hinzu, die nicht geparst werden konnten (z.B. falsches Antwortformat) + if parsed_count < len(original_batch_row_nums): + logger.warning(f"Nicht alle Zeilen aus dem Batch ({len(original_batch_row_nums)}) konnten in der OpenAI-Antwort ({len(lines)} Zeilen) geparst werden.") + # Logge den Anfang der unvollstaendigen Antwort auf Debug + logger.debug(f"Unerwartete Antwortteile (erste 500 Zeichen): {chat_response[:500]}") + 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'}...") + + + # --- 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...") + # 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.") + 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}") + 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.") + 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}") + + # Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen) + if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows: + self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") + return # Beende die Methode, wenn der Bereich leer ist + + + # --- Indizes und Buchstaben --- + # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind + required_keys = [ + "Wiki Verif. Timestamp", "Wiki URL", "Chat Wiki Konsistenzpruefung", # Pruefkriterien / Timestamp (AX, M, S) + "CRM Name", "CRM Beschreibung", "Wiki Absatz", "Wiki Kategorien", # Daten fuer Prompt (B, F, N, R) + "Chat Begruendung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", # Ergebnisspalten (T, U) + "Begruendung bei Abweichung", "Chat Begruendung Abweichung Branche", # Spalten V-Y zum Leeren + "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.") + 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 Begruendung 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"] + y_idx = col_indices["Chat Begruendung Abweichung Branche"] # Block 1 Column Map + # 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 + empty_vy_values = [''] * (y_idx - v_idx + 1) # Anzahl der Spalten = Y_Index - V_Index + 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) + openai_batch_size = getattr(Config, 'PROCESSING_BATCH_SIZE', 20) # Nutzt dieselbe Batch-Groesse wie Scraping/Summarization + # Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1) + update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) + + + current_openai_batch_data = [] # Daten fuer den aktuellen OpenAI Batch (Liste von Dicts) + rows_in_current_openai_batch = [] # 1-basierte Zeilennummern im aktuellen OpenAI Batch + all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts) + + + processed_count = 0 # Zaehlt Zeilen, die fuer die Verarbeitung in Frage kommen und in den Batch aufgenommen werden (im Rahmen des Limits). + skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (wegen Status, fehlender Daten etc.). + skipped_no_wiki_url = 0 # Zaehlt Zeilen, die speziell wegen fehlender M-URL uebersprungen wurden. + + + # 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).") + 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 = is_s_in_endstate = s_value_upper in s_end_states # Bugfix: variable is_s_in_endstate wurde falsch zugewiesen. + + # 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}") # Gekuerzt loggen + + + # 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 --- + processed_count += 1 # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im Rahmen des Limits zaehlen) + + # Pruefe das Limit fuer verarbeitete Zeilen + if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit: + # Wenn das Limit erreicht ist und es ein positives Limit gibt + self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_verification_batch erreicht. Breche weitere Zeilenpruefung ab.") + break # Brich die Schleife ab + + + # Sammle die benoetigten Daten fuer den OpenAI Prompt (_process_verification_openai_batch Block 26). + # 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}) ---") + + + # Rufe die interne Methode auf, die den OpenAI Call fuer den Batch macht. + # _process_verification_openai_batch (derselbe Block) 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) + wiki_confirm, alt_article, wiki_explanation = "", "", "" # Initialisiere mit leeren Strings + + # 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 | <Detail> | <Begruendung>" + parts = answer.split("|", 2) # Teile maximal in 3 Teile + wiki_confirm = "X" # Status ist X + if len(parts) > 1: + detail = parts[1].strip() # Zweiter Teil ist Detail (Alternative URL oder "Kein passender Artikel gefunden") + 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: + reason_part = parts[2].strip() # Dritter Teil ist Begruendung + 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 + + else: # Unerwartetes Format der Antwort (weder OK noch X | noch FEHLER) + wiki_confirm = "?" # Setze Status auf unbekannt + wiki_explanation = f"Unerwartetes Format: {str(answer)[:100]}..." # Speichere Anfang der Antwort in Begruendung (gekuerzt) + 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...") + # 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.") + # 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 ---") + 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}) ---") + + # 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)...") + # 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.") + # 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).") + # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt. + + +# ============================================================================== +# Ende DataProcessor Klasse Batch: Wiki Verification Block +# ============================================================================== + + # ========================================================================== + # === Batch Processing Methods ============================================= + # ========================================================================== + + # --- Methode fuer den Website-Scraping-Batchmodus (AR) --- + # Diese Methode verarbeitet Zeilen, bei denen AR leer ist, um den Rohtext zu scrapen. + # Sie nutzt einen ThreadPoolExecutor und ruft eine globale Worker-Funktion auf. + # Basierend auf process_website_batch aus Teil 9. + # Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter. + # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), datetime, time, + # concurrent.futures, get_website_raw (Block 11). + # Nutzt die uebergeordnete sheet_handler Instanz (Block 14). + def process_website_scraping_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None): + """ + Batch-Prozess NUR fuer Website-Scraping (Rohtext AR). + Laedt Daten neu, prueft Spalte AR auf Inhalt ('', 'k.A.', etc.) und ueberspringt Zeilen mit Inhalt. + Setzt AR + AT + AP fuer bearbeitete Zeilen. Sendet Updates gebuendelt. + + Args: + start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AT). + 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 Website-Scraping (Batch AR, AT, AP). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") + + + # --- Daten laden und Startzeile ermitteln --- + # Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt + if start_sheet_row is None: + self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AT...") + # Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AT (Block 1 Column Map). + # Standardmaessig ab Zeile 7 + start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Website Scrape 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.") + 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 AT Zelle): {start_sheet_row}") + else: + # Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein. + # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2). + if not self.sheet_handler.load_data(): + self.logger.error("FEHLER beim Laden der Daten fuer process_website_scraping_batch.") + 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}") + + # Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen) + if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows: + self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") + return # Beende die Methode, wenn der Bereich leer ist + + + # --- Indizes und Buchstaben --- + # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind + required_keys = [ + "Website Rohtext", "CRM Website", "Version", "Website Scrape Timestamp", "CRM Name" # AR, D, AP, AT, 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_scraping_batch: {missing}. Breche ab.") + return # Beende die Methode bei kritischem Fehler + + # Ermitteln Sie die Indizes und Buchstaben fuer Updates (AR, AT, AP) + rohtext_col_idx = col_indices["Website Rohtext"] + website_col_idx = col_indices["CRM Website"] + version_col_idx = col_indices["Version"] + timestamp_col_idx = col_indices["Website Scrape Timestamp"] + name_col_idx = col_indices["CRM Name"] + + rohtext_col_letter = self.sheet_handler._get_col_letter(rohtext_col_idx + 1) # Block 14 _get_col_letter + version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1) + timestamp_col_letter = self.sheet_handler._get_col_letter(timestamp_col_idx + 1) + + + # --- Worker-Funktion fuer Scraping (Intern in der Methode definiert) --- + # Diese Funktion laeuft in einem separaten Thread fuer parallele Verarbeitung. + # Sie nutzt die globale Funktion get_website_raw und erhaelt sie als Argument uebergeben. + def scrape_raw_text_task(task_info, get_website_raw_func): + """ + Scrapt den Rohtext einer Website in einem separaten Thread. + Wird vom ThreadPoolExecutor in process_website_scraping_batch aufgerufen. + Nutzt die uebergebene Funktion zum Abrufen des Rohtexts. + + Args: + task_info (dict): Enthält {'row_num': int, 'url': str}. + get_website_raw_func (function): Die Funktion zum Abrufen des Website-Rohtexts (sollte die globale get_website_raw sein). + + Returns: + dict: Enthält {'row_num': int, 'raw_text': str, 'error': str}. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + row_num = task_info['row_num'] + url = task_info['url'] + raw_text = "k.A." + error = None + + try: + # RUFT die uebergebene Funktion zum Abrufen des Rohtexts auf. + # Der retry_on_failure Decorator auf get_website_raw_func (der hoffentlich get_website_raw ist) + # behandelt Retries und die meisten Fehler. + raw_text = get_website_raw_func(url) # <<< Ruft die uebergebene Funktion auf + + # Wenn die Funktion einen Fehler loggt und einen Fehlerstring im Ergebnis zurueckgibt, + # wird dies hier als Fehler im Task markiert. + if isinstance(raw_text, str) and (raw_text.startswith("k.A. (Fehler") or raw_text.startswith("FEHLER:")): + error = f"Scraping Fehler (Details im Rohtext): {raw_text[:100]}..." + # Der Fehler wurde bereits in get_website_raw geloggt, kein weiteres Logging hier noetig. + # Das raw_text selbst enthaelt den Fehlerstring. + + elif not isinstance(raw_text, str) or not raw_text.strip(): + # Wenn die Funktion keinen String oder einen leeren String zurueckgibt + error = "Scraping Task Fehler: Funktion gab keinen gueltigen String zurueck." + raw_text = "k.A. (Extraktion fehlgeschlagen)" # Standard-Fehlerwert + + except Exception as e: + # Dieser Block sollte jetzt sehr selten erreicht werden, da die uebergegebene Funktion + # mit retry_on_failure die meisten Fehler abfangen sollte. + # Wenn eine Exception hier durchkommt, ist es ein sehr unerwarteter Fehler im Task-Handling selbst. + error = f"Unerwarteter Fehler im Scraping Task Zeile {row_num} ({url[:100]}): {type(e).__name__} - {e}" # Gekuerzt loggen + logger.error(error) # Loggen Sie diesen unerwarteten Fehler + raw_text = "k.A. (Unerwarteter Fehler Task)" # Setze einen spezifischen Fehlerwert + + + # logger.debug(f"Scraping Task Zeile {row_num} abgeschlossen. Textlaenge: {len(str(raw_text))}.") # Zu viel Laerm im Debug + return {"row_num": row_num, "raw_text": raw_text, "error": error} + + + # --- Hauptlogik: Iteriere und sammle Batches --- + # Holen Sie die Batch-Groesse fuer Verarbeitung (Threading) aus Config (Block 1) + processing_batch_size = getattr(Config, 'PROCESSING_BATCH_SIZE', 20) + # Holen Sie die maximale Anzahl Worker aus Config (Block 1) + max_scraping_workers = getattr(Config, 'MAX_SCRAPING_WORKERS', 10) + # Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1) + update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) + + + tasks_for_processing_batch = [] # Tasks fuer den aktuellen Scraping-Batch (Liste von Dicts) + rows_in_current_scraping_batch = [] # 1-basierte Zeilennummern im aktuellen Batch + all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts) + + + processed_count = 0 # Zaehlt Zeilen, die fuer die Verarbeitung in Frage kommen und in den Batch aufgenommen werden (im Rahmen des Limits). + skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (wegen Inhalt oder fehlender URL). + skipped_no_url = 0 # Zaehlt Zeilen, die speziell wegen fehlender URL uebersprungen wurden. + + + # 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 + 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: Website Rohtext (AR) ist leer oder ein Standard-Fehlerwert. + # UND Website URL (D) ist vorhanden und gueltig aussehend. + + # Holen Sie den Wert aus Spalte AR (Website Rohtext) (nutzt interne Helfer _get_cell_value_safe) + cell_value_ar = self._get_cell_value_safe(row, "Website Rohtext") # Block 1 Column Map + # Pruefen Sie, ob AR leer ist oder einen Standard-Fehlerwert enthaelt. + ar_is_empty_or_default = not cell_value_ar or (isinstance(cell_value_ar, str) and str(cell_value_ar).strip().lower() in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]) + + + # 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 AR leer/default ist UND D gefuellt/gueltig aussieht. + processing_needed_for_row = ar_is_empty_or_default 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 Scraping Check): AR leer/default? {ar_is_empty_or_default}, D gueltig? {website_url_is_valid_looking}. Benötigt Verarbeitung? {processing_needed_for_row}") # Gekuerzt loggen + + + # Wenn die Verarbeitung fuer diese Zeile nicht noetig ist + if not processing_needed_for_row: + skipped_count += 1 # Zaehlen als uebersprungene Zeile + # Zaehlen Sie speziell, wenn die Zeile wegen fehlender gueltiger URL uebersprungen wurde. + if not website_url_is_valid_looking: skipped_no_url += 1 + continue # Springe zur naechsten Zeile + + + # --- Wenn Verarbeitung noetig: Fuege zur Batch-Liste hinzu --- + processed_count += 1 # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im Rahmen des Limits zaehlen) + + # Pruefe das Limit fuer verarbeitete Zeilen + if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit: + # Wenn das Limit erreicht ist und es ein positives Limit gibt + self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_website_scraping_batch erreicht. Breche weitere Zeilenpruefung ab.") + break # Brich die Schleife ab + + + # Fuege die benoetigten Daten fuer den Task hinzu (Zeilennummer und URL) + tasks_for_processing_batch.append({"row_num": i, "url": website_url}) + # Fuege die Zeilennummer zur Liste der Zeilennummern im Batch hinzu + rows_in_current_scraping_batch.append(i) + + + # --- Verarbeite den Batch, wenn voll --- + # Pruefe, ob die aktuelle Batch-Liste die definierte Groesse erreicht hat. + # scraping_batch_size wird aus Config geholt (Block 1). + if len(tasks_for_processing_batch) >= processing_batch_size: + # Logge den Start der Batch-Verarbeitung + batch_start_row = tasks_for_processing_batch[0]['row_num'] + batch_end_row = tasks_for_processing_batch[-1]['row_num'] + self.logger.debug(f"\n--- Starte Website-Scraping Batch ({len(tasks_for_processing_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") + + scraping_results = {} # Dictionary zum Speichern der Ergebnisse {row_num: raw_text} + batch_error_count = 0 # Fehlerzaehler fuer diesen spezifischen Batch + + self.logger.debug(f" Scrape {len(tasks_for_processing_batch)} Websites parallel (max {max_scraping_workers} worker)...") + # Nutzt concurrent.futures.ThreadPoolExecutor fuer paralleles Scraping. + # max_workers wird aus Config geholt (Block 1). + with concurrent.futures.ThreadPoolExecutor(max_workers=max_scraping_workers) as executor: + # Map tasks to futures. Ruft die INTERNE Worker-Funktion auf. + # Uebergibt das task_info Dictionary und die globale Funktion get_website_raw (Block 11) als Argument. + future_to_task = {executor.submit(scrape_raw_text_task, task, get_website_raw): task for task in tasks_for_processing_batch} + + # Verarbeite die Ergebnisse, sobald sie fertig sind. + for future in concurrent.futures.as_completed(future_to_task): + task = future_to_task[future] # Holen Sie die urspruenglichen Task-Daten (Dict) + try: + # Holen Sie das Ergebnis vom Future. Wenn die Worker-Funktion eine Exception wirft, wird diese hier gefangen. + result = future.result() # Ergebnis ist ein Dictionary {'row_num': ..., 'raw_text': ..., 'error': ...} + # Speichere das Ergebnis im scraping_results Dictionary + scraping_results[result['row_num']] = result['raw_text'] + # Wenn der Worker einen Fehler gemeldet hat (z.B. durch Fehlerstring im raw_text oder error-Feld) + if result.get('error'): + batch_error_count += 1 # Erhoehe den Fehlerzaehler fuer diesen Batch + + except Exception as exc: + # Dieser Block faengt unerwartete Fehler ab, die waehrend der Future-Ergebnis-Abfrage auftreten. + # Die meisten Fehler sollten von get_website_raws retry/logging behandelt werden. + row_num = task['row_num'] # Zeilennummer aus den Task-Daten + err_msg = f"Unerwarteter Fehler bei Ergebnisabfrage Scraping Task Zeile {row_num} ({task['url'][:100]}): {type(exc).__name__} - {exc}" # Gekuerzt loggen + logger.error(err_msg) # Logge den Fehler + # Setze einen Standard-Fehlerwert fuer diese Zeile im Ergebnis + scraping_results[row_num] = "k.A. (Unerwarteter Fehler Task)" + batch_error_count += 1 # Erhoehe den Fehlerzaehler + + + self.logger.debug(f" Scraping fuer Batch beendet. {len(scraping_results)} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).") + + + # Sammle Sheet Updates (AR, AT, AP) fuer diesen Batch. + # Dies geschieht jetzt nach der parallelen Verarbeitung. + if scraping_results: + # Aktueller Zeitstempel und Version + current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + current_version = getattr(Config, 'VERSION', 'unknown') # Block 1 Config Attribut + batch_sheet_updates = [] # Updates fuer DIESEN spezifischen Batch von Zeilen + + + # Iteriere ueber die Zeilennummern im Batch, fuer die Ergebnisse vorliegen. + # Ergebnisse koennen Fehlerwerte enthalten. + for row_num, raw_text_res in scraping_results.items(): + # Fuege Updates fuer AR, AT und AP hinzu (nutzt interne Helfer) + # AR: Roh extrahierter Text (kann auch Fehlerwert sein) + batch_sheet_updates.append({'range': f'{rohtext_col_letter}{row_num}', 'values': [[raw_text_res]]}) # Block 1 Column Map + # AT: Timestamp des Scraping-Versuchs (immer setzen, wenn versucht wurde) + batch_sheet_updates.append({'range': f'{timestamp_col_letter}{row_num}', 'values': [[current_timestamp]]}) # Block 1 Column Map + # AP: Version des Skripts + batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Block 1 Column Map + + + # Sammle diese Batch-Updates fuer das groessere Batch-Update am Ende oder bei Limit. + # update_batch_row_limit wird aus Config geholt (Block 1). + all_sheet_updates.extend(batch_sheet_updates) + + + # Leere den Scraping-Batch fuer die naechste Iteration + tasks_for_processing_batch = [] + rows_in_current_scraping_batch = [] + + + # Sende gesammelte Sheet Updates, wenn das Update-Batch-Limit erreicht ist. + # Updates pro Zeile sind 3 (AR, AT, AP). Anzahl der Zeilen = len(all_sheet_updates) / 3. + rows_in_update_batch = len(all_sheet_updates) // 3 # Ganzzahl-Division + + if rows_in_update_batch >= update_batch_row_limit: + self.logger.debug(f" Sende gesammelte Sheet-Updates ({rows_in_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") + # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry. + # Wenn es fehlschlaegt, wird es intern geloggt. + success = self.sheet_handler.batch_update_cells(all_sheet_updates) + if success: + self.logger.info(f" Sheet-Update fuer {rows_in_update_batch} Zeilen erfolgreich.") + # Der Fehlerfall wird von batch_update_cells geloggt + + # Leere die gesammelten Updates nach dem Senden. + all_sheet_updates = [] + # rows_in_update_batch muss nicht explizit zurueckgesetzt werden, da es aus len(all_sheet_updates) berechnet wird. + + + # Keine Pause hier nach jedem kleinen Scraping-Batch, da wir auf batch_update warten. + # Die Pause kommt erst nach dem Batch-Update (oder am Ende des Modus). + # time.sleep(0.1) # Optionale kurze Pause + + + # --- Verarbeitung des letzten unvollstaendigen Scraping-Batches nach der Schleife --- + # Fuehre den letzten Batch aus, wenn nach der Hauptschleife noch Tasks in der Batch-Liste sind. + if tasks_for_processing_batch: + # Logge den Start des finalen Batches + batch_start_row = tasks_for_processing_batch[0]['row_num'] + batch_end_row = tasks_for_processing_batch[-1]['row_num'] + self.logger.debug(f"\n--- Starte FINALEN Website-Scraping Batch ({len(tasks_for_processing_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") + + scraping_results = {} # Dictionary fuer die Ergebnisse + batch_error_count = 0 # Fehlerzaehler + + self.logger.debug(f" Scrape {len(tasks_for_processing_batch)} Websites parallel (max {max_scraping_workers} worker)...") + with concurrent.futures.ThreadPoolExecutor(max_workers=max_scraping_workers) as executor: + # Map tasks to futures. Ruft die GLOBALE Worker-Funktion auf. + # Uebergibt das task_info Dictionary und die globale Funktion get_website_raw (Block 11) als Argument. + future_to_task = {executor.submit(_scrape_raw_text_task_global, task, get_website_raw): task for task in tasks_for_processing_batch} + + # Verarbeite die Ergebnisse + for future in concurrent.futures.as_completed(future_to_task): + task = future_to_task[future] # Holen Sie die urspruenglichen Task-Daten + try: + result = future.result() # Holen Sie das Ergebnis + scraping_results[result['row_num']] = result['raw_text'] + # Pruefe, ob der Worker einen Fehler gemeldet hat + if result.get('error'): batch_error_count += 1 + except Exception as exc: + # Faengt unerwartete Fehler bei der Ergebnisabfrage ab + row_num = task['row_num'] + err_msg = f"Unerwarteter Fehler bei Ergebnisabfrage Scraping Task Zeile {row_num} ({task['url'][:100]}): {type(exc).__name__} - {exc}" # Gekuerzt loggen + logger.error(err_msg) # Logge den Fehler + # Setze einen Standard-Fehlerwert + scraping_results[row_num] = "k.A. (Unerwarteter Fehler Task)" + batch_error_count += 1 + + + self.logger.debug(f" FINALER Scraping Batch beendet. {len(scraping_results)} Ergebnisse erhalten ({batch_error_count} Fehler).") + + # Sammle Sheet Updates (AR, AT, AP) fuer diesen finalen Batch. + if scraping_results: + current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + current_version = getattr(Config, 'VERSION', 'unknown') # Block 1 Config Attribut + batch_sheet_updates = [] # Updates fuer diesen spezifischen Batch + # Iteriere ueber die Zeilennummern im Batch, fuer die Ergebnisse vorliegen. + for row_num, raw_text_res in scraping_results.items(): + # Fuege Updates fuer AR, AT und AP hinzu + batch_sheet_updates.append({'range': f'{rohtext_col_letter}{row_num}', 'values': [[raw_text_res]]}) # Block 1 Column Map + batch_sheet_updates.append({'range': f'{timestamp_col_letter}{row_num}', 'values': [[current_timestamp]]}) # Block 1 Column Map + batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Block 1 Column Map + # Fuege diese Updates zur globalen Liste hinzu (wird dann nur noch einmal gesendet) + all_sheet_updates.extend(batch_sheet_updates) + + + # --- Finale Sheet Updates senden --- + # Sende alle verbleibenden gesammelten Updates in einem letzten Batch-Update. + if all_sheet_updates: + rows_in_final_update_batch = len(all_sheet_updates) // 3 # Updates pro Zeile ist 3 + self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({rows_in_final_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") + # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry. + success = self.sheet_handler.batch_update_cells(all_sheet_updates) + if success: + self.logger.info(f"FINALES Sheet-Update erfolgreich.") + # Der Fehlerfall wird von batch_update_cells geloggt + + + # Logge den Abschluss des Modus + self.logger.info(f"Website-Scraping (Batch) abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {skipped_count} Zeilen uebersprungen.") + # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt. + + +# ============================================================================== +# Ende DataProcessor Klasse Batch: Website Scraping Block +# ============================================================================== + + # ========================================================================== + # === Batch Processing Methods ============================================= + # ========================================================================== + + # --- Methode fuer den Website-Summarization-Batchmodus (AS) --- + # Diese Methode verarbeitet Zeilen, bei denen AR gefuellt und AS leer ist, um die Zusammenfassung zu erstellen. + # Sie nutzt die globale Batch-Zusammenfassungsfunktion fuer den OpenAI Call. + # Basierend auf process_website_summarization_batch aus Teil 9. + # Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter. + # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), datetime, time, + # summarize_batch_openai (Block 9). + # Nutzt die uebergeordnete sheet_handler Instanz (Block 14). + def process_summarization_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None): + """ + Batch-Prozess NUR fuer Website-Zusammenfassung (AS). + Laedt Daten neu, prueft, ob Rohtext (AR) vorhanden und Zusammenfassung (AS) fehlt. + Fasst Rohtexte im Batch ueber OpenAI zusammen und setzt AS + AP. + + Args: + start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AS). + 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 Website-Zusammenfassung (Batch AS, AP). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") + + + # --- Daten laden und Startzeile ermitteln --- + # Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt + if start_sheet_row is None: + self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AS...") + # Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AS (Block 1 Column Map). + # Standardmaessig ab Zeile 7 + start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Website Zusammenfassung", 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.") + 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 AS Zelle): {start_sheet_row}") + else: + # Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein. + # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2). + if not self.sheet_handler.load_data(): + self.logger.error("FEHLER beim Laden der Daten fuer process_summarization_batch.") + 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}") + + # Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen) + if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows: + self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") + return # Beende die Methode, wenn der Bereich leer ist + + + # --- Indizes und Buchstaben --- + # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind + required_keys = [ + "Website Rohtext", "Website Zusammenfassung", "Version", "CRM Name" # AR, AS, AP, 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_summarization_batch: {missing}. Breche ab.") + return # Beende die Methode bei kritischem Fehler + + # Ermitteln Sie die Indizes und Buchstaben fuer Updates (AS, AP) + rohtext_col_idx = col_indices["Website Rohtext"] + summary_col_idx = col_indices["Website Zusammenfassung"] + version_col_idx = col_indices["Version"] + name_col_idx = col_indices["CRM Name"] # Benoetigt fuer Logging + + summary_col_letter = self.sheet_handler._get_col_letter(summary_col_idx + 1) # Block 14 _get_col_letter + version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1) + + + # --- Verarbeitung --- + # Holen Sie die Batch-Groesse fuer OpenAI-Aufrufe aus Config (Block 1) + openai_batch_size = getattr(Config, 'OPENAI_BATCH_SIZE_LIMIT', 4) + # Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1) + update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) + + + tasks_for_openai_batch = [] # Tasks fuer den aktuellen OpenAI Batch (Liste von Dicts) + rows_in_current_openai_batch = [] # 1-basierte Zeilennummern im aktuellen OpenAI Batch + all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts) + + + processed_count = 0 # Zaehlt Zeilen, die fuer die Verarbeitung in Frage kommen und in den Batch aufgenommen werden (im Rahmen des Limits). + skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (wegen fehlendem Rohtext oder vorhandener Zusammenfassung). + + + # 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 + 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: Website Rohtext (AR) ist vorhanden und gueltig (nicht k.A. oder Fehlerwerte). + # UND Website Zusammenfassung (AS) ist leer oder ein Standard-Fehlerwert. + + # Holen Sie den Wert aus Spalte AR (Website Rohtext) (nutzt interne Helfer _get_cell_value_safe) + raw_text = self._get_cell_value_safe(row, "Website Rohtext") # Block 1 Column Map + # Pruefen Sie, ob AR gefuellt und gueltig ist. + raw_text_is_valid = raw_text and isinstance(raw_text, str) and str(raw_text).strip().lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"] + + + # Holen Sie den Wert aus Spalte AS (Website Zusammenfassung) (nutzt interne Helfer _get_cell_value_safe) + summary_value = self._get_cell_value_safe(row, "Website Zusammenfassung") # Block 1 Column Map + # Pruefen Sie, ob AS leer ist oder einen Standard-Fehlerwert enthaelt. + summary_is_empty_or_default = not summary_value or (isinstance(summary_value, str) and str(summary_value).strip().lower() in ["k.a.", "k.a. (keine zusammenfassung erhalten)"]) + + + # Verarbeitung ist noetig, wenn AR gueltig ist UND AS leer/default ist. + processing_needed_for_row = raw_text_is_valid and summary_is_empty_or_default + + + # 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 Summarization Check): AR gueltig? {raw_text_is_valid} (len={len(str(raw_text))}), AS leer/default? {summary_is_empty_or_default}. Benötigt Verarbeitung? {processing_needed_for_row}") # Gekuerzt loggen + + + # Wenn die Verarbeitung fuer diese Zeile nicht noetig ist + if not processing_needed_for_row: + skipped_count += 1 # Zaehlen als uebersprungene Zeile + continue # Springe zur naechsten Zeile + + + # --- Wenn Verarbeitung noetig: Fuege zur Batch-Liste fuer OpenAI hinzu --- + processed_count += 1 # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im Rahmen des Limits zaehlen) + + # Pruefe das Limit fuer verarbeitete Zeilen + if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit: + # Wenn das Limit erreicht ist und es ein positives Limit gibt + self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_summarization_batch erreicht. Breche weitere Zeilenpruefung ab.") + break # Brich die Schleife ab + + + # Fuege die benoetigten Daten fuer den OpenAI Batch hinzu (Zeilennummer und Rohtext) + tasks_for_openai_batch.append({'row_num': i, 'raw_text': raw_text}) + # 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(tasks_for_openai_batch) >= openai_batch_size: + # Logge den Start der Batch-Verarbeitung + batch_start_row = tasks_for_openai_batch[0]['row_num'] + batch_end_row = tasks_for_openai_batch[-1]['row_num'] + self.logger.debug(f"\n--- Starte Website-Summarization Batch ({len(tasks_for_openai_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") + + # Rufe die globale Funktion auf, die den OpenAI Call fuer den Batch macht (Block 9). + # summarize_batch_openai ist mit retry_on_failure dekoriert (Block 2). + # Wenn summarize_batch_openai eine Exception wirft (nach Retries), wird diese hier gefangen. + batch_results = self._process_verification_openai_batch(current_openai_batch_data) # <-- Falsche Methode aufgerufen! MUSS summarize_batch_openai sein. + # TODO: Diesen Aufruf zu summarize_batch_openai aendern! + + # !!! KORRIGIERTER AUFRUF !!! + try: + # Rufen Sie die korrekte globale Funktion auf + batch_results = summarize_batch_openai(current_openai_batch_data) # <<< Korrekter Aufruf Block 9 + # Ergebnisse sollten ein Dictionary {row_num: summary_text} sein, auch bei Fehlern. + + # Sammle Sheet Updates (AS, AP) fuer diesen Batch + current_version = getattr(Config, 'VERSION', 'unknown') # Block 1 Config Attribut + 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 summarize_batch_openai korrekt ist). + summary = batch_results.get(row_num, "k.A. (Batch Ergebnis fehlte)") + # Stelle sicher, dass 'k.A.' bei leeren/kurzen Summaries gesetzt wird + if not summary or (isinstance(summary, str) and summary.strip().lower() in ["k.a.", "k.a. (keine zusammenfassung erhalten)"]): + summary = "k.A. (Keine Zusammenfassung erhalten)" + # Fuege "k.A." oder Fehler an, wenn der Wert von summarize_batch_openai ein Fehlerstring ist + elif isinstance(summary, str) and (summary.startswith("k.A. (Fehler") or summary.startswith("FEHLER:")): + pass # Behalte den Fehlerstring von summarize_batch_openai + + # Fuege Updates fuer AS und AP hinzu (nutzt interne Helfer) + batch_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[summary]]}) # Block 1 Column Map + batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Block 1 Column Map + + + # Sammle diese Batch-Updates fuer das groessere Batch-Update + all_sheet_updates.extend(batch_sheet_updates) + + + except Exception as e_openai_batch: + # Wenn summarize_batch_openai eine Exception wirft (nach Retries) + # Der Fehler wird bereits vom retry_on_failure Decorator auf summarize_batch_openai geloggt. + self.logger.error(f"Endgueltiger FEHLER beim OpenAI-Batch-Aufruf fuer Zusammenfassung: {e_openai_batch}") + # Logge den Traceback + self.logger.debug(traceback.format_exc()) + # Fügen Sie Fehlerwerte für alle Zeilen im Batch hinzu + current_version = getattr(Config, 'VERSION', 'unknown') # Block 1 Config Attribut + for row_num in rows_in_current_openai_batch: + error_summary = f"FEHLER OpenAI Batch: {str(e_openai_batch)[:100]}..." # Gekuerzt + # Fuege Updates mit Fehlerwerten fuer AS und AP hinzu + all_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[error_summary]]}) # Block 1 Column Map + all_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Block 1 Column Map + + + # Leere den OpenAI-Batch zurueck + tasks_for_openai_batch = [] + rows_in_current_openai_batch = [] + + + # Sende gesammelte Sheet Updates, wenn das Update-Batch-Limit erreicht ist. + # Updates pro Zeile sind 2 (AS, AP). Anzahl der Zeilen = len(all_sheet_updates) / 2. + rows_in_update_batch = len(all_sheet_updates) // 2 + + if rows_in_update_batch >= update_batch_row_limit: + self.logger.debug(f" Sende gesammelte Sheet-Updates ({rows_in_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") + # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry. + # Wenn es fehlschlaegt, wird es intern geloggt. + success = self.sheet_handler.batch_update_cells(all_sheet_updates) + if success: + self.logger.info(f" Sheet-Update fuer {rows_in_update_batch} Zeilen erfolgreich.") + # Der Fehlerfall wird von batch_update_cells geloggt + + # Leere die gesammelten Updates nach dem Senden. + all_sheet_updates = [] + + # Kurze Pause nach jedem OpenAI Batch (nutzt Config Block 1). + # Dies ist wichtig, um Rate Limits zu vermeiden. + pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.5 # 50% der Retry-Wartezeit + self.logger.debug(f"Warte {pause_duration:.2f}s vor naechstem Batch...") + time.sleep(pause_duration) + + + # --- Verarbeitung des letzten unvollstaendigen OpenAI 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 Website-Summarization Batch ({len(current_openai_batch_data)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") + + # Rufe die globale Funktion auf, die den OpenAI Call fuer den Batch macht (Block 9). + # summarize_batch_openai ist mit retry_on_failure dekoriert (Block 2). + # Wenn summarize_batch_openai eine Exception wirft (nach Retries), wird diese hier gefangen. + batch_results = None + try: + batch_results = summarize_batch_openai(current_openai_batch_data) # <<< Korrekter Aufruf Block 9 + # Ergebnisse sollten ein Dictionary {row_num: summary_text} sein, auch bei Fehlern. + + # Sammle Sheet Updates (AS, AP) fuer diesen finalen Batch + current_version = getattr(Config, 'VERSION', 'unknown') # Block 1 Config Attribut + batch_sheet_updates = [] # Updates fuer diesen spezifischen Batch + # Iteriere ueber die Zeilennummern im Batch, fuer die Ergebnisse vorliegen. + for row_num in rows_in_current_openai_batch: + # Hole das Ergebnis fuer diese Zeile aus dem Ergebnis-Dictionary. + summary = batch_results.get(row_num, "k.A. (Batch Ergebnis fehlte)") # Fallback + + # Stelle sicher, dass 'k.A.' bei leeren/kurzen Summaries gesetzt wird + if not summary or (isinstance(summary, str) and summary.strip().lower() in ["k.a.", "k.a. (keine zusammenfassung erhalten)"]): + summary = "k.A. (Keine Zusammenfassung erhalten)" + # Fuege "k.A." oder Fehler an, wenn der Wert von summarize_batch_openai ein Fehlerstring ist + elif isinstance(summary, str) and (summary.startswith("k.A. (Fehler") or summary.startswith("FEHLER:")): + pass # Behalte den Fehlerstring von summarize_batch_openai + + + # Fuege Updates fuer AS und AP hinzu + batch_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[summary]]}) # Block 1 Column Map + batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Block 1 Column Map + + + # Fuege diese Updates zur globalen Liste hinzu (wird dann nur noch einmal gesendet) + all_sheet_updates.extend(batch_sheet_updates) + + + except Exception as e_openai_batch: + # Wenn summarize_batch_openai eine Exception wirft (nach Retries) + # Der Fehler wird bereits vom retry_on_failure Decorator auf summarize_batch_openai geloggt. + self.logger.error(f"Endgueltiger FEHLER beim FINALEN OpenAI-Batch-Aufruf fuer Zusammenfassung: {e_openai_batch}") + # Logge den Traceback + self.logger.debug(traceback.format_exc()) + # Fügen Sie Fehlerwerte für alle Zeilen im Batch hinzu + current_version = getattr(Config, 'VERSION', 'unknown') # Block 1 Config Attribut + for row_num in rows_in_current_openai_batch: + error_summary = f"FEHLER OpenAI Batch: {str(e_openai_batch)[:100]}..." # Gekuerzt + # Fuege Updates mit Fehlerwerten fuer AS und AP hinzu + all_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[error_summary]]}) # Block 1 Column Map + all_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Block 1 Column Map + + + # --- Finale Sheet Updates senden --- + # Sende alle verbleibenden gesammelten Updates in einem letzten Batch-Update. + if all_sheet_updates: + rows_in_final_update_batch = len(all_sheet_updates) // 2 # Updates pro Zeile ist 2 + self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({rows_in_final_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") + # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry. + success = self.sheet_handler.batch_update_cells(all_sheet_updates) + if success: + self.logger.info(f"FINALES Sheet-Update erfolgreich.") + # Der Fehlerfall wird von batch_update_cells geloggt + + + # Logge den Abschluss des Modus + self.logger.info(f"Website-Zusammenfassung (Batch) abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {skipped_count} Zeilen uebersprungen.") + # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt. + +# ============================================================================== +# Ende DataProcessor Klasse Batch: Summarization Block +# ============================================================================== + + # ========================================================================== + # === Batch Processing Methods ============================================= + # ========================================================================== + + # --- Interne Hilfsfunktion fuer Branchen-Batch (OpenAI Call) --- + # Diese Funktion laeuft in einem separaten Thread fuer parallele Verarbeitung. + # Sie wird von process_branch_batch (dieser Block) aufgerufen. + # Sie nutzt die globale Funktion evaluate_branche_chatgpt und erhaelt sie als Argument uebergeben. + # Nutzt globale Helfer: evaluate_branche_chatgpt (Block 10), logger, time, threading.Semaphore, traceback. + # Nutzt lokale Semaphore Instanz, die von process_branch_batch uebergeben wird. + def evaluate_branch_task(self, task_data, openai_semaphore): + """ + Führt die Branchenevaluation fuer eine einzelne Zeile aus. + Läuft in einem separaten Thread fuer den Branchen-Batch. + + Args: + task_data (dict): Enthält die Daten fuer die Zeile. + openai_semaphore (threading.Semaphore): Semaphore zur Begrenzung gleichzeitiger OpenAI-Calls. + + Returns: + dict: Ergebnis von evaluate_branche_chatgpt (Block 10) plus row_num und error. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + row_num = task_data['row_num'] + # Initialisiere Ergebnis mit Fehlerwerten, falls der Task fehlschlaegt + result = {"branch": "k.A. (Fehler Task)", "consistency": "error", "justification": "Fehler in Worker-Task"} + error = None + + try: + # Acquire the semaphore before making the OpenAI call (indirekt ueber evaluate_branche_chatgpt) + with openai_semaphore: + # Kleine künstliche Pause reduziert manchmal Race Conditions bei hoher Last oder schnellen APIs + # time.sleep(0.05) # Optional + + # Rufe die globale Funktion zur Branchenevaluation auf (Block 10). + # evaluate_branche_chatgpt nutzt call_openai_chat (Block 8), der den retry_on_failure Decorator (Block 2) nutzt. + # Wenn evaluate_branche_chatgpt eine Exception wirft (nach Retries), wird diese hier gefangen. + 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: + # Wenn evaluate_branche_chatgpt eine Exception wirft (nach Retries) + # Der Fehler wird bereits vom retry_on_failure Decorator oder evaluate_branche_chatgpt geloggt. + error = f"Fehler bei Branchenevaluation Zeile {row_num}: {type(e).__name__} - {e}" + self.logger.error(error) # Logge den Fehler + # Logge den Traceback + self.logger.debug(traceback.format_exc()) + # Stellen Sie sicher, dass das Ergebnis-Dict im Fehlerfall spezifische Fehlerwerte enthaelt + result = {"branch": "FEHLER", "consistency": "error_task", "justification": error[:500]} # Kuerze Begruendung + + # logger.debug(f"Branch Task Zeile {row_num} abgeschlossen.") # Zu viel Laerm im Debug + return {"row_num": row_num, "result": result, "error": error} + + + # --- Methode fuer den Branchen-Batchmodus (AO) --- + # Diese Methode verarbeitet Zeilen, bei denen AO leer ist, um die Branche ueber ChatGPT zu bewerten. + # Sie nutzt einen ThreadPoolExecutor und ruft die interne Worker-Funktion evaluate_branch_task auf. + # Basierend auf process_branch_batch aus Teil 9. + # Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter, evaluate_branch_task (derselbe Block). + # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), datetime, time, + # concurrent.futures, threading.Semaphore, load_target_schema (Block 6), + # ALLOWED_TARGET_BRANCHES (Block 6). + # Nutzt die uebergeordnete sheet_handler Instanz (Block 14). + def process_branch_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None): + """ + Batch-Prozess fuer Brancheneinschaetzung mit paralleler Verarbeitung ueber Threads. + Prueft Timestamp AO, fuehrt evaluate_branche_chatgpt parallel aus (limitiert), + setzt W, X, Y, AO + AP und sendet Sheet-Updates GEBUENDELT PRO VERARBEITUNGS-BATCH. + + Args: + start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AO). + 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 Brancheneinschaetzung (Parallel Batch W-Y, AO, AP). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") + + + # --- Daten laden und Startzeile ermitteln --- + # Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt + if start_sheet_row is None: + self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AO...") + # Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AO (Block 1 Column Map). + # Standardmaessig ab Zeile 7 + start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Timestamp letzte Pruefung", 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.") + 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 AO Zelle): {start_sheet_row}") + else: + # Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein. + # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2). + if not self.sheet_handler.load_data(): + self.logger.error("FEHLER beim Laden der Daten fuer process_branch_batch.") + 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}") + + # Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen) + if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows: + self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") + return # Beende die Methode, wenn der Bereich leer ist + + + # --- Indizes und Buchstaben --- + # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind + required_keys = [ + "Timestamp letzte Pruefung", # AO - Pruefkriterium + "CRM Branche", "CRM Beschreibung", "Wiki Branche", "Wiki Kategorien", # Daten fuer Prompt + "Website Zusammenfassung", "Version", # Weitere Daten fuer Prompt / Update + "Chat Vorschlag Branche", "Chat Konsistenz Branche", "Chat Begruendung Abweichung Branche" # Ergebnisspalten W, X, Y + ] + # 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_branch_batch: {missing}. Breche ab.") + return # Beende die Methode bei kritischem Fehler + + # Ermitteln Sie die Spaltenbuchstaben fuer Updates (W, X, Y, AO, AP) (nutzt interne Helfer _get_col_letter Block 14) + ts_ao_letter = self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Pruefung"] + 1) + version_col_letter = self.sheet_handler._get_col_letter(col_indices["Version"] + 1) + branch_w_letter = self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Branche"] + 1) + branch_x_letter = self.sheet_handler._get_col_letter(col_indices["Chat Konsistenz Branche"] + 1) + branch_y_letter = self.sheet_handler._get_col_letter(col_indices["Chat Begruendung Abweichung Branche"] + 1) + + + # --- Konfiguration fuer Parallelisierung --- + # Holen Sie die Konfiguration aus Config (Block 1) + MAX_BRANCH_WORKERS = getattr(Config, 'MAX_BRANCH_WORKERS', 10) # Threads fuer parallele Verarbeitung + OPENAI_CONCURRENCY_LIMIT = getattr(Config, 'OPENAI_CONCURRENCY_LIMIT', 3) # Max. gleichzeitige OpenAI Calls + openai_semaphore_branch = threading.Semaphore(OPENAI_CONCURRENCY_LIMIT) # Erstellen Sie die Semaphore Instanz + + # Holen Sie die Batch-Groesse fuer Verarbeitung (Threading) aus Config (Block 1) + processing_batch_size = getattr(Config, 'PROCESSING_BRANCH_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) # Wird derzeit nicht verwendet, da wir pro Batch senden + + + # --- Verarbeitung --- + tasks_for_processing_batch = [] # Tasks fuer den aktuellen Batch (Liste von Dicts) + rows_in_current_batch = [] # 1-basierte Zeilennummern im aktuellen Batch + # Sheet Updates werden direkt nach Verarbeitung eines Batch geschrieben, + # keine grosse gesammelte Liste wie bei Scraping/Summarization + + + processed_count = 0 # Zaehlt Zeilen, die fuer die Verarbeitung in Frage kommen und in den Batch aufgenommen werden (im Rahmen des Limits). + skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (wegen AO oder fehlender Daten). + + + # Laden Sie das Zielschema, falls noch nicht geschehen (evaluate_branche_chatgpt benoetigt es Block 10) + # load_target_schema (Block 6) befuellt die globale Variable ALLOWED_TARGET_BRANCHES. + global ALLOWED_TARGET_BRANCHES + if not ALLOWED_TARGET_BRANCHES: + # load_target_schema ist mit retry_on_failure dekoriert (Block 2). + load_target_schema() # Versuche, das Schema zu laden + + # Pruefe erneut, ob das Schema geladen wurde + if not ALLOWED_TARGET_BRANCHES: + self.logger.critical("FEHLER: Ziel-Branchenschema konnte nach Ladeversuch nicht geladen werden. Branchenbewertung nicht moeglich. Breche Batch ab.") + return # Beende die Methode + + + # 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 + 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: Timestamp letzte Pruefung (AO) ist leer. + # ZUSAETZLICH: Pruefen, ob genuegend Quelldaten fuer die Evaluation vorhanden sind. + # Mindestens 2 der folgenden Quellen muessen vorhanden sein: + # CRM Branche ODER Beschreibung ODER Wiki Branche/Kategorien ODER Website Summary. + + # Holen Sie den Wert aus Spalte AO (Timestamp letzte Pruefung) (nutzt interne Helfer _get_cell_value_safe) + ao_value = self._get_cell_value_safe(row, "Timestamp letzte Pruefung").strip() # Block 1 Column Map + # Pruefung basiert darauf, ob AO leer ist. + processing_needed_based_on_status = not ao_value + + + # Wenn der Schritt laut Status nicht noetig ist, ueberspringen + if not processing_needed_based_on_status: + skipped_count += 1 # Zaehlen als uebersprungene Zeile + continue # Springe zur naechsten Zeile + + + # Pruefe, ob ausreichend Daten vorhanden sind (mindestens 2 Quellen) + # Nutzt interne Helfer _get_cell_value_safe + crm_branche = self._get_cell_value_safe(row, "CRM Branche").strip() # Block 1 Column Map + crm_beschreibung = self._get_cell_value_safe(row, "CRM Beschreibung").strip() # Block 1 Column Map + wiki_branche = self._get_cell_value_safe(row, "Wiki Branche").strip() # Block 1 Column Map + wiki_kategorien = self._get_cell_value_safe(row, "Wiki Kategorien").strip() # Block 1 Column Map + website_summary = self._get_cell_value_safe(row, "Website Zusammenfassung").strip() # Block 1 Column Map + + # Pruefe, ob die Werte vorhanden und nicht Standard "k.A." sind. + info_sources_count = sum(1 for val in [crm_branche, crm_beschreibung, wiki_branche, wiki_kategorien, website_summary] + if val and isinstance(val, str) and val.strip() and val.strip().lower() != "k.a." and not val.strip().startswith("FEHLER")) # Schliesse Fehlerwerte aus + + # Wenn nicht genuegend Informationsquellen verfuegbar sind + if info_sources_count < 2: # Mindestens 2 Info-Punkte sollten vorhanden sein (kann angepasst werden) + self.logger.debug(f"Zeile {i} (Branch Check): Uebersprungen (AO leer, aber nur {info_sources_count} Informationsquellen verfuegbar). Mindestens 2 benoetigt.") + skipped_count += 1 # Zaehlen als uebersprungene Zeile + continue # Springe zur naechsten Zeile + + + # --- Wenn Verarbeitung noetig und genuegend Daten da: Fuege zur Batch-Liste hinzu --- + processed_count += 1 # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im Rahmen des Limits zaehlen) + + # Pruefe das Limit fuer verarbeitete Zeilen + if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit: + # Wenn das Limit erreicht ist und es ein positives Limit gibt + self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_branch_batch erreicht. Breche weitere Zeilenpruefung ab.") + break # Brich die Schleife ab + + + # Sammle die benoetigten Daten fuer den Branchen-Task (evaluate_branch_task denselben Block). + # Diese Daten werden in einem Dictionary fuer den Batch gesammelt. + tasks_for_processing_batch.append({ + "row_num": i, # Die 1-basierte Sheet-Zeilennummer + "crm_branche": crm_branche, # Nutzt den Wert aus dem Sheet + "beschreibung": crm_beschreibung, # Nutzt den Wert aus dem Sheet + "wiki_branche": wiki_branche, # Nutzt den Wert aus dem Sheet + "wiki_kategorien": wiki_kategorien, # Nutzt den Wert aus dem Sheet + "website_summary": website_summary # Nutzt den Wert aus dem Sheet + }) + # Fuege die Zeilennummer zur Liste der Zeilennummern im Batch hinzu + rows_in_current_batch.append(i) + + + # --- Verarbeite den Batch, wenn voll --- + # Pruefe, ob die aktuelle Batch-Liste die definierte Groesse erreicht hat. + # processing_batch_size wird aus Config geholt (Block 1). + if len(tasks_for_processing_batch) >= processing_batch_size: + # Logge den Start der Batch-Verarbeitung + batch_start_row = tasks_for_processing_batch[0]['row_num'] + batch_end_row = tasks_for_processing_batch[-1]['row_num'] + self.logger.debug(f"\n--- Starte Branch-Evaluation Batch ({len(tasks_for_processing_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") + + + results_list = [] # Liste zum Speichern der Ergebnisse fuer diesen Batch (Liste von Dicts) + batch_error_count = 0 # Fehlerzaehler fuer diesen spezifischen Batch + + self.logger.debug(f" Evaluiere {len(tasks_for_processing_batch)} Zeilen parallel (max {MAX_BRANCH_WORKERS} worker, {OPENAI_CONCURRENCY_LIMIT} OpenAI gleichzeitig)...") + # Holen Sie die Parallelisierungskonfiguration aus Config (Block 1). + MAX_BRANCH_WORKERS = getattr(Config, 'MAX_BRANCH_WORKERS', 10) + OPENAI_CONCURRENCY_LIMIT = getattr(Config, 'OPENAI_CONCURRENCY_LIMIT', 3) + # Erstellen Sie die Semaphore Instanz (wird von evaluate_branch_task benutzt). + # threading.Semaphore muss hier innerhalb des Batch-Aufrufs erstellt werden. + openai_semaphore_branch = threading.Semaphore(OPENAI_CONCURRENCY_LIMIT) + + + # *** BEGINN PARALLELE VERARBEITUNG MIT THREADS *** + # Verwende ThreadPoolExecutor fuer parallele Ausfuehrung der evaluate_branch_task. + with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_BRANCH_WORKERS) as executor: + # Map tasks to futures. Ruft die INTERNE Worker-Funktion auf. + # Uebergibt das task_data Dictionary und die Semaphore Instanz als Argumente. + future_to_task = {executor.submit(self.evaluate_branch_task, task, openai_semaphore_branch): task for task in tasks_for_processing_batch} + + + # Verarbeite die Ergebnisse, sobald sie fertig sind. + for future in concurrent.futures.as_completed(future_to_task): + task = future_to_task[future] # Holen Sie die urspruenglichen Task-Daten (Dict) + try: + # Holen Sie das Ergebnis vom Future. Wenn die Worker-Funktion eine Exception wirft, wird diese hier gefangen. + result_data = future.result() # Ergebnis ist ein Dictionary {'row_num': ..., 'result': ..., 'error': ...} + results_list.append(result_data) # Fuege das Ergebnis zur Liste hinzu + # Pruefe, ob der Worker einen Fehler gemeldet hat (error Feld im Ergebnis) + if result_data.get('error'): + batch_error_count += 1 # Erhoehe den Fehlerzaehler fuer diesen Batch + + except Exception as exc: + # Dieser Block faengt unerwartete Fehler ab, die waehrend der Future-Ergebnis-Abfrage auftreten. + # Die meisten Fehler sollten von evaluate_branch_task oder seinen Helfern behandelt werden. + row_num = task['row_num'] # Zeilennummer aus den Task-Daten + err_msg = f"Unerwarteter Fehler bei Ergebnisabfrage Branch Task Zeile {row_num}: {type(exc).__name__} - {exc}" + logger.error(err_msg) # Logge den Fehler + # Setze einen Standard-Fehler-Ergebniswert fuer diese Zeile + results_list.append({"row_num": row_num, "result": {"branch": "FEHLER", "consistency": "error_task", "justification": err_msg[:500]}, "error": err_msg}) # Kuerze Begruendung + batch_error_count += 1 # Erhoehe den Fehlerzaehler + + + # *** ENDE PARALLELE VERARBEITUNG *** + self.logger.debug(f" Branch-Evaluation fuer Batch beendet. {len(results_list)} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).") + + + # Sheet Updates vorbereiten FÜR DIESEN BATCH. + # Dies geschieht jetzt nach der parallelen Verarbeitung. + if results_list: + # Aktueller Zeitstempel und Version fuer die Updates + current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + current_version = getattr(Config, 'VERSION', 'unknown') # Block 1 Config Attribut + batch_sheet_updates = [] # Updates fuer DIESEN spezifischen Batch von Zeilen + + # Sortiere Ergebnisse nach Zeilennummer fuer geordnetes Schreiben (optional, aber gut) + results_list.sort(key=lambda x: x['row_num']) + + # Iteriere ueber die Ergebnisse dieses Batches + for res_data in results_list: + row_num = res_data['row_num'] # 1-basierte Zeilennummer + result = res_data['result'] # Das Ergebnis-Dictionary von evaluate_branch_task + + # Logge das individuelle Ergebnis VOR dem Update + # self.logger.debug(f" Zeile {row_num}: Ergebnis -> Branch='{result.get('branch')}', Consistency='{result.get('consistency')}', Justification='{result.get('justification', '')[:50]}...'") # Zu viel Laerm (gekuerzt) + + # Sammle Updates fuer W, X, Y, AO, AP (nutzt interne Helfer _get_col_letter Block 14) + # Stellen Sie sicher, dass die Schluessel im Ergebnis-Dict vorhanden sind, Fallback auf Standard-Fehlerwerte. + batch_sheet_updates.append({'range': f'{branch_w_letter}{row_num}', 'values': [[result.get("branch", "FEHLER")]]}) # Block 1 Column Map + batch_sheet_updates.append({'range': f'{branch_x_letter}{row_num}', 'values': [[result.get("consistency", "error")]]}) # Block 1 Column Map + batch_sheet_updates.append({'range': f'{branch_y_letter}{row_num}', 'values': [[result.get("justification", "Keine Begruendung")]]}) # Block 1 Column Map + # Setze AO Timestamp fuer diese Zeile + batch_sheet_updates.append({'range': f'{ts_ao_letter}{row_num}', 'values': [[current_timestamp]]}) # Block 1 Column Map + # Setze AP Version fuer diese Zeile + batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Block 1 Column Map + + + # --- Sende Updates fuer DIESEN BATCH SOFORT --- + # Sende die gesammelten Updates fuer diesen Batch. + if batch_sheet_updates: + self.logger.debug(f" Sende Sheet-Update fuer {len(results_list)} Zeilen ({len(batch_sheet_updates)} Zellen) dieses Batches...") + # 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 Batch Zeilen {batch_start_row}-{batch_end_row} erfolgreich.") + # Der Fehlerfall wird von batch_update_cells geloggt + + # else: self.logger.debug(f" Keine Sheet-Updates fuer Batch Zeilen {batch_start_row}-{batch_end_row} vorbereitet.") # Zu viel Laerm im Debug + + + # Leere den Batch fuer die naechste Iteration + tasks_for_processing_batch = [] + rows_in_current_batch = [] + + # Pause NACHDEM ein Batch komplett verarbeitet und geschrieben wurde (nutzt Config Block 1). + # Dies ist wichtig, um Rate Limits und Serverlast zu managen. + pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.8 # Längere Pause, z.B. 80% der Retry-Wartezeit + self.logger.debug(f"--- Batch Zeilen {batch_start_row}-{batch_end_row} abgeschlossen. Warte {pause_duration:.2f}s vor naechstem Batch ---") + time.sleep(pause_duration) + + + # --- Verarbeitung des letzten unvollstaendigen Batches nach der Schleife --- + # Wenn nach der Hauptschleife noch Tasks in der Batch-Liste sind. + if tasks_for_processing_batch: + # Logge den Start des finalen Batches + batch_start_row = tasks_for_processing_batch[0]['row_num'] + batch_end_row = tasks_for_processing_batch[-1]['row_num'] + self.logger.debug(f"\n--- Starte FINALEN Branch-Evaluation Batch ({len(tasks_for_processing_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") + + + results_list = [] # Liste zum Speichern der Ergebnisse fuer diesen finalen Batch + batch_error_count = 0 # Fehlerzaehler + + self.logger.debug(f" Evaluiere {len(tasks_for_processing_batch)} Zeilen parallel (max {MAX_BRANCH_WORKERS} worker, {OPENAI_CONCURRENCY_LIMIT} OpenAI gleichzeitig)...") + # Erstellen Sie die Semaphore Instanz fuer den finalen Batch. + openai_semaphore_branch = threading.Semaphore(OPENAI_CONCURRENCY_LIMIT) + + with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_BRANCH_WORKERS) as executor: + # Map tasks to futures. Ruft die INTERNE Worker-Funktion auf und uebergibt die Semaphore. + future_to_task = {executor.submit(self.evaluate_branch_task, task, openai_semaphore_branch): task for task in tasks_for_processing_batch} + + # Verarbeite die Ergebnisse + for future in concurrent.futures.as_completed(future_to_task): + task = future_to_task[future] # Holen Sie die urspruenglichen Task-Daten + try: + result_data = future.result() # Holen Sie das Ergebnis + results_list.append(result_data) # Fuege das Ergebnis zur Liste hinzu + # Pruefe, ob der Worker einen Fehler gemeldet hat + if result_data.get('error'): batch_error_count += 1 + except Exception as exc: + # Faengt unerwartete Fehler bei der Ergebnisabfrage ab + row_num = task['row_num'] + err_msg = f"Unerwarteter Fehler bei Ergebnisabfrage Branch Task Zeile {row_num}: {type(exc).__name__} - {exc}" + logger.error(err_msg) # Logge den Fehler + # Setze einen Standard-Fehler-Ergebniswert + results_list.append({"row_num": row_num, "result": {"branch": "FEHLER", "consistency": "error_task", "justification": err_msg[:500]}, "error": err_msg}) # Kuerze Begruendung + batch_error_count += 1 + + + self.logger.debug(f" FINALER Branch Batch beendet. {len(results_list)} Ergebnisse erhalten ({batch_error_count} Fehler).") + + + # Sammle Sheet Updates (W, X, Y, AO, AP) fuer diesen finalen Batch. + if results_list: + current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + current_version = getattr(Config, 'VERSION', 'unknown') # Block 1 Config Attribut + batch_sheet_updates = [] # Updates fuer diesen spezifischen Batch + results_list.sort(key=lambda x: x['row_num']) # Sortiere Ergebnisse nach Zeilennummer + for res_data in results_list: + row_num = res_data['row_num'] + result = res_data['result'] # Ergebnis-Dictionary + # Fuege Updates fuer W, X, Y, AO, AP hinzu (nutzt interne Helfer) + batch_sheet_updates.append({'range': f'{branch_w_letter}{row_num}', 'values': [[result.get("branch", "FEHLER")]]}) # Block 1 Column Map + batch_sheet_updates.append({'range': f'{branch_x_letter}{row_num}', 'values': [[result.get("consistency", "error")]]}) # Block 1 Column Map + batch_sheet_updates.append({'range': f'{branch_y_letter}{row_num}', 'values': [[result.get("justification", "Keine Begruendung")]]}) # Block 1 Column Map + batch_sheet_updates.append({'range': f'{ts_ao_letter}{row_num}', 'values': [[current_timestamp]]}) # Block 1 Column Map + batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Block 1 Column Map + # Fuege diese Updates zur globalen Liste hinzu (wird dann nur noch einmal gesendet) + # all_sheet_updates.extend(batch_sheet_updates) # Nicht hier sammeln, direkt senden + + # Sende die gesammelten Updates fuer DIESEN finalen Batch. + if batch_sheet_updates: + self.logger.debug(f" Sende FINALES Sheet-Update fuer {len(results_list)} Zeilen ({len(batch_sheet_updates)} Zellen)...") + # 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 Branch Batch erfolgreich.") + # Der Fehlerfall wird von batch_update_cells geloggt + + + # Logge den Abschluss des Modus + self.logger.info(f"Brancheneinschaetzung (Parallel Batch) abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {skipped_count} Zeilen uebersprungen.") + # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt. + + +# ============================================================================== +# Ende DataProcessor Klasse Batch: Branch Evaluation Block +# ============================================================================== + + # ========================================================================== + # === Batch Processing Methods ============================================= + # ========================================================================== + + # --- Methode fuer den SerpAPI Wiki Search Batchmodus (AY) --- + # Diese Methode sucht fehlende Wiki-URLs ueber SerpAPI. + # Basierend auf process_find_wiki_with_serp aus Teil 2. + # Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter. + # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), datetime, time, + # get_numeric_filter_value (Block 5), serp_wikipedia_lookup (Block 10). + # Nutzt die uebergeordnete sheet_handler Instanz (Block 14). + 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 (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'}...") + + + # --- 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...") + # 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.") + 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}") + 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.") + 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}") + + # Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen) + if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows: + self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") + return # Beende die Methode, wenn der Bereich leer ist + + + # --- Indizes und Buchstaben --- + # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind + required_keys = [ + "SerpAPI Wiki Search Timestamp", "Wiki URL", "CRM Umsatz", "CRM Anzahl Mitarbeiter", # AY, M, J, K (Pruefkriterien / Timestamp) + "ReEval Flag", "CRM Name", "CRM Website", # A, B, D (Daten fuer Suche / Updates) + "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", # N-R (Spalten zum Leeren) + "Chat Wiki Konsistenzpruefung", "Chat Begruendung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", # S-U (Spalten zum Leeren) + "Begruendung bei Abweichung", "Wikipedia Timestamp", "Timestamp letzte Pruefung", # V, AN, AO (Spalten zum Leeren) + "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.") + 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 + empty_nv_values = [''] * (v_idx - n_idx + 1) # Anzahl der Spalten = V_Index - N_Index + 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) + + + all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts) + + + processed_count = 0 # Zaehlt Zeilen, fuer die SerpAPI versucht wurde (im Rahmen des Limits zaehlen). + skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (verschiedene Gruende). + found_urls_count = 0 # Zaehlt Zeilen, wo eine URL gefunden und eingetragen wurde. + + + # 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}") # Gekuerzt loggen + + + # Wenn die Verarbeitung fuer diese Zeile nicht noetig ist + if not processing_needed_for_row: + skipped_count += 1 # Zaehlen als uebersprungene Zeile + continue # Springe zur naechsten Zeile + + + # --- Wenn Verarbeitung noetig: Fuehre SerpAPI Suche aus --- + processed_count += 1 # Zaehle die Zeile, fuer die SerpAPI versucht wird (im Rahmen des Limits zaehlen) + + # Pruefe das Limit fuer verarbeitete Zeilen + if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit: + # Wenn das Limit erreicht ist und es ein positives Limit gibt + self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_find_wiki_serp erreicht. Breche weitere Zeilenpruefung ab.") + 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 + website_url = self._get_cell_value_safe(row, "CRM Website").strip() # Block 1 Column Map (Website kann fuer SerpAPI Kontext hilfreich sein) + + # 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).") + skipped_count += 1 # Zaehlen als uebersprungene Zeile, da Suche nicht moeglich + # Setze AY Timestamp auch hier, um nicht immer wieder zu versuchen + updates.append({'range': f'{ts_ay_letter}{i}', 'values': [[now_timestamp_str]]}) # Block 1 Column Map + all_sheet_updates.extend(updates) # Fuege dieses einzelne Update zur Liste hinzu + 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...") # Gekuerzt loggen + + + # 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}") # Gekuerzt loggen + # 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", "fehler bei suche", "http:"]: # Fuege "http:" hinzu + self.logger.info(f" -> URL gefunden: {wiki_url_found[:100]}... Bereite Update vor (Setze M, A; Loesche N-V, AN, AO, AP, AX).") # Gekuerzt loggen + 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.") + + + # Leere Timestamps AN, AO, AX, und Version AP. + # Dies setzt die Zeile zurueck, damit andere Schritte sie spaeter bearbeiten. + 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': [['']]}) # Block 1 Column Map + + + else: + # Wenn keine Wiki-URL ueber SerpAPI gefunden wurde + self.logger.debug(f" -> Keine Wiki-URL fuer '{company_name[:100]}...' ueber SerpAPI gefunden.") # Gekuerzt loggen + # 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)...") + # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry. + # Wenn es fehlschlaegt, wird es intern geloggt. + success = self.sheet_handler.batch_update_cells(all_sheet_updates) + if success: + self.logger.info(f" Sheet-Update fuer {len(all_sheet_updates)} Zellen erfolgreich.") + # Der Fehlerfall wird von batch_update_cells geloggt + + # Leere die gesammelten Updates nach dem Senden. + all_sheet_updates = [] + + # Kleine Pause nach jeder 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)...") + # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry. + success = self.sheet_handler.batch_update_cells(all_sheet_updates) + if success: + self.logger.info(f"FINALES Sheet-Update erfolgreich.") + # Der Fehlerfall wird von batch_update_cells geloggt + + + # Logge den Abschluss des Modus + self.logger.info(f"Modus 'find_wiki_serp' abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {found_urls_count} URLs gefunden & eingetragen, {skipped_count} Zeilen uebersprungen.") + # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt. + + + # --- Methode fuer den Contact Search Batchmodus (AM, AI-AL) --- + # Diese Methode sucht LinkedIn Kontakte ueber SerpAPI und traegt Trefferzahlen ins Sheet ein. + # Basierend auf process_contact_research aus Teil 10. + # Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter. + # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), datetime, time, + # search_linkedin_contacts (Block 10), get_gender (Block 5), get_email_address (Block 5). + # Nutzt die uebergeordnete sheet_handler Instanz (Block 14). + def process_contact_search(self, start_sheet_row=None, end_sheet_row=None, limit=None): + """ + 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'}...") + + + # --- 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...") + # 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.") + 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}") + 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.") + 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}") + + # Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen) + if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows: + self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") + return # Beende die Methode, wenn der Bereich leer ist + + + # --- Indizes und Buchstaben --- + # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind + required_keys = [ + "Contact Search Timestamp", # AM - Pruefkriterium / Timestamp + "CRM Name", "CRM Kurzform", "CRM Website", # B, C, D (Daten fuer Suche) + "Linked Serviceleiter gefunden", "Linked It-Leiter gefunden", # AI, AJ (Zielspalten fuer Trefferzahlen) + "Linked Management gefunden", "Linked Disponent gefunden" # AK, AL (Zielspalten fuer Trefferzahlen) + ] + # 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.") + 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": ["Geschäftsführer", "Vorstand", "Inhaber", "CEO", "CTO", "COO", "Kaufmännischer Leiter", "Technischer Leiter"], # Management erweitert + "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.") + except gspread.exceptions.WorksheetNotFound: + # Wenn nicht gefunden, erstelle es. + 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") + 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.") + # Logge den Traceback. + self.logger.debug(traceback.format_exc()) + contacts_sheet = None # Setze contacts_sheet auf None, um spaetere Schreibversuche zu verhindern + + 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.") + contacts_sheet = None # Sicherstellen, dass contacts_sheet None ist + + + # --- Verarbeitung --- + all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Hauptblatt (Liste von Dicts) + all_contact_rows_to_append = [] # Gesammelte Zeilen fuer append_rows ins Contacts-Blatt (Liste von Listen) + # 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. + + + processed_count = 0 # Zaehlt Zeilen im Hauptblatt, die fuer die Verarbeitung in Frage kommen und in den Batch aufgenommen werden (im Rahmen des Limits). + skipped_count = 0 # Zaehlt Zeilen im Hauptblatt, die uebersprungen wurden (wegen AM oder fehlender Daten). + + + # 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: + company_name_log = company_name[:50] + '...' if len(company_name) > 50 else company_name # Gekuerzt loggen + 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}") + + + # 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 --- + processed_count += 1 # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im Rahmen des Limits zaehlen) + + # Pruefe das Limit fuer verarbeitete Zeilen + if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit: + # Wenn das Limit erreicht ist und es ein positives Limit gibt + self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_contact_search erreicht. Breche weitere Zeilenpruefung ab.") + break # Brich die Schleife ab + + + self.logger.info(f"Zeile {i}: Suche LinkedIn Kontakte fuer '{crm_kurzform[:50]}...' ({website[:50]}...)...") # Gekuerzt loggen + + + all_found_contacts_for_row = [] # Liste zum Sammeln aller gefundenen Kontakte fuer DIESE Zeile (Liste von Dicts) + contact_counts_for_row = {key: 0 for key in positions_to_search.keys()} # Dictionary zum Zaehlen der Treffer pro Kategorie fuer diese Zeile (AI-AL) + + + # 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. + found_contacts_in_category = {} # Dictionary zum Sammeln eindeutiger Kontakte {linkedin_url: contact_data} fuer diese Kategorie + + for position_query in queries: + self.logger.debug(f" -> Suche nach Position: '{position_query}' bei '{crm_kurzform[:50]}'...") # Gekuerzt loggen + 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. + contact["Suchbegriffskategorie"] = category # Speichere die Kategorie, die den Treffer brachte + 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}") # Gekuerzt loggen + 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 --- + rows_to_append_to_contacts_sheet = [] # Liste von Listen fuer append_rows ins 'Contacts' Blatt + main_sheet_updates_for_row = [] # Updates fuer das Hauptblatt (AI-AL, AM) fuer DIESE Zeile + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Timestamp fuer DIESE Zeile/Kontakte + + + # 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.") + + + # 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). + if contacts_sheet: # Pruefen Sie, ob das Contacts-Sheet geoeffnet/erstellt werden konnte (siehe Initialisierung oben) + # 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: + firstname = contact.get("Vorname", "") # Nutzt den extrahierten Vornamen + lastname = contact.get("Nachname", "") # Nutzt den extrahierten Nachnamen + + # 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) + email = get_email_address(firstname, lastname, website) # Nutzt die Website der Firma (initial geladen) + + # 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 + contact.get("Position", ""), # Extrahierte oder Fallback Position + contact.get("Suchbegriffskategorie", ""), # Kategorie, die den Treffer brachte + email, # Generierte E-Mail-Adresse + contact.get("LinkedInURL", ""), # URL des LinkedIn Profils + 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.") + else: + self.logger.debug(f" -> Keine neuen Kontakte fuer Zeile {i} gefunden.") + + + # 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)...") + # 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.") + # 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). + pause_duration = getattr(Config, 'RETRY_DELAY', 10) * 0.8 # Laengere Pause, z.B. 80% der Retry-Wartezeit + self.logger.debug(f"Warte {pause_duration:.2f}s nach Verarbeitung von Zeile {i}...") + 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)...") + # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry. + success = self.sheet_handler.batch_update_cells(all_sheet_updates) + if success: + self.logger.info(f"FINALES 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...") + 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.") + 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}") + # Logge den Traceback. + self.logger.debug(traceback.format_exc()) + 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.") + # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt. + +# ============================================================================== +# Ende DataProcessor Klasse Batch: SerpAPI Suchen & Contacts Block +# ============================================================================== + + # ========================================================================== + # === Utility Methods (ML Data Prep & Training) ============================ + # ========================================================================== + + # --- Methode fuer ML Vorhersage (AU) --- + # Diese Methode wird in _process_single_row (Block 21) aufgerufen, wenn der ML-Schritt angefordert ist und noetig ist. + # Sie fuehrt eine Vorhersage des Servicetechniker-Buckets fuer eine einzelne Zeile mit dem trainierten ML-Modell durch. + # Sie nutzt das geladene Modell und den Imputer (Attribute der DataProcessor Instanz). + # Nutzt interne Helfer: _get_cell_value_safe, _load_ml_model. + # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, pandas, numpy, re, clean_text (Block 4), get_valid_numeric (Block 5). + def _predict_technician_bucket(self, row_data): + """ + Fuehrt eine Vorhersage des Servicetechniker-Buckets fuer eine einzelne Zeile + mit dem trainierten ML-Modell durch. Laedt das Modell und den Imputer bei Bedarf. + + Args: + row_data (list): Die Rohdaten fuer die Zeile. + + Returns: + str: Der vorhergesagte Bucket-Label oder "FEHLER Schaetzung" bei Fehler/kein Ergebnis. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Logge den Start der ML-Schaetzung fuer diese Zeile + company_name = self._get_cell_value_safe(row_data, 'CRM Name').strip() # Block 1 Column Map + self.logger.debug(f"Versuche ML-Schaetzung fuer Zeile ({company_name[:50]}...)") # Gekuerzt loggen + + # Laden Sie das Modell, den Imputer und die erwarteten Feature-Spalten, falls noch nicht geschehen. + # Diese werden als Attribute der DataProcessor Instanz gespeichert (_load_ml_model Block 31). + if self.model is None or self.imputer is None or self._expected_features is None: + self.logger.info("Lade ML-Modell, Imputer und Feature-Spalten...") + try: + # Der Aufruf von _load_ml_model (denselben Block) ist nicht mit retry_on_failure dekoriert, + # da das Laden lokaler Dateien nicht wiederholt werden muss. Fehler deuten auf ein permanentes Problem hin. + self._load_ml_model(MODEL_FILE, IMPUTER_FILE) # Nutzt globale Konstanten (Block 1) + + # Pruefe erneut, ob das Laden erfolgreich war. + if self.model is None or self.imputer is None or self._expected_features is None: + self.logger.error("Laden von Modell, Imputer oder Feature-Spalten fehlgeschlagen. Kann ML-Schaetzung nicht durchfuehren.") + return "FEHLER Schaetzung" # Gebe Fehlerwert zurueck, wenn Laden fehlschlug + + self.logger.info("ML-Modell, Imputer und Feature-Spalten erfolgreich geladen.") + + except Exception as e: + # Fange Fehler beim Laden ab und logge sie. + self.logger.error(f"FEHLER beim Laden von ML-Modell/Imputer/Feature-Spalten: {e}") + # Logge den Traceback. + self.logger.debug(traceback.format_exc()) + # Geben Sie einen Fehlerwert zurueck. + return f"FEHLER Laden: {str(e)[:100]}..." # Signalisiert Ladefehler (gekuerzt) + + + # --- Bereiten Sie die Daten fuer DIESE EINE ZEILE fuer die Vorhersage vor --- + try: + # Diese Logik ist aehnlich wie in prepare_data_for_modeling (Block 31), + # aber nur fuer eine einzelne Zeile und muss mit den exakt gleichen + # Spaltennamen, Normalisierungs- und Encoding-Schritten arbeiten wie das Training. + + # Hole die benoetigten Spaltenwerte fuer diese Zeile (basierend auf COLUMN_MAP keys Block 1) + row_values = { + # "CRM Name": self._get_cell_value_safe(row_data, "CRM Name"), # Nicht benoetigt fuer Vorhersage + "CRM Branche": self._get_cell_value_safe(row_data, "CRM Branche"), # Block 1 Column Map + "CRM Umsatz": self._get_cell_value_safe(row_data, "CRM Umsatz"), # Block 1 Column Map + "Wiki Umsatz": self._get_cell_value_safe(row_data, "Wiki Umsatz"), # Block 1 Column Map + "CRM Anzahl Mitarbeiter": self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter"), # Block 1 Column Map + "Wiki Mitarbeiter": self._get_cell_value_safe(row_data, "Wiki Mitarbeiter"), # Block 1 Column Map + # Technikerzahl wird fuer die Vorhersage NICHT benoetigt + # "CRM Anzahl Techniker": self._get_cell_value_safe(row_data, "CRM Anzahl Techniker"), + } + + # Erstellen Sie einen temporaeren DataFrame fuer diese eine Zeile aus den extrahierten Werten + df_single_row = pd.DataFrame([row_values]) + + + # --- Konsolidieren Umsatz/Mitarbeiter (Wiki > CRM) --- + # Nutzt globale Funktion get_valid_numeric (Block 5) fuer die Konvertierung. + # Diese Funktion gibt numerische Werte (Float/Int) oder NaN zurueck. + # Stellen Sie sicher, dass die Spalten existieren, bevor apply aufgerufen wird. + # Diese Spalten sollten aus row_values extrahiert worden sein, wenn COLUMN_MAP korrekt ist. + crm_umsatz_series = df_single_row['CRM Umsatz'].apply(get_valid_numeric) if 'CRM Umsatz' in df_single_row.columns else pd.Series(np.nan, index=df_single_row.index) + wiki_umsatz_series = df_single_row['Wiki Umsatz'].apply(get_valid_numeric) if 'Wiki Umsatz' in df_single_row.columns else pd.Series(np.nan, index=df_single_row.index) + crm_ma_series = df_single_row['CRM Anzahl Mitarbeiter'].apply(get_valid_numeric) if 'CRM Anzahl Mitarbeiter' in df_single_row.columns else pd.Series(np.nan, index=df_single_row.index) + wiki_ma_series = df_single_row['Wiki Mitarbeiter'].apply(get_valid_numeric).astype(float) if 'Wiki Mitarbeiter' in df_single_row.columns else pd.Series(np.nan, index=df_single_row.index) # Muss Float sein wie andere numerische + + # np.where waehlt den Wiki-Wert, wenn nicht NaN, sonst den CRM-Wert. + df_single_row['Finaler_Umsatz'] = np.where( + wiki_umsatz_series.notna(), + wiki_umsatz_series, + crm_umsatz_series + ) + + df_single_row['Finaler_Mitarbeiter'] = np.where( + wiki_ma_series.notna(), + wiki_ma_series, + crm_ma_series + ) + + # Pruefen Sie, ob die konsolidierten numerischen Features NaN sind. + # ML-Vorhersage kann nicht durchgefuehrt werden, wenn diese komplett fehlen (werden vom Imputer erwartet). + if pd.isna(df_single_row['Finaler_Umsatz'].iloc[0]) and pd.isna(df_single_row['Finaler_Mitarbeiter'].iloc[0]): + self.logger.debug(f" -> ML-Schaetzung uebersprungen: Konsolidierter Umsatz und Mitarbeiter fehlen fuer Zeile.") + return "k.A. (Daten fehlen)" # Gebe spezifischen Wert zurueck + + + # --- Kategoriale Features (Branche) --- + branche_col_name = "CRM Branche" # Original Header Name aus COLUMN_MAP (Block 1) + # Stellen Sie sicher, dass die Spalte existiert und ein String ist. Fuellen Sie NaNs mit 'Unbekannt'. + if branche_col_name not in df_single_row.columns: + self.logger.warning(f"Spalte '{branche_col_name}' nicht im DataFrame fuer ML-Vorhersage gefunden. Behandle als 'Unbekannt'.") + df_single_row[branche_col_name] = 'Unbekannt' # Setze einen Default-Wert + + df_single_row[branche_col_name] = df_single_row[branche_col_name].astype(str).fillna('Unbekannt').str.strip() + + + # One-Hot Encoding + # WICHTIG: Muss alle BRANCHEN aus dem TRAININGSDATENSATZ (self._expected_features) enthalten, + # auch wenn diese in der einzelnen Zeile nicht vorkommen. + # pd.get_dummies erstellt Spalten nur fuer die Kategorien in df_single_row. + df_encoded = pd.get_dummies(df_single_row, columns=[branche_col_name], prefix='Branche', dummy_na=False) # dummy_na=False, da NaNs gefuellt + + + # Fugen Sie fehlende Feature-Spalten hinzu (die im Training vorhanden waren, aber in dieser Zeile nicht). + # Stellen Sie die Reihenfolge der Spalten sicher, so wie sie im Training waren (self._expected_features). + # self._expected_features wird von _load_ml_model (denselben Block) geladen. + if self._expected_features is None: + self.logger.error("FEHLER: Erwartete Feature-Spalten fuer ML-Vorhersage nicht geladen. Kann nicht vorhersagen.") + return "FEHLER Schaetzung" # Gebe Fehlerwert zurueck + + # Erstellen Sie einen neuen DataFrame mit allen erwarteten Features und fuellen Sie fehlende mit 0. + # Sicherstellen, dass die Spalten im Ergebnis-DF in der Reihenfolge von self._expected_features sind. + df_processed = pd.DataFrame(columns=self._expected_features) + # Kopieren Sie die Werte aus df_encoded, wo Spalten uebereinstimmen. + for col in self._expected_features: + if col in df_encoded.columns: + df_processed[col] = df_encoded[col] + else: + df_processed[col] = 0 # Fuege fehlende Spalten mit 0 hinzu + + # Stellen Sie sicher, dass die numerischen Spalten Float sind (Imputer erwartet das oft) + numeric_features_for_imputation = ['Finaler_Umsatz', 'Finaler_Mitarbeiter'] + for col in numeric_features_for_imputation: + if col in df_processed.columns: + df_processed[col] = pd.to_numeric(df_processed[col], errors='coerce') # Wandelt NaN in NaN, Fehler in NaN + + + # --- Imputation der fehlenden Werte --- + # Muss konsistent mit dem Imputer aus dem Training sein. + # Der Imputer (self.imputer) wird auf die vorbereiteten Features angewendet. + if self.imputer is None: + self.logger.error("FEHLER: ML-Imputer ist nicht geladen. Kann nicht imputieren/vorhersagen.") + return "FEHLER Schaetzung" # Gebe Fehlerwert zurueck + + # Imputer.transform gibt ein Numpy Array zurueck. + df_imputed_array = self.imputer.transform(df_processed) + # Konvertiere das Ergebnis zurueck zu einem DataFrame mit den erwarteten Spaltennamen. + df_imputed = pd.DataFrame(df_imputed_array, columns=self._expected_features) + + # Optional: Pruefen Sie, ob nach Imputation NaNs verbleiben (sollte nicht passieren bei SimpleImputer) + # if df_imputed.isna().any().any(): + # self.logger.warning("WARNUNG: NaNs verbleiben nach Imputation.") + + + # --- Vorhersage --- + # Das Decision Tree Modell (self.model) erwartet die vorbereiteten und imputierten Features. + if not self.model: + self.logger.error("FEHLER: ML-Modell ist nicht geladen. Kann nicht vorhersagen.") + return "FEHLER Schaetzung" # Gebe Fehlerwert zurueck + + + # Fuehren Sie die Vorhersage durch. + # predict_proba gibt die Wahrscheinlichkeiten fuer jede Klasse zurueck. + prediction_proba = self.model.predict_proba(df_imputed) + # prediction_proba ist ein Array von Wahrscheinlichkeiten pro Klasse fuer jede Eingabezeile (hier nur 1 Zeile). + + # Die Klassen-Labels des Modells (z.B. ['Bucket_1', 'Bucket_2', ...]) + model_classes = self.model.classes_ + + # Finden Sie den Index der Klasse mit der hoechsten Wahrscheinlichkeit fuer die erste (und einzige) Zeile. + predicted_class_index = np.argmax(prediction_proba[0]) + # Holen Sie das entsprechende Label aus den Modell-Klassen. + predicted_bucket_label = model_classes[predicted_class_index] + + # Logge die Vorhersage auf Debug-Level + self.logger.debug(f" -> ML Vorhersage Ergebnis: '{predicted_bucket_label}' (Wahrscheinlichkeiten: {prediction_proba[0]})") + + + return predicted_bucket_label # Gebe das vorhergesagte Bucket-Label zurueck (String) + + except Exception as e: + # Fange alle unerwarteten Fehler ab, die waehrend der Datenvorbereitung oder Vorhersage auftreten. + self.logger.exception(f"FEHLER bei der Datenvorbereitung/Vorhersage fuer Zeile (ML): {e}") # Logge Fehler und Traceback + # Geben Sie einen Fehlerwert zurueck, der im Sheet gespeichert werden kann. + return f"FEHLER Schaetzung: {str(e)[:100]}..." # Signalisiert Fehler bei der Schaetzung (gekuerzt) + + + # --- Methode zum Laden des ML Modells und Imputers --- + # Diese Methode wird von _predict_technician_bucket (denselben Block) und train_technician_model (denselben Block) aufgerufen. + # Sie laedt die serialisierten Modelle von der Festplatte. + # Nutzt globale Helfer: MODEL_FILE (Block 1), IMPUTER_FILE (Block 1), PATTERNS_FILE_JSON (Block 1), + # logger, os, pickle, json. + def _load_ml_model(self, model_path, imputer_path): + """ + Laedt das trainierte ML-Modell, den Imputer und die erwarteten Feature-Spalten + von den definierten Dateipfaden. Speichert sie als Instanzattribute. + + Args: + model_path (str): Dateipfad zum Modell (.pkl). + imputer_path (str): Dateipfad zum Imputer (.pkl). + # Der Pfad zur Feature-Spaltenliste (JSON) wird aus PATTERNS_FILE_JSON (Block 1) geholt. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Setzen Sie die Instanzattribute zunaechst auf None + self.model = None + self.imputer = None + self._expected_features = None # Liste der erwarteten Feature-Spalten fuer Vorhersage + + try: + # Pruefen Sie, ob die Modelldateien existieren + if not os.path.exists(model_path): + self.logger.error(f"ML-Modell Datei nicht gefunden: {model_path}") + return # Beende die Methode, wenn die Datei fehlt + if not os.path.exists(imputer_path): + self.logger.error(f"Imputer Datei nicht gefunden: {imputer_path}") + return # Beende die Methode, wenn die Datei fehlt + + # Laden Sie das serialisierte Modell + with open(model_path, 'rb') as f: + self.model = pickle.load(f) + self.logger.info(f"ML-Modell '{model_path}' erfolgreich geladen.") + # Loggen Sie die Klassen-Labels des geladenen Modells zur Info + if hasattr(self.model, 'classes_'): + self.logger.debug(f"Geladene Modell-Klassen: {self.model.classes_}") + else: + self.logger.debug("Geladenes Modell hat kein 'classes_' Attribut.") + + + # Laden Sie den serialisierten Imputer + with open(imputer_path, 'rb') as f: + self.imputer = pickle.load(f) + self.logger.info(f"Imputer '{imputer_path}' erfolgreich geladen.") + + + # Laden Sie die Liste der erwarteten Feature-Spalten (JSON-Datei wird empfohlen) + expected_features_path = PATTERNS_FILE_JSON # Nutzt globale Konstante (Block 1) + # Pruefen Sie, ob die Feature-Spalten-Datei existiert + if os.path.exists(expected_features_path): + try: + # Oeffnen Sie die JSON-Datei + with open(expected_features_path, 'r', encoding='utf-8') as f: + # Laden Sie die Daten aus der JSON-Datei + data = json.load(f) + # Annahme: Die JSON-Datei enthaelt eine Liste der Feature-Spalten unter dem Schluessel "feature_columns" + self._expected_features = data.get("feature_columns") + # Pruefen Sie, ob die geladenen Daten eine nicht-leere Liste sind. + if self._expected_features and isinstance(self._expected_features, list): + self.logger.info(f"Erwartete Feature-Spalten ({len(self._expected_features)}) aus '{expected_features_path}' geladen.") + # Loggen Sie die ersten paar erwarteten Features auf Debug + # self.logger.debug(f"Erwartete Features (erste 5): {self._expected_features[:5]}...") # Zu viel Laerm im Debug + else: + # Wenn die geladenen Daten nicht das erwartete Format haben oder leer sind + self.logger.error(f"Formatfehler in '{expected_features_path}' oder Schluessel 'feature_columns' fehlt/ist leer. ML-Vorhersage koennte fehlschlagen.") + self._expected_features = None # Setze auf None bei Fehler + + except Exception as e_json: + # Fangen Sie Fehler beim Laden oder Parsen der JSON-Datei ab + self.logger.error(f"FEHLER beim Laden oder Parsen der Feature-Spalten Datei '{expected_features_path}': {e_json}") + # Logge den Traceback + self.logger.debug(traceback.format_exc()) + self._expected_features = None # Setze auf None bei Fehler + + else: + # Wenn die Feature-Spalten-Datei nicht gefunden wird + self.logger.warning(f"Datei mit erwarteten Feature-Spalten '{expected_features_path}' nicht gefunden. ML-Vorhersage koennte fehlschlagen.") + self._expected_features = None # Setze auf None, da die Datei fehlt + + + # Fallback: Wenn expected_features nicht geladen werden konnte, versuchen Sie es aus Imputer/Modell zu extrahieren (wenn die Bibliothek es unterstuetzt) + if self._expected_features is None: + try: + # Neuere Scikit-learn Versionen haben oft ein feature_names_in_ Attribut + if hasattr(self.imputer, 'feature_names_in_') and self.imputer.feature_names_in_ is not None: + self._expected_features = list(self.imputer.feature_names_in_) + self.logger.info(f"Erwartete Feature-Spalten ({len(self._expected_features)}) aus Imputer geladen (Fallback).") + elif hasattr(self.model, 'feature_names_in_') and self.model.feature_names_in_ is not None: + self._expected_features = list(self.model.feature_names_in_) + self.logger.info(f"Erwartete Feature-Spalten ({len(self._expected_features)}) aus Modell geladen (Fallback).") + else: + # Wenn es nirgends gefunden werden konnte + self.logger.error("Konnte erwartete Feature-Spalten weder aus Datei noch aus Modell/Imputer extrahieren. ML-Vorhersage wird fehlschlagen.") + self._expected_features = None + except Exception as e_extract: + # Fange Fehler beim Extrahieren aus Modell/Imputer ab + self.logger.error(f"FEHLER beim Extrahieren der Feature-Namen aus Modell/Imputer (Fallback): {e_extract}") + # Logge den Traceback + self.logger.debug(traceback.format_exc()) + self._expected_features = None + + + except Exception as e: + # Fange alle anderen unerwarteten Fehler waehrend des Ladens ab + self.logger.exception(f"FEHLER beim Laden von ML-Artefakten: {e}") # Logge Fehler und Traceback + # Setzen Sie die Attribute auf None bei Fehler + self.model = None + self.imputer = None + self._expected_features = None + # Die Methode endet implizit hier nach dem Fangen der Exception. + + + # Methode zur Datenvorbereitung fuer ML (WIRD VON train_technician_model aufgerufen) + # Diese Methode laedt alle relevanten Daten aus dem Sheet, bereitet sie auf + # und gibt einen DataFrame fuer das Training zurueck. + # Basierend auf prepare_data_for_modeling aus Teil 12/13. + # Nutzt interne Helfer: _get_cell_value_safe. + # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, pandas, numpy, re, + # clean_text (Block 4), normalize_string (Block 4), get_valid_numeric (Block 5), + # load_target_schema (Block 6 - relevant fuer Branchentypen), traceback. + # Nutzt die uebergeordnete sheet_handler Instanz (Block 14). + def prepare_data_for_modeling(self): + """ + Laedt Daten aus dem Google Sheet ueber den sheet_handler, + bereitet sie fuer das Decision Tree Modell vor: + - Waehlt relevante Spalten aus und benennt sie um. + - Konsolidiert Umsatz/Mitarbeiter (Wiki > CRM Prioritaet). + - Filtert nach gueltiger Technikerzahl (> 0). + - Erstellt die Zielvariable (Techniker-Bucket). + - Bereitet Features auf (One-Hot Encoding fuer Branche). + - Behaelt NaNs in numerischen Features fuer spaetere Imputation. + + Returns: + pandas.DataFrame: Vorbereiteter DataFrame fuer Training/Test-Split, + oder None bei Fehlern oder wenn keine gueltigen Trainingsdaten gefunden wurden. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + self.logger.info("Starte Datenvorbereitung fuer Modellierung (Training)...") + # Nutzt den self.sheet_handler der Klasse (Block 15). + # Pruefen Sie, ob der Sheet Handler initialisiert wurde und Daten hat. + if not self.sheet_handler or not self.sheet_handler.sheet_values: + self.logger.error("Fehler: Sheet Handler nicht initialisiert oder keine Daten geladen fuer prepare_data_for_modeling.") + # Versuchen Sie die Daten einmalig innerhalb dieser Methode zu laden, falls sie fehlen. + # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2). + if not self.sheet_handler.load_data(): + self.logger.critical("Konnte Daten auch nach erneutem Versuch nicht laden. Abbruch der Datenvorbereitung.") + return None # Gebe None zurueck, wenn Laden fehlschlaegt + + + # Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler. + all_data = self.sheet_handler.get_all_data_with_headers() + # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14). + header_rows = self.sheet_handler._header_rows + # Pruefe auf ausreichende Zeilenzahl (Header + mindestens eine Datenzeile) + min_required_rows = header_rows + 1 + # Wenn nicht genuegend Zeilen da sind + if not all_data or len(all_data) < min_required_rows: + self.logger.error(f"Fehler: Nicht genuegend Datenzeilen ({len(all_data)}) im Sheet gefunden fuer Modellierung (mindestens {min_required_rows} benoetigt).") + return None # Gebe None zurueck, wenn nicht genuegend Daten da sind + + + # --- Header pruefen und DataFrame erstellen --- + try: + # Die erste Zeile sollte die Spaltennamen enthalten. + headers = all_data[0] + # Stellen Sie sicher, dass die Header-Zeile auch die erwartete Mindestlaenge hat, + # um die Spa + + # ========================================================================== + # === Utility Methods (Other Specific Tasks) =============================== + # ========================================================================== + + # --- Methode fuer experimentelle Website Details --- + # Diese Methode extrahiert Details von Websites fuer Zeilen mit 'x'. + # Basierend auf process_website_details_for_marked_rows aus Teil 12. + # Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter. + # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, + # scrape_website_details (Block 13). + # Nutzt die uebergeordnete sheet_handler Instanz (Block 14). + def process_website_details(self, start_sheet_row=None, end_sheet_row=None, limit=None): + """ + EXPERIMENTELL: Extrahiert Website-Details fuer Zeilen, die in Spalte A mit 'x' markiert sind. + Schreibt die Details in eine definierte Spalte (Website Details oder AR als Fallback). + Loescht NICHT das 'x'-Flag. + + Args: + start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (ab Zeile 7). + end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet). + limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht uebersprungener) Zeilen. Defaults to None (Unbegrenzt). + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Logge den Start des Modus auf Warning, da es experimentell ist. + self.logger.warning(f"Starte Modus (EXPERIMENTELL): Website Detail Extraction fuer Zeilen mit 'x' in Spalte A. Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") + self.logger.warning("Hinweis: Dieser Modus nutzt die globale Funktion 'scrape_website_details' (Block 13), deren Implementierung je nach Zielwebsites angepasst werden muss.") + + + # --- Daten laden --- + # Laden Sie Daten neu. Kein automatischer Startindex-Check noetig hier, + # da wir explizit nach dem 'x'-Flag suchen. + # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2). + if not self.sheet_handler.load_data(): + self.logger.error("Fehler beim Laden der Daten fuer Website Details Extraction.") + return # Beende die Methode, wenn das Laden fehlschlaegt + + + # Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler. + all_data = self.sheet_handler.get_all_data_with_headers(); + # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14). + header_rows = self.sheet_handler._header_rows; + total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet + + + # Standard Startzeile, wenn nicht manuell gesetzt + if start_sheet_row is None: start_sheet_row = header_rows + 1 # Standardmaessig ab erster Datenzeile (Zeile nach Headern) + + # Berechne Endzeile, wenn nicht manuell gesetzt + if end_sheet_row is None: end_sheet_row = total_sheet_rows # Bis zur letzten Zeile + + + # Logge den Suchbereich fuer das 'x'-Flag + self.logger.info(f"Suchbereich fuer 'x'-Flag: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") + + # Pruefe, ob der Bereich gueltig ist + if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows: + self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") + return # Beende die Methode, wenn der Bereich leer ist + + + # --- Indizes und Buchstaben --- + # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind + required_keys = ["ReEval Flag", "CRM Website"] # A, D + # Erstellen Sie ein Dictionary mit Schluesseln und Indizes + col_indices = {key: COLUMN_MAP.get(key) for key in required_keys} + + # Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden + if None in col_indices.values(): + missing = [k for k, v in col_indices.items() if v is None] + self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_website_details: {missing}. Breche ab.") + return # Beende die Methode bei kritischem Fehler + + # Ermitteln Sie die Indizes + reeval_col_idx = col_indices["ReEval Flag"] # A + website_col_idx = col_indices["CRM Website"] # D + + # Bestimme die ZIELSPALTE fuer die Details (Website Details ODER AR als Fallback) + details_col_idx = COLUMN_MAP.get("Website Details") # Versuche zuerst die dedizierte Spalte (Block 1 Column Map) + details_col_key_for_logging = "Website Details" # Name fuer Logging + # Wenn die dedizierte Spalte nicht gefunden wurde + if details_col_idx is None: + # Fallback auf 'Website Rohtext' (AR) + details_col_idx = COLUMN_MAP.get("Website Rohtext") # Block 1 Column Map + details_col_key_for_logging = "Website Rohtext" # Name fuer Logging + # Pruefen Sie, ob der Fallback-Schluessel gefunden wurde + if details_col_idx is None: + self.logger.critical("FEHLER: Weder 'Website Details' noch 'Website Rohtext' Spaltenindex in COLUMN_MAP gefunden.") + return # Beende die Methode bei kritischem Fehler + self.logger.warning(f"Keine Spalte 'Website Details' in COLUMN_MAP, nutze '{details_col_key_for_logging}' ({self.sheet_handler._get_col_letter(details_col_idx+1)}) als Fallback.") # Logge Warnung (Block 14 _get_col_letter) + else: + # Logge die Verwendung der dedizierten Spalte + self.logger.info(f"Nutze Spalte '{details_col_key_for_logging}' ({self.sheet_handler._get_col_letter(details_col_idx+1)}) fuer Website Details.") # Logge Info (Block 14 _get_col_letter) + + + # Ermitteln Sie den Spaltenbuchstaben der Zielspalte (nutzt interne Helfer _get_col_letter Block 14) + details_col_letter = self.sheet_handler._get_col_letter(details_col_idx + 1) + + + # --- Verarbeitung --- + # Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1). + update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) + + + all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts) + + + processed_count = 0 # Zaehlt Zeilen, die fuer die Verarbeitung in Frage kommen und in den Batch aufgenommen werden (im Rahmen des Limits). + skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (nicht markiert oder fehlende URL). + + + # Iteriere durch die Datenzeilen im definierten Bereich (1-basierte Sheet-Zeilennummer) + for i in range(start_sheet_row, end_sheet_row + 1): + row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste + # Pruefen Sie, ob das Ende des Sheets erreicht wurde + if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht + + + row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile + + + # Stellen Sie sicher, dass die Zeile nicht leer ist + if not any(cell and isinstance(cell, str) and cell.strip() for cell in row): + #self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug + skipped_count += 1 # Zaehlen als uebersprungene Zeile + continue # Springe zur naechsten Zeile + + + # --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist --- + # Kriterium: Zeile ist mit 'x' in Spalte A (ReEval Flag) markiert. + # UND Website URL (D) ist vorhanden und gueltig aussehend. + + # Holen Sie den Wert aus Spalte A (ReEval Flag) (nutzt interne Helfer _get_cell_value_safe) + cell_a_value = self._get_cell_value_safe(row, "ReEval Flag").strip().lower() # Block 1 Column Map + # Pruefen Sie, ob die Zelle mit 'x' markiert ist. + is_marked_for_processing = cell_a_value == "x" + + # Wenn die Zeile nicht mit 'x' markiert ist, ueberspringen + if not is_marked_for_processing: + skipped_count += 1 # Zaehlen als uebersprungene Zeile + continue # Springe zur naechsten Zeile + + + # Holen Sie den Wert aus Spalte D (CRM Website) (nutzt interne Helfer _get_cell_value_safe) + website_url = self._get_cell_value_safe(row, "CRM Website").strip() # Block 1 Column Map + # Pruefen Sie, ob die Website URL (D) vorhanden und gueltig aussehend ist. + website_url_is_valid_looking = website_url and isinstance(website_url, str) and website_url.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"] # Fuege "http:" hinzu basierend auf Log + + + # Verarbeitung ist noetig, wenn die Zeile mit 'x' markiert ist UND die Website URL gueltig ist. + processing_needed_for_row = is_marked_for_processing and website_url_is_valid_looking + + + # Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level + log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row) + if log_check: + company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map + self.logger.debug(f"Zeile {i} ({company_name[:50]}... Website Details Check): A='x'? {is_marked_for_processing}, D gueltig? {website_url_is_valid_looking}. Benötigt Verarbeitung? {processing_needed_for_row}") # Gekuerzt loggen + + + # Wenn die Verarbeitung fuer diese Zeile nicht noetig ist (trotz 'x' fehlte die URL) + if not processing_needed_for_row: + skipped_count += 1 # Zaehlen als uebersprungene Zeile + # Optionale Behandlung: Wenn mit 'x' markiert, aber URL fehlt, was tun? + # Derzeit wird sie uebersprungen. Ggf. Fehler in Spalte notieren? + continue # Springe zur naechsten Zeile + + + # --- Wenn Verarbeitung noetig: Fuehre Details-Extraktion aus --- + processed_count += 1 # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im Rahmen des Limits zaehlen) + + # Pruefe das Limit fuer verarbeitete Zeilen + if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit: + # Wenn das Limit erreicht ist und es ein positives Limit gibt + self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_website_details erreicht. Breche weitere Zeilenpruefung ab.") + break # Brich die Schleife ab + + + self.logger.info(f"Zeile {i}: Extrahiere Website Details von {website_url[:100]}...") # Logge Start (gekuerzt) + + + details = "FEHLER: Funktion 'scrape_website_details' nicht verfuegbar" # Default Fehler, falls die Funktion nicht existiert (Sollte nicht passieren, wenn Sektion 3 korrekt ist) + + try: + # Rufe die globale Funktion scrape_website_details auf (Block 13). + # scrape_website_details ist mit retry_on_failure dekoriert (Block 2). + # Wenn scrape_website_details fehlschlaegt, wirft sie eine Exception oder gibt einen Fehlerwert zurueck. + details = scrape_website_details(website_url) # <<< Ruft globale Funktion (Block 13) + + # Wenn die Funktion einen Fehler geloggt hat und einen Fehlerstring im Ergebnis zurueckgibt, + # wird dies in der 'details' Variable gespeichert. + if isinstance(details, str) and (details.startswith("k.A. (Fehler") or details.startswith("FEHLER:")): + # Fehler wurde bereits in scrape_website_details geloggt. + pass # Details enthaelt bereits den Fehlerstring. + + elif not isinstance(details, str) or not details.strip(): + # Wenn die Funktion keinen String oder einen leeren String zurueckgibt. + details = "k.A. (Extraktion leer oder ungueltig)" # Standard-Fehlerwert + + + except NameError: + # Dieser Fehler sollte nicht auftreten, wenn scrape_website_details in Sektion 3/7 ist. + # Wenn doch, deutet es auf ein schwerwiegendes Problem bei der Code-Organisation hin. + self.logger.critical("FEHLER: Funktion 'scrape_website_details' ist nicht definiert! Kann Details nicht extrahieren.") + # Logge den Traceback. + self.logger.debug(traceback.format_exc()) + details = "FEHLER: Funktion nicht definiert" # Setze spezifischen Fehlerwert + + except Exception as e_detail: + # Fange andere unerwartete Fehler ab, die nicht von scrape_website_details behandelt wurden. + self.logger.exception(f"Unerwarteter Fehler bei scrape_website_details fuer {website_url[:100]}...: {e_detail}") # Logge Fehler (gekuerzt) und Traceback + details = f"k.A. (Unerwarteter Fehler: {str(e_detail)[:100]}...)" # Signalisiert Fehler (gekuerzt) + + + # Fuege Update fuer die Details-Spalte hinzu (nutzt interne Helfer _get_col_letter Block 14) + # Stellen Sie sicher, dass der Wert ein String ist. + updates_for_row = [] # Lokale Liste fuer Updates dieser Zeile + updates_for_row.append({'range': f'{details_col_letter}{i}', 'values': [[str(details)]]}) # Block 1 Column Map + self.logger.debug(f"Zeile {i}: Details extrahiert und zum Update fuer Spalte {details_col_key_for_logging} ({details_col_letter}{i}) hinzugefuegt.") # Gekuerzt loggen + + + # Sammle die Updates fuer diese Zeile in der globalen Liste all_sheet_updates. + all_sheet_updates.extend(updates_for_row) + + + # Sende gesammelte Sheet Updates wenn das Update-Batch-Limit erreicht ist. + # update_batch_row_limit wird aus Config geholt (Block 1). + # Updates pro Zeile ist 1 in diesem Modus. Anzahl der Zeilen = len(all_sheet_updates). + if len(all_sheet_updates) >= update_batch_row_limit: + self.logger.debug(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") + # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry. + # Wenn es fehlschlaegt, wird es intern geloggt. + success = self.sheet_handler.batch_update_cells(all_sheet_updates) + if success: + self.logger.info(f" Sheet-Update fuer {len(all_sheet_updates)} Zellen erfolgreich.") + # Der Fehlerfall wird von batch_update_cells geloggt + + # Leere die gesammelten Updates nach dem Senden. + all_sheet_updates = [] + + # Kleine Pause nach jeder Extraktion (nutzt Config Block 1). + # Dieser Modus macht API calls (ueber scrape_website_details und dessen Helfer), also Pause einbauen. + pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.2 + #self.logger.debug(f"Warte {pause_duration:.2f}s nach Extraktion...") # Zu viel Laerm im Debug + time.sleep(pause_duration) + + + # --- Finale Sheet Updates senden --- + # Sende alle verbleibenden gesammelten Updates in einem letzten Batch-Update. + if all_sheet_updates: + self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") + # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry. + success = self.sheet_handler.batch_update_cells(all_sheet_updates) + if success: + self.logger.info(f"FINALES Sheet-Update erfolgreich.") + # Der Fehlerfall wird von batch_update_cells geloggt + + + # Logge den Abschluss des Modus + self.logger.info(f"Modus 'website_details' abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {skipped_count} Zeilen uebersprungen.") + # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt. + + + # --- Methode zum Verarbeiten von Wiki-Updates basierend auf ChatGPT Vorschlaegen --- + # Diese Methode verarbeitet Zeilen, in denen S gesetzt ist (nicht in Endzustand), + # prueft ob U eine valide und andere Wiki-URL ist und fuehrt entsprechende Updates durch. + # Basierend auf process_wiki_updates_from_chatgpt aus Teil 4. + # Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter. + # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), time, + # is_valid_wikipedia_article_url (Block 12). + # Nutzt die uebergeordnete sheet_handler Instanz (Block 14). + def process_wiki_updates_from_chatgpt(self, start_sheet_row=None, end_sheet_row=None, limit=None): + """ + Identifiziert Zeilen, in denen Status S gesetzt ist, aber NICHT auf einem Endzustand + (OK, X (UPDATED/COPIED/INVALID)), prueft ob U eine *valide* und *andere* Wiki-URL ist. + - Wenn ja: Kopiert U->M, markiert S='X (URL Copied)', U='URL uebernommen', loescht + abhaengige Wiki-Spalten (N-V, AN, AO, AP, AX), setzt ReEval-Flag A='x'. + - Wenn nein (U keine URL, U==M, oder U ungueltig): LOESCHT den Inhalt von U und + markiert S als 'X (Invalid Suggestion)'. + Verarbeitet maximal limit Zeilen. + + Args: + start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (ab Zeile 7). + end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet). + limit (int, optional): Maximale Anzahl ZU PRUEFENDER Zeilen. Defaults to None (Unbegrenzt). + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Logge die Konfiguration des Modus + self.logger.info(f"Starte Modus 'wiki_updates_from_chatgpt' (S, U, M, N-V, AN, AO, AX, AP, A). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") + + + # --- Daten laden --- + # Laden Sie Daten neu. Kein automatischer Startindex-Check noetig hier, + # da wir nach Status S suchen. + # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2). + if not self.sheet_handler.load_data(): + self.logger.error("Fehler beim Laden der Daten fuer Wiki Updates.") + return # Beende die Methode, wenn das Laden fehlschlaegt + + + # Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler. + all_data = self.sheet_handler.get_all_data_with_headers() + # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14). + header_rows = self.sheet_handler._header_rows + total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet + + + # Standard Startzeile, wenn nicht manuell gesetzt + if start_sheet_row is None: start_sheet_row = header_rows + 1 # Standardmaessig ab erster Datenzeile (Zeile nach Headern) + + # Berechne Endzeile, wenn nicht manuell gesetzt + if end_sheet_row is None: end_sheet_row = total_sheet_rows # Bis zur letzten Zeile + + + # Logge den Suchbereich fuer Status S + self.logger.info(f"Suchbereich fuer Status S: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") + + # Pruefe, ob der Bereich gueltig ist + if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows: + self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") + return # Beende die Methode, wenn der Bereich leer ist + + + # --- Indizes und Buchstaben --- + # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind + required_keys = [ + "Chat Wiki Konsistenzpruefung", "Chat Vorschlag Wiki Artikel", "Wiki URL", # S, U, M (Pruefkriterien / Daten) + "Wikipedia Timestamp", "Wiki Verif. Timestamp", "Timestamp letzte Pruefung", "Version", # AN, AX, AO, AP (Spalten zum Loeschen) + "ReEval Flag", # A (ReEval Flag setzen) + "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", # N-R (Spalten zum Loeschen) + "Chat Begruendung Wiki Inkonsistenz", "Begruendung bei Abweichung", # T, V (Spalten zum Loeschen) + # AY (SerpAPI Wiki Search Timestamp) wird ebenfalls geleert, da abhaengig von M. + "SerpAPI Wiki Search Timestamp" # AY (Spalte zum Leeren) + ] + # Erstellen Sie ein Dictionary mit Schluesseln und Indizes + col_indices = {key: COLUMN_MAP.get(key) for key in required_keys} + + # Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden + if None in col_indices.values(): + missing = [k for k, v in col_indices.items() if v is None] + self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_wiki_updates_from_chatgpt: {missing}. Breche ab.") + return # Beende die Methode bei kritischem Fehler + + + # Ermitteln Sie die Spaltenbuchstaben fuer Updates/Leerung (nutzt interne Helfer _get_col_letter Block 14) + s_letter = self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzpruefung"] + 1) # Status S + u_letter = self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"] + 1) # Vorschlag U + m_letter = self.sheet_handler._get_col_letter(col_indices["Wiki URL"] + 1) # Wiki URL M + a_letter = self.sheet_handler._get_col_letter(col_indices["ReEval Flag"] + 1) # ReEval Flag A + + # Spalten N-V leeren. + # N ist Wiki Absatz, V ist Begruendung bei Abweichung. + n_idx = col_indices["Wiki Absatz"] + v_idx = col_indices["Begruendung bei Abweichung"] + # Erstellen Sie den Bereichsnamen (z.B. "N:V") + n_letter = self.sheet_handler._get_col_letter(n_idx + 1) + v_letter = self.sheet_handler._get_col_letter(v_idx + 1) + nv_range_letter = f'{n_letter}:{v_letter}' # z.B. N:V + # Erstellen Sie eine Liste von leeren Strings fuer diesen Bereich + empty_nv_values = [''] * (v_idx - n_idx + 1) # Anzahl der Spalten = V_Index - N_Index + 1 + + + # Timestamps AN, AO, 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) + + + all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts) + + + processed_rows_count = 0 # Zaehlt Zeilen, die geprueft werden (im Rahmen des Limits zaehlen). + skipped_count = 0 # Zaehlt Zeilen, die uebersprungen werden (Status S im Endzustand etc.). + updated_url_count = 0 # Zaehlt Zeilen, wo U -> M kopiert wurde. + cleared_suggestion_count = 0 # Zaehlt Zeilen, wo Vorschlag U geloescht wurde. + + + # Iteriere durch die Datenzeilen im definierten Bereich (1-basierte Sheet-Zeilennummer) + for i in range(start_sheet_row, end_sheet_row + 1): + row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste + # Pruefen Sie, ob das Ende des Sheets erreicht wurde + if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht + + + row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile + + + # Stellen Sie sicher, dass die Zeile nicht leer ist + if not any(cell and isinstance(cell, str) and cell.strip() for cell in row): + #self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug + skipped_count += 1 # Zaehlen als uebersprungene Zeile + continue # Springe zur naechsten Zeile + + + # --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist --- + # Kriterium: Status S ist gesetzt (nicht leer) UND NICHT einer der Endzustaende. + # Endzustaende: "OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)" + + # Holen Sie den Wert aus Spalte S (Chat Wiki Konsistenzpruefung) (nutzt interne Helfer _get_cell_value_safe) + s_value = self._get_cell_value_safe(row, "Chat Wiki Konsistenzpruefung").strip() # Block 1 Column Map + s_value_upper = s_value.upper() + + # Definieren Sie die Endzustaende (Grossbuchstaben) + s_end_states = ["OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)"] + + # Verarbeitung ist noetig, wenn S nicht leer ist UND S NICHT im Endzustand ist. + processing_needed_for_row = s_value and s_value_upper not in s_end_states + + + # Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level + log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row) + if log_check: + self.logger.debug(f"Zeile {i} (Wiki Update Check): Status S='{s_value}'. Benoetigt Verarbeitung? {processing_needed_for_row}") + + + # Wenn die Verarbeitung fuer diese Zeile nicht noetig ist + if not processing_needed_for_row: + skipped_count += 1 # Zaehlen als uebersprungene Zeile + continue # Springe zur naechsten Zeile + + + # --- Wenn Verarbeitung noetig: Pruefe Vorschlag U und handle --- + processed_rows_count += 1 # Zaehle die Zeile, die geprueft wird (im Rahmen des Limits zaehlen). + + # Pruefe das Limit fuer verarbeitete Zeilen + if limit is not None and isinstance(limit, int) and limit > 0 and processed_rows_count > limit: + # Wenn das Limit erreicht ist und es ein positives Limit gibt + self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_wiki_updates_from_chatgpt erreicht. Breche weitere Zeilenpruefung ab.") + break # Brich die Schleife ab + + + # Holen Sie die Werte aus Spalte U (Chat Vorschlag Wiki Artikel) und M (Wiki URL) (nutzt interne Helfer _get_cell_value_safe) + vorschlag_u = self._get_cell_value_safe(row, "Chat Vorschlag Wiki Artikel").strip() # Block 1 Column Map + url_m = self._get_cell_value_safe(row, "Wiki URL").strip() # Block 1 Column Map + + + self.logger.info(f"Zeile {i}: Pruefe Wiki-Vorschlag U='{vorschlag_u[:100]}...' (aktuell M='{url_m[:100]}...')...") # Gekuerzt loggen + + is_update_candidate = False # Flag, ob U eine gueltige, neue URL ist, die uebernommen werden soll. + new_url = "" # Die URL, die ggf. in M kopiert wird. + + + # Kriterium 1: Ist Vorschlag U ueberhaupt ein String und sieht nach Wikipedia aus? + condition1_u_is_wiki_url = vorschlag_u and isinstance(vorschlag_u, str) and "wikipedia.org/wiki/" in vorschlag_u.lower() and vorschlag_u.lower().startswith(("http://", "https://")) # Check auf Schema hinzugefuegt + + + # Wenn der Vorschlag U wie eine Wikipedia-URL aussieht + if condition1_u_is_wiki_url: + new_url = vorschlag_u # Nehme den Vorschlag als potenzielle neue URL + # Kriterium 2: Unterscheidet sich der Vorschlag U von der aktuellen URL in M? + # Pruefe, ob die neue URL nicht identisch mit der aktuellen M-URL ist. + condition2_u_differs_m = new_url != url_m + + # Wenn sich der Vorschlag U von der aktuellen M-URL unterscheidet + if condition2_u_differs_m: + self.logger.debug(f" -> Vorschlag U ({new_url[:100]}...) unterscheidet sich von M ({url_m[:100]}). Pruefe Validitaet...") # Gekuerzt loggen + # Kriterium 3: Ist die vorgeschlagene URL ein valider Wikipedia-Artikel (nicht Weiterleitung, Begriffsklaerung, Fehler)? + # Nutzt globale Funktion is_valid_wikipedia_article_url (Block 12) mit Retry Decorator (Block 2). + # is_valid_wikipedia_article_url wirft Exception bei endgueltigem Fehler. + try: + condition3_u_is_valid = is_valid_wikipedia_article_url(new_url) # Nutzt globalen Helfer (Block 12) + # Wenn die vorgeschlagene URL ein valider Artikel ist + if condition3_u_is_valid: + is_update_candidate = True # Alle Kriterien erfuellt! Der Vorschlag kann uebernommen werden. + self.logger.debug(f" -> URL '{new_url[:100]}...' ist ein VALIDER Artikel laut API Check.") # Gekuerzt loggen + else: + # Wenn die vorgeschlagene URL nicht valide ist + self.logger.debug(f" -> URL '{new_url[:100]}...' ist KEIN valider Artikel laut API Check.") # Gekuerzt loggen + + except Exception as e_validity_check: + # Wenn is_valid_wikipedia_article_url eine Exception wirft (nach Retries) + # Der Fehler wird bereits vom retry_on_failure Decorator geloggt. + self.logger.error(f"FEHLER bei Validitaetspruefung von Vorschlag U '{new_url[:100]}...': {e_validity_check}") # Gekuerzt loggen + # Bei Fehler bleibt is_update_candidate False. + pass # Faert fort + + + else: + # Wenn der Vorschlag U identisch mit der aktuellen M-URL ist + self.logger.debug(f" -> Vorschlag U ist identisch mit URL M. Wird nicht uebernommen.") + + else: + # Wenn der Vorschlag U nicht wie eine Wikipedia-URL aussieht + self.logger.debug(f" -> Vorschlag U ('{vorschlag_u[:100]}...') ist keine Wikipedia URL. Wird nicht uebernommen.") # Gekuerzt loggen + + + # --- Verarbeitung des Kandidaten ODER Loeschen des ungueltigen Vorschlags --- + updates_for_row = [] # Lokale Liste fuer Updates DIESER Zeile + + if is_update_candidate: + # Fall 1: Gueltiges Update durchfuehren (Vorschlag U wird in M kopiert) + self.logger.info(f"Zeile {i}: Update-Kandidat VALIDIERUNG ERFOLGREICH. Kopiere U->M, setze ReEval-Flag 'x', loesche abhaengige Spalten.") + updated_url_count += 1 # Zaehle die uebernommene URL + + # Updates sammeln (M, S, U, N-V, AN, AO, AP, AX, AY, A) (nutzt interne Helfer _get_col_letter Block 14) + updates_for_row.append({'range': f'{m_letter}{i}', 'values': [[new_url]]}) # Setze die neue URL in Spalte M (Block 1 Column Map) + updates_for_row.append({'range': f'{s_letter}{i}', 'values': [["X (URL Copied)"]]}) # Setze Status S auf "X (URL Copied)" (Block 1 Column Map) + updates_for_row.append({'range': f'{u_letter}{i}', 'values': [["URL uebernommen"]]}) # Schreibe Info in Spalte U (Block 1 Column Map) + updates_for_row.append({'range': f'{a_letter}{i}', 'values': [["x"]]}) # Setze ReEval Flag (A) auf 'x' (Block 1 Column Map) + + # Leere Spalten N-V. + # Fuege das Update zum Leeren des Bereichs V-Y hinzu, falls der Bereichsname ermittelt werden konnte. + if nv_range_letter: # Pruefe, ob der Bereichsname ermittelt werden konnte. + updates_for_row.append({'range': f'{n_letter}{i}:{v_letter}{i}', 'values': [empty_nv_values]}) # Block 1 Column Map, lokale Variable + else: + self.logger.warning(f"Konnte Spaltenbereich N-V ({n_letter}:{v_letter}) fuer Leerung nicht ermitteln fuer Zeile {i}. Leerung uebersprungen.") + + + # Leere Timestamps AN, AO, AP, AX, AY. + # Dies setzt die Zeile zurueck, damit andere Schritte sie spaeter bearbeiten. + updates_for_row.append({'range': f'{an_letter}{i}', 'values': [['']]}) # AN (Wiki Extraction TS) Block 1 Column Map + updates_for_row.append({'range': f'{ao_letter}{i}', 'values': [['']]}) # AO (Chat Evaluation TS) Block 1 Column Map + updates_for_row.append({'range': f'{ap_letter}{i}', 'values': [['']]}) # AP (Version) Block 1 Column Map + updates_for_row.append({'range': f'{ax_letter}{i}', 'values': [['']]}) # AX (Wiki Verif. TS) Block 1 Column Map + updates_for_row.append({'range': f'{ay_letter}{i}', 'values': [['']]}) # AY (SerpAPI Wiki TS) Block 1 Column Map + + + else: + # Fall 2: Ungueltigen Vorschlag loeschen/markieren + # Wenn der Vorschlag U nicht uebernommen wird (weil ungueltig oder identisch mit M). + self.logger.info(f"Zeile {i}: Vorschlag U ('{vorschlag_u[:100]}...') ist ungueltig/identisch. Loesche U und setze Status S auf 'X (Invalid Suggestion)'.") # Gekuerzt loggen + cleared_suggestion_count += 1 # Zaehle den bereinigten Vorschlag + + # Updates sammeln (S, U) (nutzt interne Helfer _get_col_letter Block 14) + updates_for_row.append({'range': f'{s_letter}{i}', 'values': [["X (Invalid Suggestion)"]]}) # Setze Status S auf "X (Invalid Suggestion)" (Block 1 Column Map) + updates_for_row.append({'range': f'{u_letter}{i}', 'values': [[""]]}) # Loesche den Vorschlag in Spalte U (Block 1 Column Map) + # KEIN ReEval-Flag (A) setzen in diesem Fall. + + + # Sammle die Updates fuer diese Zeile in der globalen Liste all_sheet_updates. + all_sheet_updates.extend(updates_for_row) + + + # Sende gesammelte Sheet Updates wenn das Update-Batch-Limit erreicht ist. + # update_batch_row_limit wird aus Config geholt (Block 1). + # Die Anzahl der Updates pro Zeile variiert stark (ca. 2 bei ungueltigem Vorschlag, ca. 10+ bei gueltigem). + # Pruefen Sie einfach die Laenge der gesammelten Liste. + if len(all_sheet_updates) >= update_batch_row_limit * 5: # Grobe Schaetzung: im Schnitt 5 Updates pro Zeile + self.logger.debug(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") + # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry. + # Wenn es fehlschlaegt, wird es intern geloggt. + success = self.sheet_handler.batch_update_cells(all_sheet_updates) + if success: + self.logger.info(f" Sheet-Update fuer {len(all_sheet_updates)} Zellen erfolgreich.") + # Der Fehlerfall wird von batch_update_cells geloggt + + # Leere die gesammelten Updates nach dem Senden. + all_sheet_updates = [] + + + # Kleine Pause nach jeder geprueften Zeile (nutzt Config Block 1). + # Dieser Modus macht API calls (ueber is_valid_wikipedia_article_url), also Pause einbauen. + pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.2 + #self.logger.debug(f"Warte {pause_duration:.2f}s nach Pruefung...") # Zu viel Laerm im Debug + time.sleep(pause_duration) + + + # --- Finale Sheet Updates senden --- + # Sende alle verbleibenden gesammelten Updates in einem letzten Batch-Update. + if all_sheet_updates: + self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") + # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry. + success = self.sheet_handler.batch_update_cells(all_sheet_updates) + if success: + self.logger.info(f"FINALES Sheet-Update erfolgreich.") + # Der Fehlerfall wird von batch_update_cells geloggt + + + # Logge den Abschluss des Modus + self.logger.info(f"Modus 'wiki_updates_from_chatgpt' abgeschlossen. {processed_rows_count} Zeilen geprueft, {updated_url_count} URLs kopiert & fuer ReEval markiert, {cleared_suggestion_count} ungueltige Vorschlaege geloescht/markiert, {skipped_count} Zeilen uebersprungen.") + # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt. + + + # --- Methode zur Re-Extraktion von Wiki-Daten bei fehlendem Timestamp AN --- + # Diese Methode identifiziert Zeilen mit M gefuellt und AN leer und fuehrt _process_single_row (Block 19) fuer diese aus. + # Nutzt interne Helfer: _get_cell_value_safe, _process_single_row. + # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger. + # Nutzt die uebergeordnete sheet_handler Instanz (Block 14). + def process_wiki_reextract_missing_an(self, start_sheet_row=None, end_sheet_row=None, limit=None): + """ + Identifiziert Zeilen, bei denen eine Wiki URL (M) vorhanden ist, aber der + Wikipedia Timestamp (AN) fehlt. Fuehrt _process_single_row fuer diese Zeilen aus, + beschraenkt auf den 'wiki'-Schritt und mit force_reeval=True, um die Extraktion + erneut zu versuchen. + + Args: + start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AN). + end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet). + limit (int, optional): Maximale Anzahl ZU VERARBEITENDER Zeilen. Defaults to None (Unbegrenzt). + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Logge die Konfiguration des Modus + self.logger.info(f"Starte Modus 'wiki_reextract_missing_an' (M gefuellt & AN leer). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") + + + # --- Daten laden und Startzeile ermitteln --- + # Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt. + # Dieser Modus sucht nach leeren AN mit gefuelltem M. Die automatische Startzeile + # basierend auf leeren AN ist ein guter Startpunkt. + if start_sheet_row is None: + self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AN...") + # Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AN (Block 1 Column Map). + # Standardmaessig ab Zeile 7 + start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Wikipedia Timestamp", min_sheet_row=7) + + # Wenn get_start_row_index -1 zurueckgibt (Fehler) + if start_data_index_no_header == -1: + self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche Modus ab.") + return # Beende die Methode + + # Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten Daten-Index + start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut + self.logger.info(f"Automatisch ermittelte Startzeile (erste leere AN Zelle): {start_sheet_row}") + else: + # Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein. + # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2). + if not self.sheet_handler.load_data(): + self.logger.error("Fehler beim Laden der Daten fuer wiki_reextract_missing_an.") + return # Beende die Methode, wenn das Laden fehlschlaegt + + + # Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler. + all_data = self.sheet_handler.get_all_data_with_headers(); + # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14). + header_rows = self.sheet_handler._header_rows; + total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet + + + # Berechne Endzeile, wenn nicht manuell gesetzt + if end_sheet_row is None: + end_sheet_row = total_sheet_rows # Bis zur letzten Zeile + + + # Logge den verarbeitungsbereich + self.logger.info(f"Suchbereich fuer M gefuellt & AN leer: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") + + # Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen) + if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows: + self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") + return # Beende die Methode, wenn der Bereich leer ist + + + # --- Indizes --- + # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind + required_keys = ["Wiki URL", "Wikipedia Timestamp"] # M, AN (Pruefkriterien) + # Erstellen Sie ein Dictionary mit Schluesseln und Indizes + col_indices = {key: COLUMN_MAP.get(key) for key in required_keys} + + # Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden + if None in col_indices.values(): + missing = [k for k, v in col_indices.items() if v is None] + self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer wiki_reextract_missing_an: {missing}. Breche ab.") + return # Beende die Methode bei kritischem Fehler + + # Ermitteln Sie die Indizes + m_col_idx = col_indices["Wiki URL"] + an_col_idx = col_indices["Wikipedia Timestamp"] + + + # --- Verarbeitung --- + processed_count = 0 # Zaehlt Zeilen, die an _process_single_row uebergeben wurden (im Rahmen des Limits zaehlen). + skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden. + + + # Iteriere durch die Datenzeilen im definierten Bereich (1-basierte Sheet-Zeilennummer) + for i in range(start_sheet_row, end_sheet_row + 1): + row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste + # Pruefen Sie, ob das Ende des Sheets erreicht wurde + if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht + + + row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile + + + # Stellen Sie sicher, dass die Zeile nicht leer ist + if not any(cell and isinstance(cell, str) and cell.strip() for cell in row): + #self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug + skipped_count += 1 # Zaehlen als uebersprungene Zeile + continue # Springe zur naechsten Zeile + + + # --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist --- + # Kriterium: Wiki URL (M) ist vorhanden und gueltig aussehend. + # UND Wikipedia Timestamp (AN) ist leer. + + # Holen Sie die Werte aus den entsprechenden Spalten (nutzt interne Helfer _get_cell_value_safe) + m_value = self._get_cell_value_safe(row, "Wiki URL").strip() # Block 1 Column Map + an_value = self._get_cell_value_safe(row, "Wikipedia Timestamp").strip() # Block 1 Column Map + + # Pruefen Sie, ob M gefuellt und gueltig aussieht. + is_m_valid_looking = m_value and isinstance(m_value, str) and "wikipedia.org/wiki/" in m_value.lower() and m_value.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"] # Fuege "http:" hinzu basierend auf Log + + # Pruefen Sie, ob AN leer ist. + is_an_empty = not an_value + + # Verarbeitung ist noetig, wenn M gueltig aussieht UND AN leer ist. + processing_needed_for_row = is_m_valid_looking and is_an_empty + + + # Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level + log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row) + if log_check: + company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map + self.logger.debug(f"Zeile {i} ({company_name[:50]}... Wiki Re-extract Check): M ('{m_value[:50]}...') gueltig? {is_m_valid_looking}, AN leer? {is_an_empty}. Benötigt Verarbeitung? {processing_needed_for_row}") # Gekuerzt loggen + + + # Wenn die Verarbeitung fuer diese Zeile nicht noetig ist + if not processing_needed_for_row: + skipped_count += 1 # Zaehlen als uebersprungene Zeile + continue # Springe zur naechsten Zeile + + + # --- Wenn Verarbeitung noetig: Rufe _process_single_row auf --- + processed_count += 1 # Zaehle die Zeile, die an _process_single_row uebergeben wird (im Rahmen des Limits zaehlen) + + # Pruefe das Limit fuer verarbeitete Zeilen + if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit: + # Wenn das Limit erreicht ist und es ein positives Limit gibt + self.logger.info(f"Verarbeitungslimit ({limit}) fuer wiki_reextract_missing_an erreicht. Breche weitere Zeilenpruefung ab.") + break # Brich die Schleife ab + + + self.logger.info(f"Zeile {i}: M gefuellt & AN leer. Versuche Wiki-Re-Extraktion ueber _process_single_row...") + + try: + # RUFE _process_single_row AUF (Block 19). + # Mit steps_to_run={'wiki'} und force_reeval=True, + # damit nur der Wiki-Schritt ausgefuehrt wird und Timestamps ignoriert werden. + # Im Re-Extract Modus loeschen wir das 'x'-Flag NICHT automatisch. + self._process_single_row( + row_num_in_sheet = i, + row_data = row, # Uebergibt die aktuellen Rohdaten der Zeile + steps_to_run = {'wiki'}, # <<< NUR der Wiki-Schritt soll laufen + force_reeval = True, # <<< Erzwingt die Ausfuehrung des 'wiki' Schritts (ignoriert AN, S). + clear_x_flag = False # <<< 'x'-Flag wird in diesem Modus NICHT geloescht + ) + # _process_single_row (Block 19) loggt intern den Abschluss und fuehrt das Sheet-Update durch. + + except Exception as e_proc: + # Wenn _process_single_row einen Fehler wirft (nachdem interne Retries aufgaben), + # fangen wir ihn hier, loggen ihn und fahren mit der naechsten Zeile fort. + self.logger.exception(f"FEHLER bei Verarbeitung von Zeile {i} in wiki_reextract_missing_an: {e_proc}") + # Hier koennen Sie z.B. einen Fehlerindikator in eine spezielle Spalte im Sheet schreiben lassen. + # Dieses Update muesste dann separat oder im naechsten Lauf behandelt werden. + + # _process_single_row beinhaltet bereits eine kleine Pause am Ende. + # Hier ist keine zusaetzliche Pause noetig, wenn _process_single_row erfolgreich war. + # Wenn _process_single_row eine Exception wirft, kann hier eine kurze Pause sinnvoll sein. + # time.sleep(0.1) # Optional: Kurze Pause bei Fehler nach Exception + + + # Logge den Abschluss des Modus + self.logger.info(f"Modus 'wiki_reextract_missing_an' abgeschlossen. {processed_count} Zeilen an _process_single_row uebergeben, {skipped_count} Zeilen uebersprungen.") + # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt. + + +# ============================================================================== +# Ende DataProcessor Klasse Utility: Other Specific Tasks Block +# ============================================================================== + +# ============================================================================== +# Ende DataProcessor Klasse +# ============================================================================== + + # --- Ende der DataProcessor Klasse --- + # Ein pass statement, um die Klassendefinition abzuschliessen, falls keine weiteren Methoden folgen. + pass # <-- DIESES pass STATEMENT GEHOERT ZUM ENDE DER KLASSENDEFINITION + + +# ============================================================================== +# Hauptausfuehrungsblock & Globale Funktionen nach Klassen +# ============================================================================== +# Der naechste Block (Block 34) enthaelt die main Funktion und den Entry Point. + +# ============================================================================== +# 6. MAIN FUNCTION (HAUPTEINSTIEGSPUNKT & UI DISPATCHER) +# ============================================================================== + +# Der globale Root Logger wird in main() konfiguriert +# logger = logging.getLogger(__name__) # Diesen Logger gibt es schon, keine Neudefinition hier + def main(): """ Haupteinstiegspunkt des Skripts. Verarbeitet Kommandozeilen-Argumente, richtet Logging ein, initialisiert Komponenten und dispatchet zu den passenden Modi. """ - # WICHTIG: Global LOG_FILE wird benötigt + # WICHTIG: Globale Variable LOG_FILE wird benoetigt (Initialisierung Block 3) global LOG_FILE # --- Initial Logging Setup (Konfiguration von Level und Format) --- - # Diese Konfiguration wird wirksam, sobald die Handler hinzugefügt werden. - # Standard-Logging Level festlegen - log_level = logging.DEBUG if getattr(Config, 'DEBUG', False) else logging.INFO # Nutzt Config.DEBUG + # Diese Konfiguration wird wirksam, sobald die Handler hinzugefuegt werden. + # Standard-Logging Level festlegen (aus Config Block 1) + log_level = logging.DEBUG if getattr(Config, 'DEBUG', False) else logging.INFO log_format = '%(asctime)s - %(levelname)-8s - %(name)-25s - %(message)s' # Angepasstes Format mit breiterem Namen - # Root-Logger konfigurieren (mit Console Handler, File Handler wird später hinzugefügt) - # handlers=[] verhindert default Console Handler, wir fügen ihn manuell hinzu für mehr Kontrolle + # Root-Logger konfigurieren (mit Console Handler, File Handler wird spaeter hinzugefuegt) + # handlers=[] verhindert default Console Handler, wir fuegen ihn manuell hinzu fuer mehr Kontrolle logging.basicConfig(level=log_level, format=log_format, handlers=[]) - # Console Handler explizit hinzufügen + # Console Handler explizit hinzufuegen console_handler = logging.StreamHandler() console_handler.setLevel(log_level) # Nimm das globale Level console_handler.setFormatter(logging.Formatter(log_format)) - logging.getLogger('').addHandler(console_handler) # Füge zum Root-Logger hinzu + # Pruefen, ob nicht schon ein Console Handler vorhanden ist (z.B. bei wiederholten Aufrufen in Tests) + if not any(isinstance(h, logging.StreamHandler) for h in logging.getLogger('').handlers): + logging.getLogger('').addHandler(console_handler) + # Testnachricht (geht nur an Konsole, da File Handler noch fehlt) logger.debug("DEBUG Logging initial konfiguriert (nur Konsole).") @@ -7019,409 +10210,546 @@ def main(): # --- Initialisierung (Argument Parser) --- - current_script_version = getattr(Config, 'VERSION', 'unknown') + current_script_version = getattr(Config, 'VERSION', 'unknown') # Aus Config Block 1 parser = argparse.ArgumentParser( description=f"Firmen-Datenanreicherungs-Skript {current_script_version}. Automatisiert Anreicherung und Validierung aus Google Sheets.", - formatter_class=argparse.RawTextHelpFormatter # Behält Formatierung im Help-Text + formatter_class=argparse.RawTextHelpFormatter # Behaelt Formatierung im Help-Text ) - # Liste der gültigen Modi - MUSS mit den elif-Zweigen unten übereinstimmen! - # Kategorisiert für die Menü-Ausgabe + # Liste der gueltigen Modi - MUSS mit den elif-Zweigen unten uebereinstimmen! + # Kategorisiert fuer die Menue-Ausgabe mode_categories = { "Batch-Verarbeitung (Schritt-Optimiert)": [ - "wiki_verify", "website_scraping", "summarize_website", "branch_eval", + "wiki_verify", # Uebereinstimmend mit process_verification_batch (Block 26) + "website_scraping", # Uebereinstimmend mit process_website_scraping_batch (Block 27) + "summarize_website", # Uebereinstimmend mit process_summarization_batch (Block 28) + "branch_eval", # Uebereinstimmend mit process_branch_batch (Block 29) ], - "Sequenzielle Verarbeitung (Zeilenweise)": [ - "full_run", + "Sequentielle Verarbeitung (Zeilenweise)": [ + "full_run", # Nutzt process_rows_sequentially (Block 24) ], "Re-Evaluate Markierte Zeilen (Spalte A='x')": [ - "reeval", + "reeval", # Nutzt process_reevaluation_rows (Block 25) ], "Einzelne Dienstprogramme / Suchen": [ - "find_wiki_serp", "website_lookup", "contacts", "update_wiki_suggestions", # update_wiki umbenannt - "train_technician_model", "alignment", "wiki_reextract_missing_an", - "website_details", # Experimentell + "find_wiki_serp", # Nutzt process_find_wiki_serp (Block 30) + "website_lookup", # Nutzt process_serp_website_lookup (Block 30) + "contacts", # Nutzt process_contact_search (Block 30) + "update_wiki_suggestions", # Nutzt process_wiki_updates_from_chatgpt (Block 32) + "wiki_reextract_missing_an", # Nutzt process_wiki_reextract_missing_an (Block 32) + "website_details", # EXPERIMENTELL - Nutzt process_website_details (Block 32) + "train_technician_model", # Nutzt train_technician_model (Block 31) + "alignment", # Nutzt globale alignment_demo (Block 14) ], - "Kombinierte Läufe (Vordefiniert)": [ - "combined_all", # Neuer kombinierter Modus + "Kombinierte Laeufe (Vordefiniert)": [ + "combined_all", # Definiert eine Sequenz von Batch-Modi ] } - # Erstellen Sie eine flache Liste aller validen Modi + # Erstellen Sie eine flache Liste aller validen Modi fuer die Validierung valid_modes = [mode for modes in mode_categories.values() for mode in modes] - # Dynamisch generieren des Help-Textes für den Modus - mode_help_text = "Betriebsmodus. Wählen Sie einen der folgenden:\n" + # Dynamisch generieren des Help-Textes fuer den Modus + mode_help_text = "Betriebsmodus. Waehlen Sie einen der folgenden:\n" for category, modes in mode_categories.items(): mode_help_text += f"\n{category}:\n" for mode in modes: mode_help_text += f" - {mode}\n" parser.add_argument("--mode", type=str, help=mode_help_text) - parser.add_argument("--limit", type=int, help="Maximale Anzahl zu verarbeitender Zeilen in den meisten Modi (prüft Zeilen VOR Überspringung/Filterung).", default=None) - # start_sheet_row wird primär für full_run verwendet - parser.add_argument("--start_sheet_row", type=int, help="Startzeile im Sheet (1-basiert) für 'full_run' und einige Batch-Modi. Standard: Automatische Ermittlung basierend auf Timestamp.", default=None) - # end_sheet_row für Bereiche - parser.add_argument("--end_sheet_row", type=int, help="Endzeile im Sheet (1-basiert) für 'full_run' und einige Batch-Modi. Standard: Ende des Sheets.", default=None) + # Hilfsargument fuer die CLI-basierte Modusauswahl (wenn --mode gesetzt ist) + parser.add_argument("-m", "--cli-mode", dest="mode", action="store_const", const=valid_modes[0] if valid_modes else None, help=argparse.SUPPRESS) # Unterdruecke in --help + + parser.add_argument("--limit", type=int, help="Maximale Anzahl zu verarbeitender Zeilen in den meisten Modi (prueft Zeilen VOR Ueberspringung/Filterung).", default=None) + # start_sheet_row wird primaer fuer full_run verwendet, kann aber auch fuer Bereiche in Batch nuetzlich sein + parser.add_argument("--start_sheet_row", type=int, help="Startzeile im Sheet (1-basiert) fuer 'full_run' und einige Batch-Modi. Standard: Automatische Ermittlung basierend auf Timestamp.", default=None) + # end_sheet_row fuer Bereiche + parser.add_argument("--end_sheet_row", type=int, help="Endzeile im Sheet (1-basiert) fuer 'full_run' und einige Batch-Modi. Standard: Ende des Sheets.", default=None) - # Argument für den Re-Eval Modus zur Auswahl der Schritte - # Mögliche Werte für die Schritte: 'wiki', 'chat', 'web', 'ml_predict', etc. (entsprechend den step_type Schlüsseln in _process_single_row) - # Default ist 'all' für alle Schritte, oder eine spezifische Liste - valid_reeval_steps = ['wiki', 'chat', 'web', 'ml_predict'] # Fügen Sie hier weitere Schritt-Keys hinzu - reeval_steps_help = f"Komma-getrennte Liste der Schritte im 'reeval' und 'full_run' Modus (z.B. 'wiki,chat').\nMögliche Schritte: {', '.join(valid_reeval_steps)}.\nStandard: {'all' if not valid_reeval_steps else ','.join(valid_reeval_steps)}" # Standard: alle verfügbaren Schritte + # Argument fuer den Re-Eval und Full-Run Modus zur Auswahl der Schritte + # Moegliche Werte fuer die Schritte: 'wiki', 'chat', 'web', 'ml_predict', etc. (entsprechend den step_type Schluesseln in _process_single_row Block 19) + # Default ist 'all' fuer alle Schritte, oder eine spezifische Liste + # Dies sind die Schluessel, die _process_single_row (Block 19) in steps_to_run Set erwartet. + valid_single_row_steps = ['wiki', 'chat', 'web', 'ml_predict'] # Fuegen Sie hier weitere Schritt-Schluessel hinzu, die _process_single_row versteht + single_row_steps_help = f"Komma-getrennte Liste der Schritte im 'reeval' und 'full_run' Modus (z.B. 'wiki,chat').\nMögliche Schritte: {', '.join(valid_single_row_steps)}.\nStandard: {'all' if valid_single_row_steps else 'keine'}" # Standard: alle verfuegbaren Schritte - parser.add_argument("--steps", type=str, help=reeval_steps_help, default=','.join(valid_reeval_steps) if valid_reeval_steps else 'all') # Default alle, wenn Liste definiert + # Standardwert fuer --steps: Alle gueltigen Single-Row Schritte, wenn es welche gibt + default_steps_arg = ','.join(valid_single_row_steps) if valid_single_row_steps else '' + parser.add_argument("--steps", type=str, help=single_row_steps_help, default=default_steps_arg) - # Argumente für find_wiki_serp (falls über CLI gesteuert) - parser.add_argument("--min_umsatz", type=float, help="Mindestumsatz in MIO € (CRM Spalte J) für find_wiki_serp Filter.", default=200.0) # Float für Konsistenz - parser.add_argument("--min_employees", type=int, help="Mindestmitarbeiterzahl (CRM Spalte K) für find_wiki_serp Filter.", default=500) - # Argumente für train_technician_model - parser.add_argument("--model_out", type=str, default=MODEL_FILE, help=f"Pfad für das trainierte Modell (.pkl). Standard: {MODEL_FILE}") - parser.add_argument("--imputer_out", type=str, default=IMPUTER_FILE, help=f"Pfad für den trainierten Imputer (.pkl). Standard: {IMPUTER_FILE}") - parser.add_argument("--patterns_out", type=str, default=PATTERNS_FILE_JSON, help=f"Pfad für die Feature-Spaltenliste (.json). Standard: {PATTERNS_FILE_JSON}") # Ändern zu JSON + # Argumente fuer find_wiki_serp (falls ueber CLI gesteuert) + parser.add_argument("--min_umsatz", type=float, help="Mindestumsatz in MIO € (CRM Spalte J) fuer find_wiki_serp Filter.", default=200.0) # Float fuer Konsistenz + parser.add_argument("--min_employees", type=int, help="Mindestmitarbeiterzahl (CRM Spalte K) fuer find_wiki_serp Filter.", default=500) - # TODO: Fügen Sie hier weitere CLI-Argumente hinzu, falls andere Modi Parameter benötigen + + # Argumente fuer train_technician_model (Pfade fuer Output-Dateien) + parser.add_argument("--model_out", type=str, default=MODEL_FILE, help=f"Pfad fuer das trainierte Modell (.pkl). Standard: {MODEL_FILE}") # Block 1 Konstante + parser.add_argument("--imputer_out", type=str, default=IMPUTER_FILE, help=f"Pfad fuer den trainierten Imputer (.pkl). Standard: {IMPUTER_FILE}") # Block 1 Konstante + parser.add_argument("--patterns_out", type=str, default=PATTERNS_FILE_JSON, help=f"Pfad fuer die Feature-Spaltenliste (.json). Standard: {PATTERNS_FILE_JSON}") # Block 1 Konstante + + # TODO: Fuegen Sie hier weitere CLI-Argumente hinzu, falls andere Modi Parameter benoetigen args = parser.parse_args() # --- Konfiguration laden --- - Config.load_api_keys() # Nutzt jetzt logging intern (print am Anfang) + Config.load_api_keys() # Nutzt jetzt logging intern (print am Anfang Block 1) - # --- Logdatei-Konfiguration abschließen --- + # --- Logdatei-Konfiguration abschliessen --- # Bestimmen Sie den Log-Modus Namen basierend auf CLI oder Interaktion + # Wir nutzen den CLI Modus Namen, wenn --mode gesetzt ist, sonst "interactive". log_mode_name = args.mode if args.mode else "interactive" - LOG_FILE = create_log_filename(log_mode_name) # Nutzt globale Funktion + LOG_FILE = create_log_filename(log_mode_name) # Nutzt globale Funktion (Block 3) + # Wenn die Logdatei erfolgreich erstellt wurde if LOG_FILE: try: - # Erstellen Sie den FileHandler + # Erstellen Sie den FileHandler fuer die Logdatei + # mode='a' zum Anhaengen, encoding='utf-8' fuer Unicode file_handler = logging.FileHandler(LOG_FILE, mode='a', encoding='utf-8') file_handler.setLevel(log_level) # Nimm das globale Level - # Verwenden Sie denselben Formatter wie für den Console Handler + # Verwenden Sie denselben Formatter wie fuer den Console Handler file_handler.setFormatter(logging.Formatter(log_format)) - # Füge FileHandler zum Root-Logger hinzu - logging.getLogger('').addHandler(file_handler) + # Fuege FileHandler zum Root-Logger hinzu + # Pruefen, ob nicht schon ein File Handler mit demselben Pfad vorhanden ist (z.B. bei wiederholten Aufrufen in Tests) + if not any(isinstance(h, logging.FileHandler) and h.baseFilename == os.path.abspath(LOG_FILE) for h in logging.getLogger('').handlers): + logging.getLogger('').addHandler(file_handler) logger.info(f"Logging wird jetzt auch in Datei geschrieben: {LOG_FILE}") except Exception as e: # Logge Fehler nur auf Konsole, da FileHandler fehlgeschlagen ist # logger.exception loggt auch an die Konsole, wenn kein FileHandler da ist - logger.error(f"Konnte FileHandler für Logdatei '{LOG_FILE}' nicht erstellen: {e}") + logger.error(f"Konnte FileHandler fuer Logdatei '{LOG_FILE}' nicht erstellen: {e}") # Optional: Entfernen Sie evtl. den fehlerhaften Handler aus der Liste - logging.getLogger('').handlers = [h for h in logging.getLogger('').handlers if not isinstance(h, logging.FileHandler)] + logging.getLogger('').handlers = [h for h in logging.getLogger('').handlers if not isinstance(h, logging.FileHandler) or h.baseFilename == os.path.abspath(LOG_FILE)] # Entferne nur den fehlerhaften Handler # --- JETZT die Startmeldungen loggen (gehen jetzt in Konsole UND Datei) --- logger.info(f"===== Skript gestartet =====") logger.info(f"Version: {current_script_version}") + # Logge den tatsaechlichen Pfad der Logdatei oder die Fehlermeldung logger.info(f"Logdatei: {LOG_FILE if LOG_FILE else 'FEHLER - Keine Logdatei erstellt'}") # Loggen Sie relevante CLI Argumente zur Dokumentation des Laufs logger.info(f"CLI Argumente: {args}") - # --- Vorbereitung (Schema, Handler etc.) --- - load_target_schema() # Annahme: load_target_schema ist global definiert (utils.py) + # --- Vorbereitung (Schema, Handler etc.) --- + # Laden Sie das Ziel-Branchenschema (Block 6) + # load_target_schema ist mit retry_on_failure dekoriert (Block 2). + load_target_schema() + + + # Initialisiere GoogleSheetHandler (Block 14) sheet_handler = None # Initialisiere Variable try: - sheet_handler = GoogleSheetHandler() # Initialisiere den Sheet Handler - except Exception as e: + # Der GoogleSheetHandler Init (_init_ Methode) baut die Verbindung auf und laedt Daten. + # Fehler werden dort gefangen und als ConnectionError erneut geworfen. + sheet_handler = GoogleSheetHandler() + logger.info("GoogleSheetHandler erfolgreich initialisiert.") + except ConnectionError as e: + # Wenn die Initialisierung des SheetHandlers fehlschlaegt (Verbindungs-/Ladefehler) logger.critical(f"FATAL: Initialisierung des GoogleSheetHandlers fehlgeschlagen: {e}") - logger.critical(f"Bitte Logdatei prüfen: {LOG_FILE}") - # Das Skript kann ohne Sheet-Zugriff nicht arbeiten + logger.critical(f"Bitte ueberpruefen Sie Ihre Google Sheets URL, Credentials und Berechtigungen.") + logger.critical(f"Bitte Logdatei pruefen fuer Details: {LOG_FILE}") + return # Beende Skript, wenn Sheet nicht geladen werden kann + except Exception as e: + # Fangen Sie andere unerwartete Fehler bei der Initialisierung ab + logger.critical(f"FATAL: Unerwarteter Fehler bei Initialisierung von GoogleSheetHandler: {e}") + logger.debug(traceback.format_exc()) + logger.critical(f"Bitte Logdatei pruefen fuer Details: {LOG_FILE}") return # Beende Skript + + # Initialisiere WikipediaScraper (Block 14) wiki_scraper = None # Initialisiere Variable try: - # Initialisiere WikipediaScraper - wiki_scraper = WikipediaScraper() # Annahme: WikipediaScraper ist global definiert + # Der WikipediaScraper Init (_init_ Methode) konfiguriert die Bibliothek und Requests. + # Fehler werden dort gefangen und erneut geworfen. + wiki_scraper = WikipediaScraper() + logger.info("WikipediaScraper erfolgreich initialisiert.") except Exception as e: + # Wenn die Initialisierung des WikipediaScrapers fehlschlaegt logger.critical(f"FATAL: Initialisierung des WikipediaScrapers fehlgeschlagen: {e}") - logger.critical(f"Bitte Logdatei prüfen: {LOG_FILE}") + logger.debug(traceback.format_exc()) + logger.critical(f"Bitte Logdatei pruefen fuer Details: {LOG_FILE}") # Das Skript kann ohne Wiki Scraper viele Modi nicht sinnvoll laufen return # Beende Skript - # TODO: Initialisieren Sie hier weitere Worker-Instanzen, falls Sie separate Klassen haben - # z.B. openai_handler = OpenAIHandler() + + # TODO: Initialisieren Sie hier weitere Worker-Instanzen, falls Sie separate Klassen haben (z.B. OpenAIHandler, SerpAPIHandler) + # openai_handler = OpenAIHandler() # serpapi_handler = SerpAPIHandler() - # Initialisiere DataProcessor Instanz mit Handlern - # Übergeben Sie alle benötigten Handler - data_processor = DataProcessor(sheet_handler=sheet_handler, wiki_scraper=wiki_scraper) # Übergeben Sie die Instanzen + # Initialisiere DataProcessor Instanz (Block 15) mit Handlern + # Uebergeben Sie alle benoetigten Handler an den DataProcessor. + # Die __init__ Methode des DataProcessor (Block 15) prueft die Typen und wirft Value Error, wenn falsch. + try: + data_processor = DataProcessor(sheet_handler=sheet_handler, wiki_scraper=wiki_scraper) + logger.info("DataProcessor erfolgreich initialisiert.") + except Exception as e: + # Fangen Sie Fehler bei der DataProcessor Initialisierung ab. + logger.critical(f"FATAL: Initialisierung des DataProcessors fehlgeschlagen: {e}") + logger.debug(traceback.format_exc()) + logger.critical(f"Bitte Logdatei pruefen fuer Details: {LOG_FILE}") + return # Beende Skript - # --- Modusauswahl und Ausführung --- - start_time = time.time() + # --- Modusauswahl und Ausfuehrung --- + start_process_time = time.time() # Zeitmessung fuer die Verarbeitung starten logger.info(f"Starte Verarbeitung um {datetime.now().strftime('%H:%M:%S')}...") - selected_mode = None # Variable für den tatsächlich ausgeführten Modus - # --- Ermitteln des zu führenden Modus (CLI hat Priorität) --- + selected_mode = None # Variable fuer den tatsaechlich auszufuehrenden Modus + + # --- Ermitteln des zu fuehrenden Modus (CLI hat Prioritaet vor interaktiver Auswahl) --- + # Wenn das --mode Argument ueber die Kommandozeile gesetzt wurde if args.mode: - selected_mode = args.mode.lower() + selected_mode = args.mode.lower() # Konvertiere zu Kleinbuchstaben + # Pruefen Sie, ob der gewaehlte Modus in der Liste der validen Modi enthalten ist if selected_mode not in valid_modes: - logger.error(f"Ungültiger Modus '{args.mode}' über Kommandozeile angegeben. Gültige Modi: {', '.join(valid_modes)}") - print(f"Fehler: Ungültiger Modus '{args.mode}'. Siehe --help.") + # Logge einen Fehler und beende das Skript, wenn der Modus ungueltig ist. + logger.error(f"Ungueltiger Modus '{args.mode}' ueber Kommandozeile angegeben. Gueltige Modi: {', '.join(valid_modes)}") + print(f"Fehler: Ungueltiger Modus '{args.mode}'. Bitte ueberpruefen Sie die Liste der gueltigen Modi (siehe --help).") return # Skript beenden - logger.info(f"Betriebsmodus (CLI gewählt): {selected_mode}") + logger.info(f"Betriebsmodus (CLI gewaehlt): {selected_mode}") + + # Wenn das --mode Argument NICHT ueber die Kommandozeile gesetzt wurde else: - # --- Interaktive Modusauswahl --- - print("\nBitte wählen Sie den Betriebsmodus:") - # Zeigen Sie die Liste der validen Modi kategorisiert an - mode_options_map = {} - option_counter = 1 + # --- Interaktive Modusauswahl ueber die Konsole --- + print("\nBitte waehlen Sie den Betriebsmodus:") + # Zeigen Sie die Liste der validen Modi kategorisiert an, mit Nummern. + mode_options_map = {} # Dictionary zum Abbilden von Zahl/Name auf Modusname + option_counter = 1 # Zaehler fuer die numerischen Optionen + # Iteriere durch die Kategorien und Modi for category, modes in mode_categories.items(): print(f"\n{category}:") for mode in modes: print(f" {option_counter}: {mode}") - mode_options_map[str(option_counter)] = mode # Map Zahl zu Modusname - mode_options_map[mode] = mode # Map Modusname zu Modusname (für direkte Eingabe) - option_counter += 1 + mode_options_map[str(option_counter)] = mode # Bilde die numerische Option auf den Modusnamen ab + mode_options_map[mode] = mode # Bilde den Modusnamen (kleingeschrieben) auf sich selbst ab (fuer direkte Eingabe) + option_counter += 1 # Erhoehe den Zaehler - # Fügen Sie eine Option zum Abbrechen hinzu + + # Fuegen Sie eine Option zum Abbrechen hinzu print(f"\n 0: Abbrechen") - mode_options_map['0'] = 'exit' + mode_options_map['0'] = 'exit' # Bilde 0 auf den speziellen 'exit' Modus ab - while selected_mode is None: # Schleife, bis ein gültiger Modus gewählt wurde + # Schleife, bis ein gueltiger Modus gewaehlt wurde oder der Benutzer abbricht + while selected_mode is None: try: + # Lesen Sie die Eingabe vom Benutzer mode_input = input(f"Geben Sie den Modusnamen oder die Zahl ein: ").strip().lower() + # Pruefen Sie, ob die Eingabe einer Option in der Map entspricht if mode_input in mode_options_map: - selected_mode = mode_options_map[mode_input] - if selected_mode == 'exit': - logger.info("Modus 'exit' gewählt. Skript wird beendet.") - print("Abgebrochen.") - return # Skript beenden - logger.info(f"Betriebsmodus (interaktiv gewählt): {selected_mode}") - else: - print("Ungültige Eingabe. Bitte wählen Sie eine gültige Option.") + selected_mode = mode_options_map[mode_input] # Setzen Sie den gewaehlten Modusnamen - except EOFError: # Benutzer hat Ctrl+D gedrückt + # Wenn der 'exit' Modus gewaehlt wurde + if selected_mode == 'exit': + logger.info("Modus 'exit' gewaehlt. Skript wird beendet.") + print("Abgebrochen durch Benutzer.") + return # Beende das Skript + + # Logge den gewaehlten Modus + logger.info(f"Betriebsmodus (interaktiv gewaehlt): {selected_mode}") + + else: + # Wenn die Eingabe keinem gueltigen Modus entspricht + print("Ungueltige Eingabe. Bitte waehlen Sie eine gueltige Option aus der Liste.") + + # Wenn selected_mode immer noch None ist, laeuft die Schleife weiter + + + except EOFError: # Benutzer hat Ctrl+D gedrueckt (End-of-File) + # Fangen Sie das EOFError ab und beenden Sie das Skript sauber. logger.warning("Interaktive Modus-Eingabe abgebrochen (EOFError). Skript wird beendet.") print("\nEingabe abgebrochen.") - return + return # Beende das Skript except Exception as e: + # Fangen Sie andere unerwartete Fehler bei der Eingabe ab logger.error(f"Fehler bei interaktiver Modus-Eingabe: {e}") logger.debug(traceback.format_exc()) - print(f"Fehler bei der Modus-Eingabe ({e}).") - return # Skript beenden bei unerwartetem Fehler + print(f"Ein Fehler ist bei der Modus-Eingabe aufgetreten ({e}). Bitte pruefen Sie die Logdatei.") + return # Beende das Skript bei unerwartetem Fehler - # --- Ausführung des gewählten Modus --- + + # --- Ausfuehrung des gewaehlten Modus --- try: - # Holen Sie die CLI-Argumente für Start/End/Limit/Steps + # Holen Sie die CLI-Argumente fuer Start/End/Limit/Steps limit_arg = args.limit start_row_arg = args.start_sheet_row end_row_arg = args.end_sheet_row - # Sonderbehandlung für --steps Argument (relevant für reeval und full_run) - steps_to_run_set = set() - if args.steps and args.steps.lower() != 'all': + # Sonderbehandlung fuer --steps Argument (relevant fuer reeval und full_run) + steps_to_run_set = set() # Initialisiere ein leeres Set + # Pruefen Sie, ob das --steps Argument gesetzt ist und nicht "all" (case-insensitive) + if args.steps and isinstance(args.steps, str) and args.steps.strip().lower() != 'all': + # Teilen Sie den String in Schritte auf und bereinigen Sie Leerzeichen steps_list = [step.strip().lower() for step in args.steps.split(',') if step.strip()] - # Filtern Sie nur erlaubte Schritte - steps_to_run_set = set(step for step in steps_list if step in valid_reeval_steps) + # Filtern Sie nur erlaubte Schritte (die von _process_single_row verstanden werden Block 19) + steps_to_run_set = set(step for step in steps_list if step in valid_single_row_steps) # valid_single_row_steps wurde oben definiert + + # Logge eine Warnung, wenn ungueltige Schritte angegeben wurden if len(steps_to_run_set) != len(steps_list): - invalid_steps = [step for step in steps_list if step not in valid_reeval_steps] - logger.warning(f"Ignoriere ungültige Schritte im --steps Argument: {invalid_steps}. Führe nur {steps_to_run_set} aus.") + invalid_steps = [step for step in steps_list if step not in valid_single_row_steps] + logger.warning(f"Ignoriere ungueltige Schritte im --steps Argument: {invalid_steps}. Fuehre nur {steps_to_run_set} aus.") + + # Wenn nach der Filterung keine gueltigen Schritte uebrig sind if not steps_to_run_set: - logger.error("Keine gültigen Schritte im --steps Argument gefunden. Re-Eval/Full-Run kann nicht gestartet werden.") - print("Fehler: Keine gültigen Schritte für den Modus ausgewählt.") - return # Skript beenden, wenn keine Schritte ausgewählt sind - else: # --steps ist 'all' oder nicht gesetzt - steps_to_run_set = set(valid_reeval_steps) # Führe standardmäßig alle Re-Eval/Full-Run Schritte aus + logger.error("Keine gueltigen Schritte im --steps Argument gefunden. Re-Eval/Full-Run kann nicht gestartet werden.") + print("Fehler: Keine gueltigen Schritte fuer den Modus ausgewaehlt. Bitte ueberpruefen Sie das --steps Argument.") + return # Skript beenden, wenn keine Schritte ausgewaehlt sind + + # Wenn das --steps Argument 'all' ist oder nicht gesetzt + else: + # Fuhren Sie standardmaessig alle gueltigen Single-Row Schritte aus. + steps_to_run_set = set(valid_single_row_steps) # valid_single_row_steps wurde oben definiert + # Logge, welche Schritte ausgewaehlt wurden, wenn es der Standard ist + if default_steps_arg: # Wenn es ueberhaupt gueltige Schritte gibt + logger.debug(f"--steps Argument 'all' oder nicht gesetzt. Standard Schritte: {steps_to_run_set}.") - # Dispatching basierend auf dem gewählten Modus - logger.info(f"Starte Ausführung des Modus: {selected_mode}") + # Dispatching basierend auf dem gewaehlten Modus (selected_mode) + logger.info(f"Starte Ausfuehrung des Modus: {selected_mode}") + # ---- Kombinierte LÄUFE ---- if selected_mode == "combined_all": # Führt die wichtigsten Batch-Modi nacheinander aus logger.info("--- Start Kombinierter Modus: wiki_verify ---") + # Rufe die Methode der DataProcessor Instanz auf (Block 26) data_processor.process_verification_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) logger.info("--- Start Kombinierter Modus: website_scraping ---") + # Rufe die Methode der DataProcessor Instanz auf (Block 27) data_processor.process_website_scraping_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) logger.info("--- Start Kombinierter Modus: summarize_website ---") + # Rufe die Methode der DataProcessor Instanz auf (Block 28) data_processor.process_summarization_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) logger.info("--- Start Kombinierter Modus: branch_eval ---") + # Rufe die Methode der DataProcessor Instanz auf (Block 29) data_processor.process_branch_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) - # TODO: Fügen Sie hier weitere Batch-Modi hinzu, falls sie im kombinierten Lauf enthalten sein sollen + # TODO: Fuegen Sie hier weitere Batch-Modi hinzu, falls sie im kombinierten Lauf enthalten sein sollen + logger.info("--- Kombinierter Modus abgeschlossen ---") - elif selected_mode == "wiki_verify": + + # ---- Batch-VERARBEITUNG (Schritt-Optimiert) ---- + elif selected_mode == "wiki_verify": # Entspricht dem Batch-Modus Wiki Verifizierung (AX) + # Rufe die Methode der DataProcessor Instanz auf (Block 26) data_processor.process_verification_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) - elif selected_mode == "website_scraping": + elif selected_mode == "website_scraping": # Entspricht dem Batch-Modus Website Scraping (AR, AT, AP) + # Rufe die Methode der DataProcessor Instanz auf (Block 27) data_processor.process_website_scraping_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) - elif selected_mode == "summarize_website": + elif selected_mode == "summarize_website": # Entspricht dem Batch-Modus Website Summarization (AS, AP) + # Rufe die Methode der DataProcessor Instanz auf (Block 28) data_processor.process_summarization_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) - elif selected_mode == "branch_eval": + elif selected_mode == "branch_eval": # Entspricht dem Batch-Modus Branchen-Einstufung (W-Y, AO, AP) + # Rufe die Methode der DataProcessor Instanz auf (Block 29) data_processor.process_branch_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) - elif selected_mode == "reeval": - # reeval Modus nutzt immer force_reeval=True in _process_single_row - data_processor.process_reevaluation_rows( - row_limit=limit_arg, - clear_flag=True, # Standardmäßig 'x' löschen - # Übergeben Sie die aus dem --steps Argument ermittelten Schritte - process_wiki_steps='wiki' in steps_to_run_set, - process_chatgpt_steps='chat' in steps_to_run_set, - process_website_steps='web' in steps_to_run_set - # TODO: Weitere Schritt-Flags hier übergeben - ) - elif selected_mode == "full_run": - # Full_run verarbeitet sequenziell einen Bereich. + # ---- Sequentielle VERARBEITUNG (Zeilenweise) ---- + elif selected_mode == "full_run": # Nutzt process_rows_sequentially (Block 24) + # Full_run verarbeitet sequentiell einen Bereich. # Startzeile wird vom CLI Argument oder automatisch ermittelt (erste leere AO). # Endzeile vom CLI Argument oder bis Ende Sheet. # Limit begrenzt die Anzahl der *verarbeiteten* Zeilen im Bereich. - calculated_start_sheet_row = start_row_arg # Beginne mit CLI Argument + calculated_start_sheet_row = start_row_arg # Beginne mit CLI Argument start_sheet_row + # Wenn start_sheet_row nicht ueber CLI gesetzt wurde if calculated_start_sheet_row is None: # Automatische Ermittlung der Startzeile (erste Zeile ohne AO) - logger.info("Automatische Ermittlung der Startzeile für sequenzielle Verarbeitung (erste Zeile ohne AO)...") - # get_start_row_index gibt 0-basierten Index in Daten (ohne Header) zurück - start_data_index_no_header = sheet_handler.get_start_row_index(check_column_key="Timestamp letzte Prüfung") + logger.info("Automatische Ermittlung der Startzeile fuer sequenzielle Verarbeitung (erste Zeile ohne AO)...") + # get_start_row_index (Block 14) gibt 0-basierten Index in Daten (ohne Header) zurueck. + # Prueft auf leeren AO (Block 1 Column Map). + start_data_index_no_header = sheet_handler.get_start_row_index(check_column_key="Timestamp letzte Pruefung", min_sheet_row=7) + + # Wenn get_start_row_index -1 zurueckgibt (Fehler) if start_data_index_no_header == -1: - logger.error("FEHLER bei automatischer Ermittlung der Startzeile.") - # Laden fehlgeschlagen, Skript muss beendet werden - return + logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Kann Full-Run nicht starten.") + return # Beende das Skript - calculated_start_sheet_row = start_data_index_no_header + sheet_handler._header_rows + 1 # 1-basierte Sheet-Zeile + # Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten Daten-Index + calculated_start_sheet_row = start_data_index_no_header + sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut - # Berechnen Sie die tatsächliche Anzahl der zu verarbeitenden Zeilen im Bereich + # Berechnen Sie die tatsaechliche Anzahl der zu verarbeitenden Zeilen im Bereich. # (basierend auf Endzeile und Limit) - total_sheet_rows = len(sheet_handler.get_all_data_with_headers()) + total_sheet_rows = len(sheet_handler.get_all_data_with_headers()) # Block 14 SheetHandler calculated_end_sheet_row = end_row_arg if end_row_arg is not None else total_sheet_rows - # Der zu betrachtende Bereich ist [calculated_start_sheet_row, calculated_end_sheet_row] + # Stellen Sie sicher, dass die Endzeile nicht vor der Startzeile liegt + calculated_end_sheet_row = max(calculated_start_sheet_row - 1, calculated_end_sheet_row) - # Anzahl der Zeilen im betrachteten Bereich + + # Die Anzahl der Zeilen im betrachteten Bereich rows_in_range = max(0, calculated_end_sheet_row - calculated_start_sheet_row + 1) - # num_to_process ist das Limit, angewendet auf die Zeilen im Bereich - num_to_process = rows_in_range # Standard: alle Zeilen im Bereich - if limit_arg is not None and limit_arg >= 0: - num_to_process = min(rows_in_range, limit_arg) + # num_to_process ist das Limit, angewendet auf die Zeilen im Bereich. + num_to_process_calc = rows_in_range # Standard: alle Zeilen im Bereich + # Wenn ein Limit ueber CLI gesetzt wurde und es gueltig ist + if limit_arg is not None and isinstance(limit_arg, int) and limit_arg >= 0: + num_to_process_calc = min(rows_in_range, limit_arg) - if num_to_process > 0: - logger.info(f"'full_run': Verarbeite {num_to_process} Zeilen im Sheet-Bereich [{calculated_start_sheet_row}, {calculated_end_sheet_row}].") - # Rufen Sie die sequenzielle Verarbeitungsmethode auf + # Wenn es Zeilen zu verarbeiten gibt + if num_to_process_calc > 0: + logger.info(f"'full_run': Verarbeite {num_to_process_calc} Zeilen im Sheet-Bereich [{calculated_start_sheet_row}, {calculated_end_sheet_row}].") + # Rufe die sequentielle Verarbeitungsmethode auf (Block 24) + # _process_single_row (Block 19) wird intern aufgerufen. data_processor.process_rows_sequentially( start_sheet_row = calculated_start_sheet_row, - num_to_process = num_to_process, - # Übergeben Sie die aus dem --steps Argument ermittelten Flags + num_to_process = num_to_process_calc, + # Uebergeben Sie die aus dem --steps Argument ermittelten Flags (steps_to_run_set) process_wiki_steps='wiki' in steps_to_run_set, process_chatgpt_steps='chat' in steps_to_run_set, - process_website_steps='web' in steps_to_run_set - # TODO: Weitere Schritt-Flags hier übergeben + process_website_steps='web' in steps_to_run_set, + process_ml_steps='ml_predict' in steps_to_run_set + # TODO: Weitere Schritt-Flags hier uebergeben # force_reeval_in_single_row=False # Normalerweise kein Re-Eval im Full-Run + # clear_x_flag=False # Normalerweise kein X loeschen im Full-Run ) else: - logger.info(f"Keine Zeilen für 'full_run' zu verarbeiten im Bereich [{calculated_start_sheet_row}, {calculated_end_sheet_row}] mit Limit {limit_arg}.") + # Wenn keine Zeilen zu verarbeiten sind + logger.info(f"Keine Zeilen fuer 'full_run' zu verarbeiten im Bereich [{calculated_start_sheet_row}, {calculated_end_sheet_row}] mit Limit {limit_arg}.") - elif selected_mode == "find_wiki_serp": - # find_wiki_serp nutzt limit, min_employees, min_umsatz und automatische Startzeile (leere AY) + # ---- Re-EVALUATE Markierte Zeilen ---- + elif selected_mode == "reeval": # Nutzt process_reevaluation_rows (Block 25) + # reeval Modus nutzt immer force_reeval=True in _process_single_row. + # Das 'x'-Flag wird von _process_single_row (Block 21) geloescht, wenn clear_flag=True uebergeben wird. + # Das Limit wird direkt an process_reevaluation_rows uebergeben und dort gehandhabt. + if limit_arg is not None and isinstance(limit_arg, int) and limit_arg <= 0: + # Wenn ein Limit von 0 oder weniger angegeben wurde + logger.info(f"Limit {limit_arg} angegeben im Re-Eval Modus. Ueberspringe Verarbeitung.") + else: + # Rufe die Methode der DataProcessor Instanz auf (Block 25) + data_processor.process_reevaluation_rows( + row_limit=limit_arg, # Uebergibt das Limit (kann None sein) + clear_flag=True, # Standardmaessig das 'x'-Flag loeschen + # Uebergeben Sie die aus dem --steps Argument ermittelten Schritte (steps_to_run_set) + process_wiki_steps='wiki' in steps_to_run_set, + process_chatgpt_steps='chat' in steps_to_run_set, + process_website_steps='web' in steps_to_run_set, + process_ml_steps='ml_predict' in steps_to_run_set + # TODO: Weitere Schritt-Flags hier uebergeben + ) + + + # ---- Einzelne DIENSTPROGRAMME / SUCHEN ---- + elif selected_mode == "find_wiki_serp": # Nutzt process_find_wiki_serp (Block 30) + # find_wiki_serp sucht leere AY mit Groessenfilter. Nutzt limit, min_employees, min_umsatz. + # Start/Endzeile koennen manuell gesetzt werden oder werden automatisch ermittelt (erste leere AY). data_processor.process_find_wiki_serp( start_sheet_row=start_row_arg, # Kann manuell gesetzt werden end_sheet_row=end_row_arg, # Kann manuell gesetzt werden - limit=limit_arg, - min_employees=args.min_employees, - min_umsatz=args.min_umsatz + limit=limit_arg, # Kann manuell gesetzt werden + min_employees=args.min_employees, # Aus CLI Argument + min_umsatz=args.min_umsatz # Aus CLI Argument ) - elif selected_mode == "website_lookup": - # website_lookup sucht leere D. Nutzt limit und scannt ab Zeile 7 standardmäßig. + elif selected_mode == "website_lookup": # Nutzt process_serp_website_lookup (Block 30) + # website_lookup sucht leere D. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden. data_processor.process_serp_website_lookup( start_sheet_row=start_row_arg, # Kann manuell gesetzt werden end_sheet_row=end_row_arg, # Kann manuell gesetzt werden - limit=limit_arg + limit=limit_arg # Kann manuell gesetzt werden ) - elif selected_mode == "contacts": - # contacts sucht leere AM. Nutzt limit und scannt ab Zeile 7 standardmäßig. + elif selected_mode == "contacts": # Nutzt process_contact_search (Block 30) + # contacts sucht leere AM. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden. data_processor.process_contact_search( start_sheet_row=start_row_arg, # Kann manuell gesetzt werden end_sheet_row=end_row_arg, # Kann manuell gesetzt werden - limit=limit_arg + limit=limit_arg # Kann manuell gesetzt werden ) - elif selected_mode == "update_wiki_suggestions": - # update_wiki_suggestions prüft Status S. Nutzt limit und scannt ab Zeile 7 standardmäßig. + elif selected_mode == "update_wiki_suggestions": # Nutzt process_wiki_updates_from_chatgpt (Block 32) + # update_wiki_suggestions prueft Status S. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden. data_processor.process_wiki_updates_from_chatgpt( start_sheet_row=start_row_arg, # Kann manuell gesetzt werden end_sheet_row=end_row_arg, # Kann manuell gesetzt werden - limit=limit_arg + limit=limit_arg # Kann manuell gesetzt werden ) - elif selected_mode == "train_technician_model": - # training braucht keine Zeilenlimits, verwendet prepare_data_for_modeling - data_processor.train_technician_model( - model_out=args.model_out, - imputer_out=args.imputer_out, - patterns_out=args.patterns_out # Dies ist jetzt die JSON Datei - ) - - elif selected_mode == "alignment": - # alignment_demo ist eine globale Funktion, die das sheet Objekt braucht - if sheet_handler and sheet_handler.sheet: - alignment_demo(sheet_handler.sheet) - else: - logger.error("Sheet-Handler oder Sheet-Objekt nicht verfügbar für Alignment-Demo.") - - - elif selected_mode == "wiki_reextract_missing_an": - # wiki_reextract_missing_an sucht M gefüllt & AN leer. Nutzt limit und scannt ab Zeile 7. + elif selected_mode == "wiki_reextract_missing_an": # Nutzt process_wiki_reextract_missing_an (Block 32) + # wiki_reextract_missing_an sucht M gefuellt & AN leer. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden. + # Ruft intern _process_single_row mit steps={'wiki'} und force_reeval=True auf. data_processor.process_wiki_reextract_missing_an( start_sheet_row=start_row_arg, # Kann manuell gesetzt werden end_sheet_row=end_row_arg, # Kann manuell gesetzt werden - limit=limit_arg + limit=limit_arg # Kann manuell gesetzt werden ) - elif selected_mode == "website_details": - # website_details sucht 'x' in A. Nutzt limit und scannt ab Zeile 7. + + elif selected_mode == "website_details": # EXPERIMENTELL - Nutzt process_website_details (Block 32) + # website_details sucht 'x' in A. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden. data_processor.process_website_details( start_sheet_row=start_row_arg, # Kann manuell gesetzt werden end_sheet_row=end_row_arg, # Kann manuell gesetzt werden - limit=limit_arg + limit=limit_arg # Kann manuell gesetzt werden ) + elif selected_mode == "train_technician_model": # Nutzt train_technician_model (Block 31) + # training braucht keine Zeilenlimits im Sinne eines Bereichs oder der Anzahl zu verarbeitender Zeilen im Sheet. + # Es nutzt prepare_data_for_modeling (Block 31), die alle relevanten Zeilen filtert. + # Die output-Pfade werden aus CLI Argumenten genommen (args). + data_processor.train_technician_model( + model_out=args.model_out, # Aus CLI Argument + imputer_out=args.imputer_out, # Aus CLI Argument + patterns_out=args.patterns_out # Aus CLI Argument (JSON Datei) + ) + + elif selected_mode == "alignment": # Nutzt globale alignment_demo (Block 14) + # alignment_demo ist eine globale Funktion, die das sheet Objekt braucht. + # Sie braucht keine Zeilenlimits oder Start/Ende. + if sheet_handler and sheet_handler.sheet: + alignment_demo(sheet_handler.sheet) + else: + logger.error("Sheet-Handler oder Sheet-Objekt nicht verfuegbar fuer Alignment-Demo.") + + + # ---- Modus nicht gefunden (sollte durch Validierung oben abgefangen werden) ---- else: - # Dieser Zweig sollte aufgrund der Validierung am Anfang nie erreicht werden - logger.error(f"Unerwarteter Modus '{selected_mode}' erreichte das Ausführungsende des Dispatchers.") + # Dieser Zweig sollte aufgrund der Validierung am Anfang nie erreicht werden. + logger.error(f"Unerwarteter Modus '{selected_mode}' erreichte das Ausfuehrungsende des Dispatchers.") print(f"Interner Fehler: Unbekannter Modus '{selected_mode}'.") + # --- Ausnahmebehandlung fuer den gesamten Ausfuehrungsblock --- except KeyboardInterrupt: + # Wenn der Benutzer das Skript manuell unterbricht (Ctrl+C) logger.warning("Skript durch Benutzer unterbrochen (KeyboardInterrupt).") print("\n! Skript wurde manuell beendet.") except Exception as e: - # Dieser Block fängt Fehler ab, die in den aufgerufenen Funktionen/Methoden passieren - logger.critical(f"FATAL: Unerwarteter Fehler während der Ausführung von Modus '{selected_mode}': {e}") - # exception() loggt den Fehlertyp, die Nachricht und den Traceback + # Dieser Block faengt alle unerwarteten Exceptions ab, die in den aufgerufenen + # Funktionen/Methoden passieren und nicht intern gefangen und behandelt werden. + logger.critical(f"FATAL: Unerwarteter Fehler waehrend der Ausfuehrung von Modus '{selected_mode}': {e}") + # exception() loggt den Fehlertyp, die Nachricht und den vollständigen Traceback. logger.exception("Traceback des kritischen Fehlers:") + # Gebe eine Fehlermeldung an die Konsole aus, die auf das Log verweist. print(f"\n! Ein kritischer Fehler ist aufgetreten: {type(e).__name__} - {e}") - print(f"Bitte prüfen Sie die Logdatei für Details: {LOG_FILE}") + print(f"Bitte pruefen Sie die Logdatei fuer Details: {LOG_FILE}") - # --- Abschluss --- - end_time = time.time() - duration = end_time - start_time + # --- Abschluss der Skriptausfuehrung --- + end_process_time = time.time() # Ende der Zeitmessung + duration = end_process_time - start_process_time # Berechne die Gesamtdauer logger.info(f"Verarbeitung abgeschlossen um {datetime.now().strftime('%H:%M:%S')}.") logger.info(f"Gesamtdauer: {duration:.2f} Sekunden.") logger.info(f"===== Skript beendet =====") - # Schließe Logging Handler explizit + # Schliesse Logging Handler explizit + # Dies stellt sicher, dass alle gepufferten Logmeldungen in die Datei geschrieben werden. logging.shutdown() - # Logfile Pfad für den Nutzer ausgeben (geht auf Konsole) + # Logfile Pfad fuer den Nutzer auf der Konsole ausgeben if LOG_FILE: print(f"\nVerarbeitung abgeschlossen. Logfile: {LOG_FILE}") else: @@ -7429,25 +10757,1757 @@ def main(): # ============================================================================== -# 9. ENTRY POINT +# 7. ENTRY POINT # ============================================================================== -# Führt die main-Funktion aus, wenn das Skript direkt gestartet wird +# Fuehrt die main-Funktion aus, wenn das Skript direkt gestartet wird. if __name__ == '__main__': - # Stellen Sie sicher, dass alle globalen Imports am Anfang der Datei stehen - # Stellen Sie sicher, dass alle globalen Hilfsfunktionen (retry_on_failure, - # token_count, clean_text, normalize_company_name, simple_normalize_url, - # extract_numeric_value, get_numeric_filter_value, get_gender, get_email_address, - # fuzzy_similarity, is_valid_wikipedia_article_url, serp_wikipedia_lookup, - # serp_website_lookup, search_linkedin_contacts, call_openai_chat, - # summarize_website_content, summarize_batch_openai, - # evaluate_branche_chatgpt, load_target_schema, create_log_filename, - # scrape_website_details (falls implementiert) etc.) vor der main() Funktion - # oder in importierten Modulen definiert sind. - # Stellen Sie sicher, dass alle Klassen (Config, GoogleSheetHandler, WikipediaScraper, - # DataProcessor) vor der main() Funktion definiert sind. + # Die main() Funktion enthaltet nun die gesamte Logik und Initialisierung. + # Alle globalen imports und Funktionen MÜSSEN VOR diesem Block definiert sein. + # Die Klassen MÜSSEN VOR diesem Block definiert sein. - # Die globale Variable LOG_FILE muss vor main() initialisiert werden (z.B. LOG_FILE = None) - # und wird dann in main() gesetzt. + main() + + # ========================================================================== + # === Utility Methods (ML Data Prep & Training) ============================ + # ========================================================================== + + # --- Methode fuer ML Vorhersage (AU) --- + # Diese Methode wird in _process_single_row (Block 21) aufgerufen, wenn der ML-Schritt angefordert ist und noetig ist. + # Sie fuehrt eine Vorhersage des Servicetechniker-Buckets fuer eine einzelne Zeile mit dem trainierten ML-Modell durch. + # Sie nutzt das geladene Modell und den Imputer (Attribute der DataProcessor Instanz). + # Nutzt interne Helfer: _get_cell_value_safe, _load_ml_model (denselben Block). + # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, pandas, numpy, re, clean_text (Block 4), get_valid_numeric (Block 5). + def _predict_technician_bucket(self, row_data): + """ + Fuehrt eine Vorhersage des Servicetechniker-Buckets fuer eine einzelne Zeile + mit dem trainierten ML-Modell durch. Laedt das Modell und den Imputer bei Bedarf. + + Args: + row_data (list): Die Rohdaten fuer die Zeile. + + Returns: + str: Der vorhergesagte Bucket-Label oder "FEHLER Schaetzung" bei Fehler/kein Ergebnis. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Logge den Start der ML-Schaetzung fuer diese Zeile + company_name = self._get_cell_value_safe(row_data, 'CRM Name').strip() # Block 1 Column Map + self.logger.debug(f"Versuche ML-Schaetzung fuer Zeile ({company_name[:50]}...)") # Gekuerzt loggen + + # Laden Sie das Modell, den Imputer und die erwarteten Feature-Spalten, falls noch nicht geschehen. + # Diese werden als Attribute der DataProcessor Instanz gespeichert (_load_ml_model denselben Block). + if self.model is None or self.imputer is None or self._expected_features is None: + self.logger.info("Lade ML-Modell, Imputer und Feature-Spalten...") + try: + # Der Aufruf von _load_ml_model (denselben Block) ist nicht mit retry_on_failure dekoriert, + # da das Laden lokaler Dateien nicht wiederholt werden muss. Fehler deuten auf ein permanentes Problem hin. + self._load_ml_model(MODEL_FILE, IMPUTER_FILE) # Nutzt globale Konstanten (Block 1) + + # Pruefe erneut, ob das Laden erfolgreich war. + if self.model is None or self.imputer is None or self._expected_features is None: + self.logger.error("Laden von Modell, Imputer oder Feature-Spalten fehlgeschlagen. Kann ML-Schaetzung nicht durchfuehren.") + return "FEHLER Schaetzung" # Gebe Fehlerwert zurueck, wenn Laden fehlschlug + + self.logger.info("ML-Modell, Imputer und Feature-Spalten erfolgreich geladen.") + + except Exception as e: + # Fange Fehler beim Laden ab und logge sie. + self.logger.error(f"FEHLER beim Laden von ML-Modell/Imputer/Feature-Spalten: {e}") + # Logge den Traceback. + self.logger.debug(traceback.format_exc()) + # Geben Sie einen Fehlerwert zurueck. + return f"FEHLER Laden: {str(e)[:100]}..." # Signalisiert Ladefehler (gekuerzt) + + + # --- Bereiten Sie die Daten fuer DIESE EINE ZEILE fuer die Vorhersage vor --- + try: + # Diese Logik ist aehnlich wie in prepare_data_for_modeling (denselben Block), + # aber nur fuer eine einzelne Zeile und muss mit den exakt gleichen + # Spaltennamen, Normalisierungs- und Encoding-Schritten arbeiten wie das Training. + + # Hole die benoetigten Spaltenwerte fuer diese Zeile (basierend auf COLUMN_MAP keys Block 1) + row_values = { + # "CRM Name": self._get_cell_value_safe(row_data, "CRM Name"), # Nicht benoetigt fuer Vorhersage + "CRM Branche": self._get_cell_value_safe(row_data, "CRM Branche"), # Block 1 Column Map + "CRM Umsatz": self._get_cell_value_safe(row_data, "CRM Umsatz"), # Block 1 Column Map + "Wiki Umsatz": self._get_cell_value_safe(row_data, "Wiki Umsatz"), # Block 1 Column Map + "CRM Anzahl Mitarbeiter": self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter"), # Block 1 Column Map + "Wiki Mitarbeiter": self._get_cell_value_safe(row_data, "Wiki Mitarbeiter"), # Block 1 Column Map + # Technikerzahl wird fuer die Vorhersage NICHT benoetigt + # "CRM Anzahl Techniker": self._get_cell_value_safe(row_data, "CRM Anzahl Techniker"), + } + + # Erstellen Sie einen temporaeren DataFrame fuer diese eine Zeile aus den extrahierten Werten + df_single_row = pd.DataFrame([row_values]) + + + # --- Konsolidieren Umsatz/Mitarbeiter (Wiki > CRM) --- + # Nutzt globale Funktion get_valid_numeric (Block 5) fuer die Konvertierung. + # Diese Funktion gibt numerische Werte (Float/Int) oder NaN zurueck. + # Stellen Sie sicher, dass die Spalten existieren, bevor apply aufgerufen wird. + # Diese Spalten sollten aus row_values extrahiert worden sein, wenn COLUMN_MAP korrekt ist. + crm_umsatz_series = df_single_row['CRM Umsatz'].apply(get_valid_numeric) if 'CRM Umsatz' in df_single_row.columns else pd.Series(np.nan, index=df_single_row.index) + wiki_umsatz_series = df_single_row['Wiki Umsatz'].apply(get_valid_numeric) if 'Wiki Umsatz' in df_single_row.columns else pd.Series(np.nan, index=df_single_row.index) + crm_ma_series = df_single_row['CRM Anzahl Mitarbeiter'].apply(get_valid_numeric) if 'CRM Anzahl Mitarbeiter' in df_single_row.columns else pd.Series(np.nan, index=df_single_row.index) + wiki_ma_series = df_single_row['Wiki Mitarbeiter'].apply(get_valid_numeric).astype(float) if 'Wiki Mitarbeiter' in df_single_row.columns else pd.Series(np.nan, index=df_single_row.index) # Muss Float sein wie andere numerische + + # np.where waehlt den Wiki-Wert, wenn nicht NaN, sonst den CRM-Wert. + df_single_row['Finaler_Umsatz'] = np.where( + wiki_umsatz_series.notna(), + wiki_umsatz_series, + crm_umsatz_series + ) + + df_single_row['Finaler_Mitarbeiter'] = np.where( + wiki_ma_series.notna(), + wiki_ma_series, + crm_ma_series + ) + + # Pruefen Sie, ob die konsolidierten numerischen Features NaN sind. + # ML-Vorhersage kann nicht durchgefuehrt werden, wenn diese komplett fehlen (werden vom Imputer erwartet). + if pd.isna(df_single_row['Finaler_Umsatz'].iloc[0]) and pd.isna(df_single_row['Finaler_Mitarbeiter'].iloc[0]): + self.logger.debug(f" -> ML-Schaetzung uebersprungen: Konsolidierter Umsatz und Mitarbeiter fehlen fuer Zeile.") + return "k.A. (Daten fehlen)" # Gebe spezifischen Wert zurueck + + + # --- Kategoriale Features (Branche) --- + branche_col_name = "CRM Branche" # Original Header Name aus COLUMN_MAP (Block 1) + # Stellen Sie sicher, dass die Spalte existiert und ein String ist. Fuellen Sie NaNs mit 'Unbekannt'. + if branche_col_name not in df_single_row.columns: + self.logger.warning(f"Spalte '{branche_col_name}' nicht im DataFrame fuer ML-Vorhersage gefunden. Behandle als 'Unbekannt'.") + df_single_row[branche_col_name] = 'Unbekannt' # Setze einen Default-Wert + + df_single_row[branche_col_name] = df_single_row[branche_col_name].astype(str).fillna('Unbekannt').str.strip() + + + # One-Hot Encoding + # WICHTIG: Muss alle BRANCHEN aus dem TRAININGSDATENSATZ (self._expected_features) enthalten, + # auch wenn diese in der einzelnen Zeile nicht vorkommen. + # pd.get_dummies erstellt Spalten nur fuer die Kategorien in df_single_row. + df_encoded = pd.get_dummies(df_single_row, columns=[branche_col_name], prefix='Branche', dummy_na=False) # dummy_na=False, da NaNs gefuellt + + + # Fugen Sie fehlende Feature-Spalten hinzu (die im Training vorhanden waren, aber in dieser Zeile nicht). + # Stellen Sie die Reihenfolge der Spalten sicher, so wie sie im Training waren (self._expected_features). + # self._expected_features wird von _load_ml_model (denselben Block) geladen. + if self._expected_features is None: + self.logger.error("FEHLER: Erwartete Feature-Spalten fuer ML-Vorhersage nicht geladen. Kann nicht vorhersagen.") + return "FEHLER Schaetzung" # Gebe Fehlerwert zurueck + + # Erstellen Sie einen neuen DataFrame mit allen erwarteten Features und fuellen Sie fehlende mit 0. + # Sicherstellen, dass die Spalten im Ergebnis-DF in der Reihenfolge von self._expected_features sind. + df_processed = pd.DataFrame(columns=self._expected_features) + # Kopieren Sie die Werte aus df_encoded, wo Spalten uebereinstimmen. + for col in self._expected_features: + if col in df_encoded.columns: + df_processed[col] = df_encoded[col] + else: + df_processed[col] = 0 # Fuege fehlende Spalten mit 0 hinzu + + # Stellen Sie sicher, dass die numerischen Spalten Float sind (Imputer erwartet das oft) + numeric_features_for_imputation = ['Finaler_Umsatz', 'Finaler_Mitarbeiter'] + for col in numeric_features_for_imputation: + if col in df_processed.columns: + df_processed[col] = pd.to_numeric(df_processed[col], errors='coerce') # Wandelt NaN in NaN, Fehler in NaN + + + # --- Imputation der fehlenden Werte --- + # Muss konsistent mit dem Imputer aus dem Training sein. + # Der Imputer (self.imputer) wird auf die vorbereiteten Features angewendet. + if self.imputer is None: + self.logger.error("FEHLER: ML-Imputer ist nicht geladen. Kann nicht imputieren/vorhersagen.") + return "FEHLER Schaetzung" # Gebe Fehlerwert zurueck + + # Imputer.transform gibt ein Numpy Array zurueck. + df_imputed_array = self.imputer.transform(df_processed) + # Konvertiere das Ergebnis zurueck zu einem DataFrame mit den erwarteten Spaltennamen. + df_imputed = pd.DataFrame(df_imputed_array, columns=self._expected_features) + + # Optional: Pruefen Sie, ob nach Imputation NaNs verbleiben (sollte nicht passieren bei SimpleImputer) + # if df_imputed.isna().any().any(): + # self.logger.warning("WARNUNG: NaNs verbleiben nach Imputation.") + + + # --- Vorhersage --- + # Das Decision Tree Modell (self.model) erwartet die vorbereiteten und imputierten Features. + if not self.model: + self.logger.error("FEHLER: ML-Modell ist nicht geladen. Kann nicht vorhersagen.") + return "FEHLER Schaetzung" # Gebe Fehlerwert zurueck + + + # Fuehren Sie die Vorhersage durch. + # predict_proba gibt die Wahrscheinlichkeiten fuer jede Klasse zurueck. + prediction_proba = self.model.predict_proba(df_imputed) + # prediction_proba ist ein Array von Wahrscheinlichkeiten pro Klasse fuer jede Eingabezeile (hier nur 1 Zeile). + + # Die Klassen-Labels des Modells (z.B. ['Bucket_1', 'Bucket_2', ...]) + model_classes = self.model.classes_ + + # Finden Sie den Index der Klasse mit der hoechsten Wahrscheinlichkeit fuer die erste (und einzige) Zeile. + predicted_class_index = np.argmax(prediction_proba[0]) + # Holen Sie das entsprechende Label aus den Modell-Klassen. + predicted_bucket_label = model_classes[predicted_class_index] + + # Logge die Vorhersage auf Debug-Level + self.logger.debug(f" -> ML Vorhersage Ergebnis: '{predicted_bucket_label}' (Wahrscheinlichkeiten: {prediction_proba[0]})") + + + return predicted_bucket_label # Gebe das vorhergesagte Bucket-Label zurueck (String) + + except Exception as e: + # Fange alle unerwarteten Fehler ab, die waehrend der Datenvorbereitung oder Vorhersage auftreten. + self.logger.exception(f"FEHLER bei der Datenvorbereitung/Vorhersage fuer Zeile (ML): {e}") # Logge Fehler und Traceback + # Geben Sie einen Fehlerwert zurueck, der im Sheet gespeichert werden kann. + return f"FEHLER Schaetzung: {str(e)[:100]}..." # Signalisiert Fehler bei der Schaetzung (gekuerzt) + + + # --- Methode zum Laden des ML Modells und Imputers --- + # Diese Methode wird von _predict_technician_bucket (denselben Block) und train_technician_model (denselben Block) aufgerufen. + # Sie laedt die serialisierten Modelle von der Festplatte. + # Nutzt globale Helfer: MODEL_FILE (Block 1), IMPUTER_FILE (Block 1), PATTERNS_FILE_JSON (Block 1), + # logger, os, pickle, json. + def _load_ml_model(self, model_path, imputer_path): + """ + Laedt das trainierte ML-Modell, den Imputer und die erwarteten Feature-Spalten + von den definierten Dateipfaden. Speichert sie als Instanzattribute. + + Args: + model_path (str): Dateipfad zum Modell (.pkl). + imputer_path (str): Dateipfad zum Imputer (.pkl). + # Der Pfad zur Feature-Spaltenliste (JSON) wird aus PATTERNS_FILE_JSON (Block 1) geholt. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Setzen Sie die Instanzattribute zunaechst auf None + self.model = None + self.imputer = None + self._expected_features = None # Liste der erwarteten Feature-Spalten fuer Vorhersage + + try: + # Pruefen Sie, ob die Modelldateien existieren + if not os.path.exists(model_path): + self.logger.error(f"ML-Modell Datei nicht gefunden: {model_path}") + return # Beende die Methode, wenn die Datei fehlt + if not os.path.exists(imputer_path): + self.logger.error(f"Imputer Datei nicht gefunden: {imputer_path}") + return # Beende die Methode, wenn die Datei fehlt + + # Laden Sie das serialisierte Modell + with open(model_path, 'rb') as f: + self.model = pickle.load(f) + self.logger.info(f"ML-Modell '{model_path}' erfolgreich geladen.") + # Loggen Sie die Klassen-Labels des geladenen Modells zur Info + if hasattr(self.model, 'classes_'): + self.logger.debug(f"Geladene Modell-Klassen: {self.model.classes_}") + else: + self.logger.debug("Geladenes Modell hat kein 'classes_' Attribut.") + + + # Laden Sie den serialisierten Imputer + with open(imputer_path, 'rb') as f: + self.imputer = pickle.load(f) + self.logger.info(f"Imputer '{imputer_path}' erfolgreich geladen.") + + + # Laden Sie die Liste der erwarteten Feature-Spalten (JSON-Datei wird empfohlen) + expected_features_path = PATTERNS_FILE_JSON # Nutzt globale Konstante (Block 1) + # Pruefen Sie, ob die Feature-Spalten-Datei existiert + if os.path.exists(expected_features_path): + try: + # Oeffnen Sie die JSON-Datei + with open(expected_features_path, 'r', encoding='utf-8') as f: + # Laden Sie die Daten aus der JSON-Datei + data = json.load(f) + # Annahme: Die JSON-Datei enthaelt eine Liste der Feature-Spalten unter dem Schluessel "feature_columns" + self._expected_features = data.get("feature_columns") + # Pruefen Sie, ob die geladenen Daten eine nicht-leere Liste sind. + if self._expected_features and isinstance(self._expected_features, list): + self.logger.info(f"Erwartete Feature-Spalten ({len(self._expected_features)}) aus '{expected_features_path}' geladen.") + # Loggen Sie die ersten paar erwarteten Features auf Debug + # self.logger.debug(f"Erwartete Features (erste 5): {self._expected_features[:5]}...") # Zu viel Laerm im Debug + else: + # Wenn die geladenen Daten nicht das erwartete Format haben oder leer sind + self.logger.error(f"Formatfehler in '{expected_features_path}' oder Schluessel 'feature_columns' fehlt/ist leer. ML-Vorhersage koennte fehlschlagen.") + self._expected_features = None # Setze auf None bei Fehler + + except Exception as e_json: + # Fangen Sie Fehler beim Laden oder Parsen der JSON-Datei ab + self.logger.error(f"FEHLER beim Laden oder Parsen der Feature-Spalten Datei '{expected_features_path}': {e_json}") + # Logge den Traceback + self.logger.debug(traceback.format_exc()) + self._expected_features = None # Setze auf None bei Fehler + + else: + # Wenn die Feature-Spalten-Datei nicht gefunden wird + self.logger.warning(f"Datei mit erwarteten Feature-Spalten '{expected_features_path}' nicht gefunden. ML-Vorhersage koennte fehlschlagen.") + self._expected_features = None # Setze auf None, da die Datei fehlt + + + # Fallback: Wenn expected_features nicht geladen werden konnte, versuchen Sie es aus Imputer/Modell zu extrahieren (wenn die Bibliothek es unterstuetzt) + if self._expected_features is None: + try: + # Neuere Scikit-learn Versionen haben oft ein feature_names_in_ Attribut + if hasattr(self.imputer, 'feature_names_in_') and self.imputer.feature_names_in_ is not None: + self._expected_features = list(self.imputer.feature_names_in_) + self.logger.info(f"Erwartete Feature-Spalten ({len(self._expected_features)}) aus Imputer geladen (Fallback).") + elif hasattr(self.model, 'feature_names_in_') and self.model.feature_names_in_ is not None: + self._expected_features = list(self.model.feature_names_in_) + self.logger.info(f"Erwartete Feature-Spalten ({len(self._expected_features)}) aus Modell geladen (Fallback).") + else: + # Wenn es nirgends gefunden werden konnte + self.logger.error("Konnte erwartete Feature-Spalten weder aus Datei noch aus Modell/Imputer extrahieren. ML-Vorhersage wird fehlschlagen.") + self._expected_features = None + except Exception as e_extract: + # Fangen Sie Fehler beim Extrahieren aus Modell/Imputer ab + self.logger.error(f"FEHLER beim Extrahieren der Feature-Namen aus Modell/Imputer (Fallback): {e_extract}") + # Logge den Traceback + self.logger.debug(traceback.format_exc()) + self._expected_features = None + + + except Exception as e: + # Fange alle anderen unerwarteten Fehler waehrend des Ladens ab + self.logger.exception(f"FEHLER beim Laden von ML-Artefakten: {e}") # Logge Fehler und Traceback + # Setzen Sie die Attribute auf None bei Fehler + self.model = None + self.imputer = None + self._expected_features = None + # Die Methode endet implizit hier nach dem Fangen der Exception. + + + # Methode zur Datenvorbereitung fuer ML (WIRD VON train_technician_model aufgerufen) + # Diese Methode laedt alle relevanten Daten aus dem Sheet, bereitet sie auf + # und gibt einen DataFrame fuer das Training zurueck. + # Basierend auf prepare_data_for_modeling aus Teil 12/13. + # Nutzt interne Helfer: _get_cell_value_safe. + # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, pandas, numpy, re, + # clean_text (Block 4), normalize_string (Block 4), get_valid_numeric (Block 5), + # load_target_schema (Block 6 - relevant fuer Branchentypen), traceback. + # Nutzt die uebergeordnete sheet_handler Instanz (Block 14). + def prepare_data_for_modeling(self): + """ + Laedt Daten aus dem Google Sheet ueber den sheet_handler, + bereitet sie fuer das Decision Tree Modell vor: + - Waehlt relevante Spalten aus und benennt sie um. + - Konsolidiert Umsatz/Mitarbeiter (Wiki > CRM Prioritaet). + - Filtert nach gueltiger Technikerzahl (> 0). + - Erstellt die Zielvariable (Techniker-Bucket). + - Bereitet Features auf (One-Hot Encoding fuer Branche). + - Behaelt NaNs in numerischen Features fuer spaetere Imputation. + + Returns: + pandas.DataFrame: Vorbereiteter DataFrame fuer Training/Test-Split, + oder None bei Fehlern oder wenn keine gueltigen Trainingsdaten gefunden wurden. + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + self.logger.info("Starte Datenvorbereitung fuer Modellierung (Training)...") + # Nutzt den self.sheet_handler der Klasse (Block 15). + # Pruefen Sie, ob der Sheet Handler initialisiert wurde und Daten hat. + if not self.sheet_handler or not self.sheet_handler.sheet_values: + self.logger.error("Fehler: Sheet Handler nicht initialisiert oder keine Daten geladen fuer prepare_data_for_modeling.") + # Versuchen Sie die Daten einmalig innerhalb dieser Methode zu laden, falls sie fehlen. + # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2). + if not self.sheet_handler.load_data(): + self.logger.critical("Konnte Daten auch nach erneutem Versuch nicht laden. Abbruch der Datenvorbereitung.") + return None # Gebe None zurueck, wenn Laden fehlschlaegt + + + # Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler. + all_data = self.sheet_handler.get_all_data_with_headers() + # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14). + header_rows = self.sheet_handler._header_rows + # Pruefe auf ausreichende Zeilenzahl (Header + mindestens eine Datenzeile) + min_required_rows = header_rows + 1 + # Wenn nicht genuegend Zeilen da sind + if not all_data or len(all_data) < min_required_rows: + self.logger.error(f"Fehler: Nicht genuegend Datenzeilen ({len(all_data)}) im Sheet gefunden fuer Modellierung (mindestens {min_required_rows} benoetigt).") + return None # Gebe None zurueck, wenn nicht genuegend Daten da sind + + + # --- Header pruefen und DataFrame erstellen --- + try: + # Die erste Zeile sollte die Spaltennamen enthalten. + headers = all_data[0] + # Stellen Sie sicher, dass die Header-Zeile auch die erwartete Mindestlaenge hat, + # um die Spa + + # ========================================================================== + # === Utility Methods (Other Specific Tasks) =============================== + # ========================================================================== + + # --- Methode fuer experimentelle Website Details --- + # Diese Methode extrahiert Details von Websites fuer Zeilen mit 'x'. + # Basierend auf process_website_details_for_marked_rows aus Teil 12. + # Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter. + # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, + # scrape_website_details (Block 13). + # Nutzt die uebergeordnete sheet_handler Instanz (Block 14). + def process_website_details(self, start_sheet_row=None, end_sheet_row=None, limit=None): + """ + EXPERIMENTELL: Extrahiert Website-Details fuer Zeilen, die in Spalte A mit 'x' markiert sind. + Schreibt die Details in eine definierte Spalte (Website Details oder AR als Fallback). + Loescht NICHT das 'x'-Flag. + + Args: + start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (ab Zeile 7). + end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet). + limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht uebersprungener) Zeilen. Defaults to None (Unbegrenzt). + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Logge den Start des Modus auf Warning, da es experimentell ist. + self.logger.warning(f"Starte Modus (EXPERIMENTELL): Website Detail Extraction fuer Zeilen mit 'x' in Spalte A. Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") + self.logger.warning("Hinweis: Dieser Modus nutzt die globale Funktion 'scrape_website_details' (Block 13), deren Implementierung je nach Zielwebsites angepasst werden muss.") + + + # --- Daten laden --- + # Laden Sie Daten neu. Kein automatischer Startindex-Check noetig hier, + # da wir explizit nach dem 'x'-Flag suchen. + # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2). + if not self.sheet_handler.load_data(): + self.logger.error("Fehler beim Laden der Daten fuer Website Details Extraction.") + return # Beende die Methode, wenn das Laden fehlschlaegt + + + # Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler. + all_data = self.sheet_handler.get_all_data_with_headers(); + # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14). + header_rows = self.sheet_handler._header_rows; + total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet + + + # Standard Startzeile, wenn nicht manuell gesetzt + if start_sheet_row is None: start_sheet_row = header_rows + 1 # Standardmaessig ab erster Datenzeile (Zeile nach Headern) + + # Berechne Endzeile, wenn nicht manuell gesetzt + if end_sheet_row is None: end_sheet_row = total_sheet_rows # Bis zur letzten Zeile + + + # Logge den Suchbereich fuer das 'x'-Flag + self.logger.info(f"Suchbereich fuer 'x'-Flag: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") + + # Pruefe, ob der Bereich gueltig ist + if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows: + self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") + return # Beende die Methode, wenn der Bereich leer ist + + + # --- Indizes und Buchstaben --- + # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind + required_keys = ["ReEval Flag", "CRM Website"] # A, D + # Erstellen Sie ein Dictionary mit Schluesseln und Indizes + col_indices = {key: COLUMN_MAP.get(key) for key in required_keys} + + # Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden + if None in col_indices.values(): + missing = [k for k, v in col_indices.items() if v is None] + self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_website_details: {missing}. Breche ab.") + return # Beende die Methode bei kritischem Fehler + + # Ermitteln Sie die Indizes + reeval_col_idx = col_indices["ReEval Flag"] # A + website_col_idx = col_indices["CRM Website"] # D + + # Bestimme die ZIELSPALTE fuer die Details (Website Details ODER AR als Fallback) + details_col_idx = COLUMN_MAP.get("Website Details") # Versuche zuerst die dedizierte Spalte (Block 1 Column Map) + details_col_key_for_logging = "Website Details" # Name fuer Logging + # Wenn die dedizierte Spalte nicht gefunden wurde + if details_col_idx is None: + # Fallback auf 'Website Rohtext' (AR) + details_col_idx = COLUMN_MAP.get("Website Rohtext") # Block 1 Column Map + details_col_key_for_logging = "Website Rohtext" + # Pruefen Sie, ob der Fallback-Schluessel gefunden wurde + if details_col_idx is None: + self.logger.critical("FEHLER: Weder 'Website Details' noch 'Website Rohtext' Spaltenindex in COLUMN_MAP gefunden.") + return # Beende die Methode bei kritischem Fehler + self.logger.warning(f"Keine Spalte 'Website Details' in COLUMN_MAP, nutze '{details_col_key_for_logging}' ({self.sheet_handler._get_col_letter(details_col_idx+1)}) als Fallback.") # Logge Warnung (Block 14 _get_col_letter) + else: + # Logge die Verwendung der dedizierten Spalte + self.logger.info(f"Nutze Spalte '{details_col_key_for_logging}' ({self.sheet_handler._get_col_letter(details_col_idx+1)}) fuer Website Details.") # Logge Info (Block 14 _get_col_letter) + + + # Ermitteln Sie den Spaltenbuchstaben der Zielspalte (nutzt interne Helfer _get_col_letter Block 14) + details_col_letter = self.sheet_handler._get_col_letter(details_col_idx + 1) + + + # --- Verarbeitung --- + # Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1). + update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) + + + all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts) + + + processed_count = 0 # Zaehlt Zeilen, die fuer die Verarbeitung in Frage kommen und in den Batch aufgenommen werden (im Rahmen des Limits). + skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (nicht markiert oder fehlende URL). + + + # Iteriere durch die Datenzeilen im definierten Bereich (1-basierte Sheet-Zeilennummer) + for i in range(start_sheet_row, end_sheet_row + 1): + row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste + # Pruefen Sie, ob das Ende des Sheets erreicht wurde + if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht + + + row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile + + + # Stellen Sie sicher, dass die Zeile nicht leer ist + if not any(cell and isinstance(cell, str) and cell.strip() for cell in row): + #self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug + skipped_count += 1 # Zaehlen als uebersprungene Zeile + continue # Springe zur naechsten Zeile + + + # --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist --- + # Kriterium: Zeile ist mit 'x' in Spalte A (ReEval Flag) markiert. + # UND Website URL (D) ist vorhanden und gueltig aussehend. + + # Holen Sie den Wert aus Spalte A (ReEval Flag) (nutzt interne Helfer _get_cell_value_safe) + cell_a_value = self._get_cell_value_safe(row, "ReEval Flag").strip().lower() # Block 1 Column Map + # Pruefen Sie, ob die Zelle mit 'x' markiert ist. + is_marked_for_processing = cell_a_value == "x" + + # Wenn die Zeile nicht mit 'x' markiert ist, ueberspringen + if not is_marked_for_processing: + skipped_count += 1 # Zaehlen als uebersprungene Zeile + continue # Springe zur naechsten Zeile + + + # Holen Sie den Wert aus Spalte D (CRM Website) (nutzt interne Helfer _get_cell_value_safe) + website_url = self._get_cell_value_safe(row, "CRM Website").strip() # Block 1 Column Map + # Pruefen Sie, ob die Website URL (D) vorhanden und gueltig aussehend ist. + website_url_is_valid_looking = website_url and isinstance(website_url, str) and website_url.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"] # Fuege "http:" hinzu basierend auf Log + + + # Verarbeitung ist noetig, wenn die Zeile mit 'x' markiert ist UND die Website URL gueltig ist. + processing_needed_for_row = is_marked_for_processing and website_url_is_valid_looking + + + # Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level + log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row) + if log_check: + company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map + self.logger.debug(f"Zeile {i} ({company_name[:50]}... Website Details Check): A='x'? {is_marked_for_processing}, D gueltig? {website_url_is_valid_looking}. Benoetigt Verarbeitung? {processing_needed_for_row}") # Gekuerzt loggen + + + # Wenn die Verarbeitung fuer diese Zeile nicht noetig ist (trotz 'x' fehlte die URL) + if not processing_needed_for_row: + skipped_count += 1 # Zaehlen als uebersprungene Zeile + # Optionale Behandlung: Wenn mit 'x' markiert, aber URL fehlt, was tun? + # Derzeit wird sie uebersprungen. Ggf. Fehler in Spalte notieren? + continue # Springe zur naechsten Zeile + + + # --- Wenn Verarbeitung noetig: Fuehre Details-Extraktion aus --- + processed_count += 1 # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im Rahmen des Limits zaehlen) + + # Pruefe das Limit fuer verarbeitete Zeilen + if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit: + # Wenn das Limit erreicht ist und es ein positives Limit gibt + self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_website_details erreicht. Breche weitere Zeilenpruefung ab.") + break # Brich die Schleife ab + + + self.logger.info(f"Zeile {i}: Extrahiere Website Details von {website_url[:100]}...") # Logge Start (gekuerzt) + + + details = "FEHLER: Funktion 'scrape_website_details' nicht verfuegbar" # Default Fehler, falls die Funktion nicht existiert (Sollte nicht passieren, wenn Block 13 korrekt ist) + + try: + # Rufe die globale Funktion scrape_website_details auf (Block 13). + # scrape_website_details ist mit retry_on_failure dekoriert (Block 2). + # Wenn scrape_website_details fehlschlaegt, wirft sie eine Exception oder gibt einen Fehlerwert zurueck. + details = scrape_website_details(website_url) # <<< Ruft globale Funktion (Block 13) + + # Wenn die Funktion einen Fehler geloggt hat und einen Fehlerstring im Ergebnis zurueckgibt, + # wird dies in der 'details' Variable gespeichert. + if isinstance(details, str) and (details.startswith("k.A. (Fehler") or details.startswith("FEHLER:")): + # Fehler wurde bereits in scrape_website_details geloggt. + pass # Details enthaelt bereits den Fehlerstring. + + elif not isinstance(details, str) or not details.strip(): + # Wenn die Funktion keinen String oder einen leeren String zurueckgibt. + details = "k.A. (Extraktion leer oder ungueltig)" # Standard-Fehlerwert + + + except NameError: + # Dieser Fehler sollte nicht auftreten, wenn scrape_website_details in Block 13 ist. + self.logger.critical("FEHLER: Funktion 'scrape_website_details' ist nicht definiert! Kann Details nicht extrahieren.") + # Logge den Traceback. + self.logger.debug(traceback.format_exc()) + details = "FEHLER: Funktion nicht definiert" # Setze spezifischen Fehlerwert + + except Exception as e_detail: + # Fange andere unerwartete Fehler ab, die nicht von scrape_website_details behandelt wurden. + self.logger.exception(f"Unerwarteter Fehler bei scrape_website_details fuer {website_url[:100]}...: {type(e_detail).__name__} - {e_detail}") # Logge Fehler (gekuerzt) und Traceback + details = f"k.A. (Unerwarteter Fehler: {str(e_detail)[:100]}...)" # Signalisiert Fehler (gekuerzt) + + + # Fuege Update fuer die Details-Spalte hinzu (nutzt interne Helfer _get_col_letter Block 14) + # Stellen Sie sicher, dass der Wert ein String ist. + updates_for_row = [] # Lokale Liste fuer Updates dieser Zeile + updates_for_row.append({'range': f'{details_col_letter}{i}', 'values': [[str(details)]]}) # Block 1 Column Map + self.logger.debug(f"Zeile {i}: Details extrahiert und zum Update fuer Spalte {details_col_key_for_logging} ({details_col_letter}{i}) hinzugefuegt.") # Gekuerzt loggen + + + # Sammle die Updates fuer diese Zeile in der globalen Liste all_sheet_updates. + all_sheet_updates.extend(updates_for_row) + + + # Sende gesammelte Sheet Updates wenn das Update-Batch-Limit erreicht ist. + # update_batch_row_limit wird aus Config geholt (Block 1). + # Updates pro Zeile ist 1 in diesem Modus. Anzahl der Zeilen = len(all_sheet_updates). + if len(all_sheet_updates) >= update_batch_row_limit: + self.logger.debug(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") + # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry. + # Wenn es fehlschlaegt, wird es intern geloggt. + success = self.sheet_handler.batch_update_cells(all_sheet_updates) + if success: + self.logger.info(f" Sheet-Update fuer {len(all_sheet_updates)} Zellen erfolgreich.") + # Der Fehlerfall wird von batch_update_cells geloggt + + # Leere die gesammelten Updates nach dem Senden. + all_sheet_updates = [] + + + # Kleine Pause nach jeder Extraktion (nutzt Config Block 1). + # Dieser Modus macht API calls (ueber scrape_website_details und dessen Helfer), also Pause einbauen. + pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.2 + #self.logger.debug(f"Warte {pause_duration:.2f}s nach Extraktion...") # Zu viel Laerm im Debug + time.sleep(pause_duration) + + + # --- Finale Sheet Updates senden --- + # Sende alle verbleibenden gesammelten Updates in einem letzten Batch-Update. + if all_sheet_updates: + self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") + # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry. + success = self.sheet_handler.batch_update_cells(all_sheet_updates) + if success: + self.logger.info(f"FINALES Sheet-Update erfolgreich.") + # Der Fehlerfall wird von batch_update_cells geloggt + + + # Logge den Abschluss des Modus + self.logger.info(f"Modus 'website_details' abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {skipped_count} Zeilen uebersprungen.") + # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt. + + + # --- Methode zum Verarbeiten von Wiki-Updates basierend auf ChatGPT Vorschlaegen --- + # Diese Methode verarbeitet Zeilen, in denen S gesetzt ist (nicht in Endzustand), + # prueft ob U eine valide und andere Wiki-URL ist und fuehrt entsprechende Updates durch. + # Basierend auf process_wiki_updates_from_chatgpt aus Teil 4. + # Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter. + # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), time, + # is_valid_wikipedia_article_url (Block 12). + # Nutzt die uebergeordnete sheet_handler Instanz (Block 14). + def process_wiki_updates_from_chatgpt(self, start_sheet_row=None, end_sheet_row=None, limit=None): + """ + Identifiziert Zeilen, in denen Status S gesetzt ist, aber NICHT auf einem Endzustand + (OK, X (UPDATED/COPIED/INVALID)), prueft ob U eine *valide* und *andere* Wiki-URL ist. + - Wenn ja: Kopiert U->M, markiert S='X (URL Copied)', U='URL uebernommen', loescht + abhaengige Wiki-Spalten (N-V, AN, AO, AP, AX), setzt ReEval-Flag A='x'. + - Wenn nein (U keine URL, U==M, oder U ungueltig): LOESCHT den Inhalt von U und + markiert S als 'X (Invalid Suggestion)'. + Verarbeitet maximal limit Zeilen. + + Args: + start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (ab Zeile 7). + end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet). + limit (int, optional): Maximale Anzahl ZU PRUEFENDER Zeilen. Defaults to None (Unbegrenzt). + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Logge die Konfiguration des Modus + self.logger.info(f"Starte Modus 'wiki_updates_from_chatgpt' (S, U, M, N-V, AN, AO, AX, AP, A). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") + + + # --- Daten laden --- + # Laden Sie Daten neu. Kein automatischer Startindex-Check noetig hier, + # da wir nach Status S suchen. + # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2). + if not self.sheet_handler.load_data(): + self.logger.error("Fehler beim Laden der Daten fuer Wiki Updates.") + return # Beende die Methode, wenn das Laden fehlschlaegt + + + # Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler. + all_data = self.sheet_handler.get_all_data_with_headers() + # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14). + header_rows = self.sheet_handler._header_rows + total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet + + + # Standard Startzeile, wenn nicht manuell gesetzt + if start_sheet_row is None: start_sheet_row = header_rows + 1 # Standardmaessig ab erster Datenzeile (Zeile nach Headern) + + # Berechne Endzeile, wenn nicht manuell gesetzt + if end_sheet_row is None: end_sheet_row = total_sheet_rows # Bis zur letzten Zeile + + + # Logge den Suchbereich fuer Status S + self.logger.info(f"Suchbereich fuer Status S: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") + + # Pruefe, ob der Bereich gueltig ist + if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows: + self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") + return # Beende die Methode, wenn der Bereich leer ist + + + # --- Indizes und Buchstaben --- + # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind + required_keys = [ + "Chat Wiki Konsistenzpruefung", "Chat Vorschlag Wiki Artikel", "Wiki URL", # S, U, M (Pruefkriterien / Daten) + "Wikipedia Timestamp", "Wiki Verif. Timestamp", "Timestamp letzte Pruefung", "Version", # AN, AX, AO, AP (Spalten zum Loeschen) + "ReEval Flag", # A (ReEval Flag setzen) + "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", # N-R (Spalten zum Loeschen) + "Chat Begruendung Wiki Inkonsistenz", "Begruendung bei Abweichung", # T, V (Spalten zum Loeschen) + # AY (SerpAPI Wiki Search Timestamp) wird ebenfalls geleert, da abhaengig von M. + "SerpAPI Wiki Search Timestamp" # AY (Spalte zum Leeren) + ] + # Erstellen Sie ein Dictionary mit Schluesseln und Indizes + col_indices = {key: COLUMN_MAP.get(key) for key in required_keys} + + # Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden + if None in col_indices.values(): + missing = [k for k, v in col_indices.items() if v is None] + self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_wiki_updates_from_chatgpt: {missing}. Breche ab.") + return # Beende die Methode bei kritischem Fehler + + + # Ermitteln Sie die Spaltenbuchstaben fuer Updates/Leerung (nutzt interne Helfer _get_col_letter Block 14) + s_letter = self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzpruefung"] + 1) # Status S + u_letter = self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"] + 1) # Vorschlag U + m_letter = self.sheet_handler._get_col_letter(col_indices["Wiki URL"] + 1) # Wiki URL M + a_letter = self.sheet_handler._get_col_letter(col_indices["ReEval Flag"] + 1) # ReEval Flag A + + # Spalten N-V leeren. + # N ist Wiki Absatz, V ist Begruendung bei Abweichung. + n_idx = col_indices["Wiki Absatz"] + v_idx = col_indices["Begruendung bei Abweichung"] + # Erstellen Sie den Bereichsnamen (z.B. "N:V") + n_letter = self.sheet_handler._get_col_letter(n_idx + 1) + v_letter = self.sheet_handler._get_col_letter(v_idx + 1) + nv_range_letter = f'{n_letter}:{v_letter}' # z.B. N:V + # Erstellen Sie eine Liste von leeren Strings fuer diesen Bereich + empty_nv_values = [''] * (v_idx - n_idx + 1) # Anzahl der Spalten = V_Index - N_Index + 1 + + + # Timestamps AN, AO, AP, AX, AY leeren. + # Diese werden von anderen Schritten gesetzt und sollen hier zurueckgesetzt werden. + an_letter = self.sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"] + 1) # AN (Wiki Extraction TS) + ao_letter = self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Pruefung"] + 1) # AO (Chat Evaluation TS) + ap_letter = self.sheet_handler._get_col_letter(col_indices["Version"] + 1) # AP (Version) + ax_letter = self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"] + 1) # AX (Wiki Verif. TS) + ay_letter = self.sheet_handler._get_col_letter(col_indices["SerpAPI Wiki Search Timestamp"] + 1) # AY (SerpAPI Wiki TS) + + + # --- Verarbeitung --- + # Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1). + update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) + + + all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts) + + + processed_rows_count = 0 # Zaehlt Zeilen, die geprueft werden (im Rahmen des Limits zaehlen). + skipped_count = 0 # Zaehlt Zeilen, die uebersprungen werden (Status S im Endzustand etc.). + updated_url_count = 0 # Zaehlt Zeilen, wo U -> M kopiert wurde. + cleared_suggestion_count = 0 # Zaehlt Zeilen, wo Vorschlag U geloescht wurde. + + + # Iteriere durch die Datenzeilen im definierten Bereich (1-basierte Sheet-Zeilennummer) + for i in range(start_sheet_row, end_sheet_row + 1): + row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste + # Pruefen Sie, ob das Ende des Sheets erreicht wurde + if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht + + + row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile + + + # Stellen Sie sicher, dass die Zeile nicht leer ist + if not any(cell and isinstance(cell, str) and cell.strip() for cell in row): + #self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug + skipped_count += 1 # Zaehlen als uebersprungene Zeile + continue # Springe zur naechsten Zeile + + + # --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist --- + # Kriterium: Status S ist gesetzt (nicht leer) UND NICHT einer der Endzustaende. + # Endzustaende: "OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)" + + # Holen Sie den Wert aus Spalte S (Chat Wiki Konsistenzpruefung) (nutzt interne Helfer _get_cell_value_safe) + s_value = self._get_cell_value_safe(row, "Chat Wiki Konsistenzpruefung").strip() # Block 1 Column Map + s_value_upper = s_value.upper() + + # Definieren Sie die Endzustaende (Grossbuchstaben) + s_end_states = ["OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)"] + + # Verarbeitung ist noetig, wenn S nicht leer ist UND S NICHT im Endzustand ist. + processing_needed_for_row = s_value and s_value_upper not in s_end_states + + + # Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level + log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row) + if log_check: + self.logger.debug(f"Zeile {i} (Wiki Update Check): Status S='{s_value}'. Benoetigt Verarbeitung? {processing_needed_for_row}") + + + # Wenn die Verarbeitung fuer diese Zeile nicht noetig ist + if not processing_needed_for_row: + skipped_count += 1 # Zaehlen als uebersprungene Zeile + continue # Springe zur naechsten Zeile + + + # --- Wenn Verarbeitung noetig: Pruefe Vorschlag U und handle --- + processed_rows_count += 1 # Zaehle die Zeile, die geprueft wird (im Rahmen des Limits zaehlen). + + # Pruefe das Limit fuer verarbeitete Zeilen + if limit is not None and isinstance(limit, int) and limit > 0 and processed_rows_count > limit: + # Wenn das Limit erreicht ist und es ein positives Limit gibt + self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_wiki_updates_from_chatgpt erreicht. Breche weitere Zeilenpruefung ab.") + break # Brich die Schleife ab + + + # Holen Sie die Werte aus Spalte U (Chat Vorschlag Wiki Artikel) und M (Wiki URL) (nutzt interne Helfer _get_cell_value_safe) + vorschlag_u = self._get_cell_value_safe(row, "Chat Vorschlag Wiki Artikel").strip() # Block 1 Column Map + url_m = self._get_cell_value_safe(row, "Wiki URL").strip() # Block 1 Column Map + + + self.logger.info(f"Zeile {i}: Pruefe Wiki-Vorschlag U='{vorschlag_u[:100]}...' (aktuell M='{url_m[:100]}...')...") # Gekuerzt loggen + + is_update_candidate = False # Flag, ob U eine gueltige, neue URL ist, die uebernommen werden soll. + new_url = "" # Die URL, die ggf. in M kopiert wird. + + + # Kriterium 1: Ist Vorschlag U ueberhaupt ein String und sieht nach Wikipedia aus? + condition1_u_is_wiki_url = vorschlag_u and isinstance(vorschlag_u, str) and "wikipedia.org/wiki/" in vorschlag_u.lower() and vorschlag_u.lower().startswith(("http://", "https://")) # Check auf Schema hinzugefuegt + + + # Wenn der Vorschlag U wie eine Wikipedia-URL aussieht + if condition1_u_is_wiki_url: + new_url = vorschlag_u # Nehme den Vorschlag als potenzielle neue URL + # Kriterium 2: Unterscheidet sich der Vorschlag U von der aktuellen URL in M? + # Pruefe, ob die neue URL nicht identisch mit der aktuellen M-URL ist. + condition2_u_differs_m = new_url != url_m + + # Wenn sich der Vorschlag U von der aktuellen M-URL unterscheidet + if condition2_u_differs_m: + self.logger.debug(f" -> Vorschlag U ({new_url[:100]}...) unterscheidet sich von M ({url_m[:100]}). Pruefe Validitaet...") # Gekuerzt loggen + # Kriterium 3: Ist die vorgeschlagene URL ein valider Wikipedia-Artikel (nicht Weiterleitung, Begriffsklaerung, Fehler)? + # Nutzt globale Funktion is_valid_wikipedia_article_url (Block 12) mit Retry Decorator (Block 2). + # is_valid_wikipedia_article_url wirft Exception bei endgueltigem Fehler. + try: + condition3_u_is_valid = is_valid_wikipedia_article_url(new_url) # Nutzt globalen Helfer (Block 12) + # Wenn die vorgeschlagene URL ein valider Artikel ist + if condition3_u_is_valid: + is_update_candidate = True # Alle Kriterien erfuellt! Der Vorschlag kann uebernommen werden. + self.logger.debug(f" -> URL '{new_url[:100]}...' ist ein VALIDER Artikel laut API Check.") # Gekuerzt loggen + else: + # Wenn die vorgeschlagene URL nicht valide ist + self.logger.debug(f" -> URL '{new_url[:100]}...' ist KEIN valider Artikel laut API Check.") # Gekuerzt loggen + + except Exception as e_validity_check: + # Wenn is_valid_wikipedia_article_url eine Exception wirft (nach Retries) + # Der Fehler wird bereits vom retry_on_failure Decorator geloggt. + self.logger.error(f"FEHLER bei Validitaetspruefung von Vorschlag U '{new_url[:100]}...': {e_validity_check}") # Gekuerzt loggen + # Bei Fehler bleibt is_update_candidate False. + pass # Faert fort + + + else: + # Wenn der Vorschlag U identisch mit der aktuellen M-URL ist + self.logger.debug(f" -> Vorschlag U ist identisch mit URL M. Wird nicht uebernommen.") + + else: + # Wenn der Vorschlag U nicht wie eine Wikipedia-URL aussieht + self.logger.debug(f" -> Vorschlag U ('{vorschlag_u[:100]}...') ist keine Wikipedia URL. Wird nicht uebernommen.") # Gekuerzt loggen + + + # --- Verarbeitung des Kandidaten ODER Loeschen des ungueltigen Vorschlags --- + updates_for_row = [] # Lokale Liste fuer Updates DIESER Zeile + + if is_update_candidate: + # Fall 1: Gueltiges Update durchfuehren (Vorschlag U wird in M kopiert) + self.logger.info(f"Zeile {i}: Update-Kandidat VALIDIERUNG ERFOLGREICH. Kopiere U->M, setze ReEval-Flag 'x', loesche abhaengige Spalten.") + updated_url_count += 1 # Zaehle die uebernommene URL + + # Updates sammeln (M, S, U, N-V, AN, AO, AP, AX, AY, A) (nutzt interne Helfer _get_col_letter Block 14) + updates_for_row.append({'range': f'{m_letter}{i}', 'values': [[new_url]]}) # Setze die neue URL in Spalte M (Block 1 Column Map) + updates_for_row.append({'range': f'{s_letter}{i}', 'values': [["X (URL Copied)"]]}) # Setze Status S auf "X (URL Copied)" (Block 1 Column Map) + updates_for_row.append({'range': f'{u_letter}{i}', 'values': [["URL uebernommen"]]}) # Schreibe Info in Spalte U (Block 1 Column Map) + updates_for_row.append({'range': f'{a_letter}{i}', 'values': [["x"]]}) # Setze ReEval Flag (A) auf 'x' (Block 1 Column Map) + + # Leere Spalten N-V. + # Fuege das Update zum Leeren des Bereichs V-Y hinzu, falls der Bereichsname ermittelt werden konnte. + if nv_range_letter: # Pruefe, ob der Bereichsname ermittelt werden konnte. + updates_for_row.append({'range': f'{n_letter}{i}:{v_letter}{i}', 'values': [empty_nv_values]}) # Block 1 Column Map, lokale Variable + else: + self.logger.warning(f"Konnte Spaltenbereich N-V ({n_letter}:{v_letter}) fuer Leerung nicht ermitteln fuer Zeile {i}. Leerung uebersprungen.") + + + # Leere Timestamps AN, AO, AP, AX, AY. + # Dies setzt die Zeile zurueck, damit andere Schritte sie spaeter bearbeiten. + updates_for_row.append({'range': f'{an_letter}{i}', 'values': [['']]}) # AN (Wiki Extraction TS) Block 1 Column Map + updates_for_row.append({'range': f'{ao_letter}{i}', 'values': [['']]}) # AO (Chat Evaluation TS) Block 1 Column Map + updates_for_row.append({'range': f'{ap_letter}{i}', 'values': [['']]}) # AP (Version) Block 1 Column Map + updates_for_row.append({'range': f'{ax_letter}{i}', 'values': [['']]}) # AX (Wiki Verif. TS) Block 1 Column Map + updates_for_row.append({'range': f'{ay_letter}{i}', 'values': [['']]}) # AY (SerpAPI Wiki TS) Block 1 Column Map + + + else: + # Fall 2: Ungueltigen Vorschlag loeschen/markieren + # Wenn der Vorschlag U nicht uebernommen wird (weil ungueltig oder identisch mit M). + self.logger.info(f"Zeile {i}: Vorschlag U ('{vorschlag_u[:100]}...') ist ungueltig/identisch. Loesche U und setze Status S auf 'X (Invalid Suggestion)'.") # Gekuerzt loggen + cleared_suggestion_count += 1 # Zaehle den bereinigten Vorschlag + + # Updates sammeln (S, U) (nutzt interne Helfer _get_col_letter Block 14) + updates_for_row.append({'range': f'{s_letter}{i}', 'values': [["X (Invalid Suggestion)"]]}) # Setze Status S auf "X (Invalid Suggestion)" (Block 1 Column Map) + updates_for_row.append({'range': f'{u_letter}{i}', 'values': [[""]]}) # Loesche den Vorschlag in Spalte U (Block 1 Column Map) + # KEIN ReEval-Flag (A) setzen in diesem Fall. + + + # Sammle die Updates fuer diese Zeile in der globalen Liste all_sheet_updates. + all_sheet_updates.extend(updates_for_row) + + + # Sende gesammelte Sheet Updates wenn das Update-Batch-Limit erreicht ist. + # update_batch_row_limit wird aus Config geholt (Block 1). + # Die Anzahl der Updates pro Zeile variiert stark (ca. 2 bei ungueltigem Vorschlag, ca. 10+ bei gueltigem). + # Pruefen Sie einfach die Laenge der gesammelten Liste. + if len(all_sheet_updates) >= update_batch_row_limit * 5: # Grobe Schaetzung: im Schnitt 5 Updates pro Zeile + self.logger.debug(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") + # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry. + # Wenn es fehlschlaegt, wird es intern geloggt. + success = self.sheet_handler.batch_update_cells(all_sheet_updates) + if success: + self.logger.info(f" Sheet-Update fuer {len(all_sheet_updates)} Zellen erfolgreich.") + # Der Fehlerfall wird von batch_update_cells geloggt + + # Leere die gesammelten Updates nach dem Senden. + all_sheet_updates = [] + + + # Kleine Pause nach jeder geprueften Zeile (nutzt Config Block 1). + # Dieser Modus macht API calls (ueber is_valid_wikipedia_article_url), also Pause einbauen. + pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.2 + #self.logger.debug(f"Warte {pause_duration:.2f}s nach Pruefung...") # Zu viel Laerm im Debug + time.sleep(pause_duration) + + + # --- Finale Sheet Updates senden --- + # Sende alle verbleibenden gesammelten Updates in einem letzten Batch-Update. + if all_sheet_updates: + self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") + # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry. + success = self.sheet_handler.batch_update_cells(all_sheet_updates) + if success: + self.logger.info(f"FINALES Sheet-Update erfolgreich.") + # Der Fehlerfall wird von batch_update_cells geloggt + + + # Logge den Abschluss des Modus + self.logger.info(f"Modus 'wiki_updates_from_chatgpt' abgeschlossen. {processed_rows_count} Zeilen geprueft, {updated_url_count} URLs kopiert & fuer ReEval markiert, {cleared_suggestion_count} ungueltige Vorschlaege geloescht/markiert, {skipped_count} Zeilen uebersprungen.") + # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt. + + + # --- Methode zur Re-Extraktion von Wiki-Daten bei fehlendem Timestamp AN --- + # Diese Methode identifiziert Zeilen mit M gefuellt und AN leer und fuehrt _process_single_row (Block 19) fuer diese aus. + # Nutzt interne Helfer: _get_cell_value_safe, _process_single_row. + # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger. + # Nutzt die uebergeordnete sheet_handler Instanz (Block 14). + def process_wiki_reextract_missing_an(self, start_sheet_row=None, end_sheet_row=None, limit=None): + """ + Identifiziert Zeilen, bei denen eine Wiki URL (M) vorhanden ist, aber der + Wikipedia Timestamp (AN) fehlt. Fuehrt _process_single_row fuer diese Zeilen aus, + beschraenkt auf den 'wiki'-Schritt und mit force_reeval=True, um die Extraktion + erneut zu versuchen. + + Args: + start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AN). + end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet). + limit (int, optional): Maximale Anzahl ZU VERARBEITENDER Zeilen. Defaults to None (Unbegrenzt). + """ + # Verwenden Sie logger, da das Logging jetzt konfiguriert ist + # Logge die Konfiguration des Modus + self.logger.info(f"Starte Modus 'wiki_reextract_missing_an' (M gefuellt & AN leer). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") + + + # --- Daten laden und Startzeile ermitteln --- + # Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt. + # Dieser Modus sucht nach leeren AN mit gefuelltem M. Die automatische Startzeile + # basierend auf leeren AN ist ein guter Startpunkt. + if start_sheet_row is None: + self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AN...") + # Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AN (Block 1 Column Map). + # Standardmaessig ab Zeile 7 + start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Wikipedia Timestamp", min_sheet_row=7) + + # Wenn get_start_row_index -1 zurueckgibt (Fehler) + if start_data_index_no_header == -1: + self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche Modus ab.") + return # Beende die Methode + + # Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten Daten-Index + start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut + self.logger.info(f"Automatisch ermittelte Startzeile (erste leere AN Zelle): {start_sheet_row}") + else: + # Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein. + # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2). + if not self.sheet_handler.load_data(): + self.logger.error("Fehler beim Laden der Daten fuer wiki_reextract_missing_an.") + return # Beende die Methode, wenn das Laden fehlschlaegt + + + # Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler. + all_data = self.sheet_handler.get_all_data_with_headers(); + # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14). + header_rows = self.sheet_handler._header_rows; + total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet + + + # Berechne Endzeile, wenn nicht manuell gesetzt + if end_sheet_row is None: + end_sheet_row = total_sheet_rows # Bis zur letzten Zeile + + + # Logge den verarbeitungsbereich + self.logger.info(f"Suchbereich fuer M gefuellt & AN leer: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") + + # Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen) + if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows: + self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") + return # Beende die Methode, wenn der Bereich leer ist + + + # --- Indizes --- + # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind + required_keys = ["Wiki URL", "Wikipedia Timestamp"] # M, AN (Pruefkriterien) + # Erstellen Sie ein Dictionary mit Schluesseln und Indizes + col_indices = {key: COLUMN_MAP.get(key) for key in required_keys} + + # Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden + if None in col_indices.values(): + missing = [k for k, v in col_indices.items() if v is None] + self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer wiki_reextract_missing_an: {missing}. Breche ab.") + return # Beende die Methode bei kritischem Fehler + + # Ermitteln Sie die Indizes + m_col_idx = col_indices["Wiki URL"] + an_col_idx = col_indices["Wikipedia Timestamp"] + + + # --- Verarbeitung --- + processed_count = 0 # Zaehlt Zeilen, die an _process_single_row uebergeben wurden (im Rahmen des Limits zaehlen). + skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden. + + + # Iteriere durch die Datenzeilen im definierten Bereich (1-basierte Sheet-Zeilennummer) + for i in range(start_sheet_row, end_sheet_row + 1): + row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste + # Pruefen Sie, ob das Ende des Sheets erreicht wurde + if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht + + + row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile + + + # Stellen Sie sicher, dass die Zeile nicht leer ist + if not any(cell and isinstance(cell, str) and cell.strip() for cell in row): + #self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug + skipped_count += 1 # Zaehlen als uebersprungene Zeile + continue # Springe zur naechsten Zeile + + + # --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist --- + # Kriterium: Wiki URL (M) ist vorhanden und gueltig aussehend. + # UND Wikipedia Timestamp (AN) ist leer. + + # Holen Sie die Werte aus den entsprechenden Spalten (nutzt interne Helfer _get_cell_value_safe) + m_value = self._get_cell_value_safe(row, "Wiki URL").strip() # Block 1 Column Map + an_value = self._get_cell_value_safe(row, "Wikipedia Timestamp").strip() # Block 1 Column Map + + # Pruefen Sie, ob M gefuellt und gueltig aussieht. + is_m_valid_looking = m_value and isinstance(m_value, str) and "wikipedia.org/wiki/" in m_value.lower() and m_value.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"] # Fuege "http:" hinzu basierend auf Log + + # Pruefen Sie, ob AN leer ist. + is_an_empty = not an_value + + # Verarbeitung ist noetig, wenn M gueltig aussieht UND AN leer ist. + processing_needed_for_row = is_m_valid_looking and is_an_empty + + + # Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level + log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row) + if log_check: + company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map + self.logger.debug(f"Zeile {i} ({company_name[:50]}... Wiki Re-extract Check): M ('{m_value[:50]}...') gueltig? {is_m_valid_looking}, AN leer? {is_an_empty}. Benoetigt Verarbeitung? {processing_needed_for_row}") # Gekuerzt loggen + + + # Wenn die Verarbeitung fuer diese Zeile nicht noetig ist + if not processing_needed_for_row: + skipped_count += 1 # Zaehlen als uebersprungene Zeile + continue # Springe zur naechsten Zeile + + + # --- Wenn Verarbeitung noetig: Rufe _process_single_row auf --- + processed_count += 1 # Zaehle die Zeile, die an _process_single_row uebergeben wird (im Rahmen des Limits zaehlen) + + # Pruefe das Limit fuer verarbeitete Zeilen + if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit: + # Wenn das Limit erreicht ist und es ein positives Limit gibt + self.logger.info(f"Verarbeitungslimit ({limit}) fuer wiki_reextract_missing_an erreicht. Breche weitere Zeilenpruefung ab.") + break # Brich die Schleife ab + + + self.logger.info(f"Zeile {i}: M gefuellt & AN leer. Versuche Wiki-Re-Extraktion ueber _process_single_row...") + + try: + # RUFE _process_single_row AUF (Block 19). + # Mit steps_to_run={'wiki'} und force_reeval=True, + # damit nur der Wiki-Schritt ausgefuehrt wird und Timestamps ignoriert werden. + # Im Re-Extract Modus loeschen wir das 'x'-Flag NICHT automatisch. + self._process_single_row( + row_num_in_sheet = i, + row_data = row, # Uebergibt die aktuellen Rohdaten der Zeile + steps_to_run = {'wiki'}, # <<< NUR der Wiki-Schritt soll laufen + force_reeval = True, # <<< Erzwingt die Ausfuehrung des 'wiki' Schritts (ignoriert AN, S). + clear_x_flag = False # <<< 'x'-Flag wird in diesem Modus NICHT geloescht + ) + # _process_single_row (Block 19) loggt intern den Abschluss und fuehrt das Sheet-Update durch. + + except Exception as e_proc: + # Wenn _process_single_row einen Fehler wirft (nachdem interne Retries aufgaben), + # fangen wir ihn hier, loggen ihn und fahren mit der naechsten Zeile fort. + self.logger.exception(f"FEHLER bei Verarbeitung von Zeile {i} in wiki_reextract_missing_an: {e_proc}") + # Hier koennen Sie z.B. einen Fehlerindikator in eine spezielle Spalte im Sheet schreiben lassen. + # Dieses Update muesste dann separat oder im naechsten Lauf behandelt werden. + + # _process_single_row beinhaltet bereits eine kleine Pause am Ende. + # Hier ist keine zusaetzliche Pause noetig, wenn _process_single_row erfolgreich war. + # Wenn _process_single_row eine Exception wirft, kann hier eine kurze Pause sinnvoll sein. + # time.sleep(0.1) # Optional: Kurze Pause bei Fehler nach Exception + + + # Logge den Abschluss des Modus + self.logger.info(f"Modus 'wiki_reextract_missing_an' abgeschlossen. {processed_count} Zeilen an _process_single_row uebergeben, {skipped_count} Zeilen uebersprungen.") + # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt. + + +# ============================================================================== +# Ende DataProcessor Klasse Utility: Other Specific Tasks Block +# ============================================================================== + +# ============================================================================== +# Ende DataProcessor Klasse +# ============================================================================== + + # --- Ende der DataProcessor Klasse --- + # Ein pass statement, um die Klassendefinition abzuschliessen, falls keine weiteren Methoden folgen. + pass # <-- DIESES pass STATEMENT GEHOERT ZUM ENDE DER KLASSENDEFINITION + + +# ============================================================================== +# Hauptausfuehrungsblock & Globale Funktionen nach Klassen +# ============================================================================== +# Der naechste Block (Block 34) enthaelt die main Funktion und den Entry Point. + +# ============================================================================== +# 6. MAIN FUNCTION (HAUPTEINSTIEGSPUNKT & UI DISPATCHER) +# ============================================================================== + +# Der globale Root Logger wird in main() konfiguriert +# logger = logging.getLogger(__name__) # Diesen Logger gibt es schon, keine Neudefinition hier + +def main(): + """ + Haupteinstiegspunkt des Skripts. + Verarbeitet Kommandozeilen-Argumente, richtet Logging ein, + initialisiert Komponenten und dispatchet zu den passenden Modi. + """ + # WICHTIG: Globale Variable LOG_FILE wird benoetigt (Initialisierung Block 1) + global LOG_FILE + + # --- Initial Logging Setup (Konfiguration von Level und Format) --- + # Diese Konfiguration wird wirksam, sobald die Handler hinzugefuegt werden. + # Standard-Logging Level festlegen (aus Config Block 1) + log_level = logging.DEBUG if getattr(Config, 'DEBUG', False) else logging.INFO + log_format = '%(asctime)s - %(levelname)-8s - %(name)-25s - %(message)s' # Angepasstes Format mit breiterem Namen + + # Root-Logger konfigurieren (mit Console Handler, File Handler wird spaeter hinzugefuegt) + # handlers=[] verhindert default Console Handler, wir fuegen ihn manuell hinzu fuer mehr Kontrolle + logging.basicConfig(level=log_level, format=log_format, handlers=[]) + + # Console Handler explizit hinzufuegen + console_handler = logging.StreamHandler() + console_handler.setLevel(log_level) # Nimm das globale Level + console_handler.setFormatter(logging.Formatter(log_format)) + # Pruefen, ob nicht schon ein Console Handler vorhanden ist (z.B. bei wiederholten Aufrufen in Tests) + if not any(isinstance(h, logging.StreamHandler) for h in logging.getLogger('').handlers): + logging.getLogger('').addHandler(console_handler) + + + # Testnachricht (geht nur an Konsole, da File Handler noch fehlt) + logger.debug("DEBUG Logging initial konfiguriert (nur Konsole).") + logger.info("INFO Logging initial konfiguriert (nur Konsole).") + + + # --- Initialisierung (Argument Parser) --- + current_script_version = getattr(Config, 'VERSION', 'unknown') # Aus Config Block 1 + + parser = argparse.ArgumentParser( + description=f"Firmen-Datenanreicherungs-Skript {current_script_version}. Automatisiert Anreicherung und Validierung aus Google Sheets.", + formatter_class=argparse.RawTextHelpFormatter # Behaelt Formatierung im Help-Text + ) + + # Liste der gueltigen Modi - MUSS mit den elif-Zweigen unten uebereinstimmen! + # Kategorisiert fuer die Menue-Ausgabe + mode_categories = { + "Batch-Verarbeitung (Schritt-Optimiert)": [ + "wiki_verify", # Uebereinstimmend mit process_verification_batch (Block 26) + "website_scraping", # Uebereinstimmend mit process_website_scraping_batch (Block 27) + "summarize_website", # Uebereinstimmend mit process_summarization_batch (Block 28) + "branch_eval", # Uebereinstimmend mit process_branch_batch (Block 29) + ], + "Sequentielle Verarbeitung (Zeilenweise)": [ + "full_run", # Nutzt process_rows_sequentially (Block 24) + ], + "Re-Evaluate Markierte Zeilen (Spalte A='x')": [ + "reeval", # Nutzt process_reevaluation_rows (Block 25) + ], + "Einzelne Dienstprogramme / Suchen": [ + "find_wiki_serp", # Nutzt process_find_wiki_serp (Block 30) + "website_lookup", # Nutzt process_serp_website_lookup (Block 30) + "contacts", # Nutzt process_contact_search (Block 30) + "update_wiki_suggestions", # Nutzt process_wiki_updates_from_chatgpt (Block 32) + "wiki_reextract_missing_an", # Nutzt process_wiki_reextract_missing_an (Block 32) + "website_details", # EXPERIMENTELL - Nutzt process_website_details (Block 32) + "train_technician_model", # Nutzt train_technician_model (Block 31) + "alignment", # Nutzt globale alignment_demo (Block 14) + ], + "Kombinierte Laeufe (Vordefiniert)": [ + "combined_all", # Definiert eine Sequenz von Batch-Modi + ] + } + # Erstellen Sie eine flache Liste aller validen Modi fuer die Validierung + valid_modes = [mode for modes in mode_categories.values() for mode in modes] + + + # Dynamisch generieren des Help-Textes fuer den Modus + mode_help_text = "Betriebsmodus. Waehlen Sie einen der folgenden:\n" + for category, modes in mode_categories.items(): + mode_help_text += f"\n{category}:\n" + for mode in modes: + mode_help_text += f" - {mode}\n" + + parser.add_argument("--mode", type=str, help=mode_help_text) + # Hilfsargument fuer die CLI-basierte Modusauswahl (wenn --mode gesetzt ist) + parser.add_argument("-m", "--cli-mode", dest="mode", action="store_const", const=valid_modes[0] if valid_modes else None, help=argparse.SUPPRESS) # Unterdruecke in --help + + parser.add_argument("--limit", type=int, help="Maximale Anzahl zu verarbeitender Zeilen in den meisten Modi (prueft Zeilen VOR Ueberspringung/Filterung).", default=None) + # start_sheet_row wird primaer fuer full_run verwendet, kann aber auch fuer Bereiche in Batch nuetzlich sein + parser.add_argument("--start_sheet_row", type=int, help="Startzeile im Sheet (1-basiert) fuer 'full_run' und einige Batch-Modi. Standard: Automatische Ermittlung basierend auf Timestamp.", default=None) + # end_sheet_row fuer Bereiche + parser.add_argument("--end_sheet_row", type=int, help="Endzeile im Sheet (1-basiert) fuer 'full_run' und einige Batch-Modi. Standard: Ende des Sheets.", default=None) + + + # Argument fuer den Re-Eval und Full-Run Modus zur Auswahl der Schritte + # Moegliche Werte fuer die Schritte: 'wiki', 'chat', 'web', 'ml_predict', etc. (entsprechend den step_type Schluesseln in _process_single_row Block 19) + # Default ist 'all' fuer alle Schritte, oder eine spezifische Liste + # Dies sind die Schluessel, die _process_single_row (Block 19) in steps_to_run Set erwartet. + valid_single_row_steps = ['wiki', 'chat', 'web', 'ml_predict'] # Fuegen Sie hier weitere Schritt-Schluessel hinzu, die _process_single_row versteht + single_row_steps_help = f"Komma-getrennte Liste der Schritte im 'reeval' und 'full_run' Modus (z.B. 'wiki,chat').\nMögliche Schritte: {', '.join(valid_single_row_steps)}.\nStandard: {'all' if valid_single_row_steps else 'keine'}" # Standard: alle verfuegbaren Schritte + + # Standardwert fuer --steps: Alle gueltigen Single-Row Schritte, wenn es welche gibt + default_steps_arg = ','.join(valid_single_row_steps) if valid_single_row_steps else '' + parser.add_argument("--steps", type=str, help=single_row_steps_help, default=default_steps_arg) + + + # Argumente fuer find_wiki_serp (falls ueber CLI gesteuert) + parser.add_argument("--min_umsatz", type=float, help="Mindestumsatz in MIO € (CRM Spalte J) fuer find_wiki_serp Filter.", default=200.0) # Float fuer Konsistenz + parser.add_argument("--min_employees", type=int, help="Mindestmitarbeiterzahl (CRM Spalte K) fuer find_wiki_serp Filter.", default=500) + + + # Argumente fuer train_technician_model (Pfade fuer Output-Dateien) + parser.add_argument("--model_out", type=str, default=MODEL_FILE, help=f"Pfad fuer das trainierte Modell (.pkl). Standard: {MODEL_FILE}") # Block 1 Konstante + parser.add_argument("--imputer_out", type=str, default=IMPUTER_FILE, help=f"Pfad fuer den trainierten Imputer (.pkl). Standard: {IMPUTER_FILE}") # Block 1 Konstante + parser.add_argument("--patterns_out", type=str, default=PATTERNS_FILE_JSON, help=f"Pfad fuer die Feature-Spaltenliste (.json). Standard: {PATTERNS_FILE_JSON}") # Block 1 Konstante + + # TODO: Fuegen Sie hier weitere CLI-Argumente hinzu, falls andere Modi Parameter benoetigen + + args = parser.parse_args() + + + # --- Konfiguration laden --- + Config.load_api_keys() # Nutzt jetzt logging intern (print am Anfang Block 1) + + + # --- Logdatei-Konfiguration abschliessen --- + # Bestimmen Sie den Log-Modus Namen basierend auf CLI oder Interaktion + # Wir nutzen den CLI Modus Namen, wenn --mode gesetzt ist, sonst "interactive". + log_mode_name = args.mode if args.mode else "interactive" + LOG_FILE = create_log_filename(log_mode_name) # Nutzt globale Funktion (Block 3) + + # Wenn die Logdatei erfolgreich erstellt wurde + if LOG_FILE: + try: + # Erstellen Sie den FileHandler fuer die Logdatei + # mode='a' zum Anhaengen, encoding='utf-8' fuer Unicode + file_handler = logging.FileHandler(LOG_FILE, mode='a', encoding='utf-8') + file_handler.setLevel(log_level) # Nimm das globale Level + # Verwenden Sie denselben Formatter wie fuer den Console Handler + file_handler.setFormatter(logging.Formatter(log_format)) + # Fuege FileHandler zum Root-Logger hinzu + # Pruefen, ob nicht schon ein File Handler mit demselben Pfad vorhanden ist (z.B. bei wiederholten Aufrufen in Tests) + if not any(isinstance(h, logging.FileHandler) and h.baseFilename == os.path.abspath(LOG_FILE) for h in logging.getLogger('').handlers): + logging.getLogger('').addHandler(file_handler) + logger.info(f"Logging wird jetzt auch in Datei geschrieben: {LOG_FILE}") + except Exception as e: + # Logge Fehler nur auf Konsole, da FileHandler fehlgeschlagen ist + # logger.exception loggt auch an die Konsole, wenn kein FileHandler da ist + logger.error(f"Konnte FileHandler fuer Logdatei '{LOG_FILE}' nicht erstellen: {e}") + # Optional: Entfernen Sie evtl. den fehlerhaften Handler aus der Liste + logging.getLogger('').handlers = [h for h in logging.getLogger('').handlers if not isinstance(h, logging.FileHandler) or h.baseFilename == os.path.abspath(LOG_FILE)] # Entferne nur den fehlerhaften Handler + + + # --- JETZT die Startmeldungen loggen (gehen jetzt in Konsole UND Datei) --- + logger.info(f"===== Skript gestartet =====") + logger.info(f"Version: {current_script_version}") + # Logge den tatsaechlichen Pfad der Logdatei oder die Fehlermeldung + logger.info(f"Logdatei: {LOG_FILE if LOG_FILE else 'FEHLER - Keine Logdatei erstellt'}") + # Logge relevante CLI Argumente zur Dokumentation des Laufs + logger.info(f"CLI Argumente: {args}") + + + # --- Vorbereitung (Schema, Handler etc.) --- + # Laden Sie das Ziel-Branchenschema (Block 6) + # load_target_schema ist mit retry_on_failure dekoriert (Block 2). + load_target_schema() + + + # Initialisiere GoogleSheetHandler (Block 14) + sheet_handler = None # Initialisiere Variable + try: + # Der GoogleSheetHandler Init (_init_ Methode) baut die Verbindung auf und laedt Daten. + # Fehler werden dort gefangen und als ConnectionError erneut geworfen. + sheet_handler = GoogleSheetHandler() + logger.info("GoogleSheetHandler erfolgreich initialisiert.") + except ConnectionError as e: + # Wenn die Initialisierung des SheetHandlers fehlschlaegt (Verbindungs-/Ladefehler) + logger.critical(f"FATAL: Initialisierung des GoogleSheetHandlers fehlgeschlagen: {e}") + logger.critical(f"Bitte ueberpruefen Sie Ihre Google Sheets URL, Credentials und Berechtigungen.") + logger.critical(f"Bitte Logdatei pruefen fuer Details: {LOG_FILE}") + return # Beende Skript, wenn Sheet nicht geladen werden kann + except Exception as e: + # Fangen Sie andere unerwartete Fehler bei der Initialisierung ab + logger.critical(f"FATAL: Unerwarteter Fehler bei Initialisierung von GoogleSheetHandler: {e}") + logger.debug(traceback.format_exc()) + logger.critical(f"Bitte Logdatei pruefen fuer Details: {LOG_FILE}") + return # Beende Skript + + + # Initialisiere WikipediaScraper (Block 14) + wiki_scraper = None # Initialisiere Variable + try: + # Der WikipediaScraper Init (_init_ Methode) konfiguriert die Bibliothek und Requests. + # Fehler werden dort gefangen und erneut geworfen. + wiki_scraper = WikipediaScraper() + logger.info("WikipediaScraper erfolgreich initialisiert.") + except Exception as e: + # Wenn die Initialisierung des WikipediaScrapers fehlschlaegt + logger.critical(f"FATAL: Initialisierung des WikipediaScrapers fehlgeschlagen: {e}") + logger.debug(traceback.format_exc()) + logger.critical(f"Bitte Logdatei pruefen fuer Details: {LOG_FILE}") + # Das Skript kann ohne Wiki Scraper viele Modi nicht sinnvoll laufen + return # Beende Skript + + + # TODO: Initialisieren Sie hier weitere Worker-Instanzen, falls Sie separate Klassen haben (z.B. OpenAIHandler, SerpAPIHandler) + # openai_handler = OpenAIHandler() + # serpapi_handler = SerpAPIHandler() + + + # Initialisiere DataProcessor Instanz (Block 15) mit Handlern + # Uebergeben Sie alle benoetigten Handler an den DataProcessor. + # Die __init__ Methode des DataProcessor (Block 15) prueft die Typen und wirft Value Error, wenn falsch. + try: + data_processor = DataProcessor(sheet_handler=sheet_handler, wiki_scraper=wiki_scraper) + logger.info("DataProcessor erfolgreich initialisiert.") + except Exception as e: + # Fangen Sie Fehler bei der DataProcessor Initialisierung ab. + logger.critical(f"FATAL: Initialisierung des DataProcessors fehlgeschlagen: {e}") + logger.debug(traceback.format_exc()) + logger.critical(f"Bitte Logdatei pruefen fuer Details: {LOG_FILE}") + return # Beende Skript + + + # --- Modusauswahl und Ausfuehrung --- + start_process_time = time.time() # Zeitmessung fuer die Verarbeitung starten + logger.info(f"Starte Verarbeitung um {datetime.now().strftime('%H:%M:%S')}...") + + + selected_mode = None # Variable fuer den tatsaechlich auszufuehrenden Modus + + # --- Ermitteln des zu fuehrenden Modus (CLI hat Prioritaet vor interaktiver Auswahl) --- + # Wenn das --mode Argument ueber die Kommandozeile gesetzt wurde + if args.mode: + selected_mode = args.mode.lower() # Konvertiere zu Kleinbuchstaben + # Pruefen Sie, ob der gewaehlte Modus in der Liste der validen Modi enthalten ist + if selected_mode not in valid_modes: + # Logge einen Fehler und beende das Skript, wenn der Modus ungueltig ist. + logger.error(f"Ungueltiger Modus '{args.mode}' ueber Kommandozeile angegeben. Gueltige Modi: {', '.join(valid_modes)}") + print(f"Fehler: Ungueltiger Modus '{args.mode}'. Bitte ueberpruefen Sie die Liste der gueltigen Modi (siehe --help).") + return # Beende das Skript + logger.info(f"Betriebsmodus (CLI gewaehlt): {selected_mode}") + + # Wenn das --mode Argument NICHT ueber die Kommandozeile gesetzt wurde + else: + # --- Interaktive Modusauswahl ueber die Konsole --- + print("\nBitte waehlen Sie den Betriebsmodus:") + # Zeigen Sie die Liste der validen Modi kategorisiert an, mit Nummern. + mode_options_map = {} # Dictionary zum Abbilden von Zahl/Name auf Modusname + option_counter = 1 # Zaehler fuer die numerischen Optionen + # Iteriere durch die Kategorien und Modi + for category, modes in mode_categories.items(): + print(f"\n{category}:") + for mode in modes: + print(f" {option_counter}: {mode}") + mode_options_map[str(option_counter)] = mode # Bilde die numerische Option auf den Modusnamen ab + mode_options_map[mode] = mode # Bilde den Modusnamen (kleingeschrieben) auf sich selbst ab (fuer direkte Eingabe) + option_counter += 1 # Erhoehe den Zaehler + + + # Fuegen Sie eine Option zum Abbrechen hinzu + print(f"\n 0: Abbrechen") + mode_options_map['0'] = 'exit' # Bilde 0 auf den speziellen 'exit' Modus ab + + + # Schleife, bis ein gueltiger Modus gewaehlt wurde oder der Benutzer abbricht + while selected_mode is None: + try: + # Lesen Sie die Eingabe vom Benutzer + mode_input = input(f"Geben Sie den Modusnamen oder die Zahl ein: ").strip().lower() + + # Pruefen Sie, ob die Eingabe einer Option in der Map entspricht + if mode_input in mode_options_map: + selected_mode = mode_options_map[mode_input] # Setzen Sie den gewaehlten Modusnamen + + # Wenn der 'exit' Modus gewaehlt wurde + if selected_mode == 'exit': + logger.info("Modus 'exit' gewaehlt. Skript wird beendet.") + print("Abgebrochen durch Benutzer.") + return # Beende das Skript + + # Logge den gewaehlten Modus + logger.info(f"Betriebsmodus (interaktiv gewaehlt): {selected_mode}") + + else: + # Wenn die Eingabe keinem gueltigen Modus entspricht + print("Ungueltige Eingabe. Bitte waehlen Sie eine gueltige Option aus der Liste.") + + # Wenn selected_mode immer noch None ist, laeuft die Schleife weiter + + + except EOFError: # Benutzer hat Ctrl+D gedrueckt (End-of-File) + # Fangen Sie das EOFError ab und beenden Sie das Skript sauber. + logger.warning("Interaktive Modus-Eingabe abgebrochen (EOFError). Skript wird beendet.") + print("\nEingabe abgebrochen.") + return # Beende das Skript + except Exception as e: + # Fangen Sie andere unerwartete Fehler bei der Eingabe ab + logger.error(f"Fehler bei interaktiver Modus-Eingabe: {e}") + logger.debug(traceback.format_exc()) + print(f"Ein Fehler ist bei der Modus-Eingabe aufgetreten ({e}). Bitte pruefen Sie die Logdatei.") + return # Beende das Skript bei unerwartetem Fehler + + + # --- Ausfuehrung des gewaehlten Modus --- + try: + # Holen Sie die CLI-Argumente fuer Start/End/Limit/Steps + limit_arg = args.limit + start_row_arg = args.start_sheet_row + end_row_arg = args.end_sheet_row + + # Sonderbehandlung fuer --steps Argument (relevant fuer reeval und full_run) + steps_to_run_set = set() # Initialisiere ein leeres Set + # Pruefen Sie, ob das --steps Argument gesetzt ist und nicht "all" (case-insensitive) + if args.steps and isinstance(args.steps, str) and args.steps.strip().lower() != 'all': + # Teilen Sie den String in Schritte auf und bereinigen Sie Leerzeichen + steps_list = [step.strip().lower() for step in args.steps.split(',') if step.strip()] + # Filtern Sie nur erlaubte Schritte (die von _process_single_row verstanden werden Block 19) + steps_to_run_set = set(step for step in steps_list if step in valid_single_row_steps) # valid_single_row_steps wurde oben definiert + + # Logge eine Warnung, wenn ungueltige Schritte angegeben wurden + if len(steps_to_run_set) != len(steps_list): + invalid_steps = [step for step in steps_list if step not in valid_single_row_steps] + logger.warning(f"Ignoriere ungueltige Schritte im --steps Argument: {invalid_steps}. Fuehre nur {steps_to_run_set} aus.") + + # Wenn nach der Filterung keine gueltigen Schritte uebrig sind + if not steps_to_run_set: + logger.error("Keine gueltigen Schritte im --steps Argument gefunden. Re-Eval/Full-Run kann nicht gestartet werden.") + print("Fehler: Keine gueltigen Schritte fuer den Modus ausgewaehlt. Bitte ueberpruefen Sie das --steps Argument.") + return # Skript beenden, wenn keine Schritte ausgewaehlt sind + + # Wenn das --steps Argument 'all' ist oder nicht gesetzt + else: + # Fuhren Sie standardmaessig alle gueltigen Single-Row Schritte aus. + steps_to_run_set = set(valid_single_row_steps) # valid_single_row_steps wurde oben definiert + # Logge, welche Schritte ausgewaehlt wurden, wenn es der Standard ist + if default_steps_arg: # Wenn es ueberhaupt gueltige Schritte gibt + logger.debug(f"--steps Argument 'all' oder nicht gesetzt. Standard Schritte: {steps_to_run_set}.") + + + # Dispatching basierend auf dem gewaehlten Modus (selected_mode) + logger.info(f"Starte Ausfuehrung des Modus: {selected_mode}") + + # ---- Kombinierte LÄUFE ---- + elif selected_mode == "combined_all": + # Führt die wichtigsten Batch-Modi nacheinander aus + logger.info("--- Start Kombinierter Modus: wiki_verify ---") + # Rufe die Methode der DataProcessor Instanz auf (Block 26) + data_processor.process_verification_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) + logger.info("--- Start Kombinierter Modus: website_scraping ---") + # Rufe die Methode der DataProcessor Instanz auf (Block 27) + data_processor.process_website_scraping_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) + logger.info("--- Start Kombinierter Modus: summarize_website ---") + # Rufe die Methode der DataProcessor Instanz auf (Block 28) + data_processor.process_summarization_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) + logger.info("--- Start Kombinierter Modus: branch_eval ---") + # Rufe die Methode der DataProcessor Instanz auf (Block 29) + data_processor.process_branch_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) + # TODO: Fuegen Sie hier weitere Batch-Modi hinzu, falls sie im kombinierten Lauf enthalten sein sollen + logger.info("--- Kombinierter Modus abgeschlossen ---") + + + # ---- Batch-VERARBEITUNG (Schritt-Optimiert) ---- + elif selected_mode == "wiki_verify": # Uebereinstimmend mit process_verification_batch (Block 26) + # Rufe die Methode der DataProcessor Instanz auf + data_processor.process_verification_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) + + elif selected_mode == "website_scraping": # Uebereinstimmend mit process_website_scraping_batch (Block 27) + # Rufe die Methode der DataProcessor Instanz auf + data_processor.process_website_scraping_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) + + elif selected_mode == "summarize_website": # Uebereinstimmend mit process_summarization_batch (Block 28) + # Rufe die Methode der DataProcessor Instanz auf + data_processor.process_summarization_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) + + elif selected_mode == "branch_eval": # Uebereinstimmend mit process_branch_batch (Block 29) + # Rufe die Methode der DataProcessor Instanz auf + data_processor.process_branch_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) + + + # ---- Sequentielle VERARBEITUNG (Zeilenweise) ---- + elif selected_mode == "full_run": # Nutzt process_rows_sequentially (Block 24) + # Full_run verarbeitet sequentiell einen Bereich. + # Startzeile wird vom CLI Argument oder automatisch ermittelt (erste leere AO). + # Endzeile vom CLI Argument oder bis Ende Sheet. + # Limit begrenzt die Anzahl der *verarbeiteten* Zeilen im Bereich. + + calculated_start_sheet_row = start_row_arg # Beginne mit CLI Argument start_sheet_row + # Wenn start_sheet_row nicht ueber CLI gesetzt wurde + if calculated_start_sheet_row is None: + # Automatische Ermittlung der Startzeile (erste Zeile ohne AO) + logger.info("Automatische Ermittlung der Startzeile fuer sequenzielle Verarbeitung (erste Zeile ohne AO)...") + # get_start_row_index (Block 14) gibt 0-basierten Index in Daten (ohne Header) zurueck. + # Prueft auf leeren AO (Block 1 Column Map). + start_data_index_no_header = sheet_handler.get_start_row_index(check_column_key="Timestamp letzte Pruefung", min_sheet_row=7) + + # Wenn get_start_row_index -1 zurueckgibt (Fehler) + if start_data_index_no_header == -1: + logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Kann Full-Run nicht starten.") + return # Beende das Skript + + # Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten Daten-Index + calculated_start_sheet_row = start_data_index_no_header + sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut + + + # Berechnen Sie die tatsaechliche Anzahl der zu verarbeitenden Zeilen im Bereich. + # (basierend auf Endzeile und Limit) + total_sheet_rows = len(sheet_handler.get_all_data_with_headers()) # Block 14 SheetHandler + calculated_end_sheet_row = end_row_arg if end_row_arg is not None else total_sheet_rows + # Stellen Sie sicher, dass die Endzeile nicht vor der Startzeile liegt + calculated_end_sheet_row = max(calculated_start_sheet_row - 1, calculated_end_sheet_row) + + + # Die Anzahl der Zeilen im betrachteten Bereich + rows_in_range = max(0, calculated_end_sheet_row - calculated_start_sheet_row + 1) + + # num_to_process ist das Limit, angewendet auf die Zeilen im Bereich. + num_to_process_calc = rows_in_range # Standard: alle Zeilen im Bereich + # Wenn ein Limit ueber CLI gesetzt wurde und es gueltig ist + if limit_arg is not None and isinstance(limit_arg, int) and limit_arg >= 0: + num_to_process_calc = min(rows_in_range, limit_arg) + + + # Wenn es Zeilen zu verarbeiten gibt + if num_to_process_calc > 0: + logger.info(f"'full_run': Verarbeite {num_to_process_calc} Zeilen im Sheet-Bereich [{calculated_start_sheet_row}, {calculated_end_sheet_row}].") + # Rufe die sequentielle Verarbeitungsmethode auf (Block 24) + # _process_single_row (Block 19) wird intern aufgerufen. + data_processor.process_rows_sequentially( + start_sheet_row = calculated_start_sheet_row, + num_to_process = num_to_process_calc, + # Uebergeben Sie die aus dem --steps Argument ermittelten Flags (steps_to_run_set) + process_wiki_steps='wiki' in steps_to_run_set, + process_chatgpt_steps='chat' in steps_to_run_set, + process_website_steps='web' in steps_to_run_set, + process_ml_steps='ml_predict' in steps_to_run_set + # TODO: Weitere Schritt-Flags hier uebergeben + # force_reeval_in_single_row=False # Normalerweise kein Re-Eval im Full-Run + # clear_x_flag=False # Normalerweise kein X loeschen im Full-Run + ) + else: + # Wenn keine Zeilen zu verarbeiten sind + logger.info(f"Keine Zeilen fuer 'full_run' zu verarbeiten im Bereich [{calculated_start_sheet_row}, {calculated_end_sheet_row}] mit Limit {limit_arg}.") + + + # ---- Re-EVALUATE Markierte Zeilen ---- + elif selected_mode == "reeval": # Nutzt process_reevaluation_rows (Block 25) + # reeval Modus nutzt immer force_reeval=True in _process_single_row. + # Das 'x'-Flag wird von _process_single_row (Block 21) geloescht, wenn clear_flag=True uebergeben wird. + # Das Limit wird direkt an process_reevaluation_rows uebergeben und dort gehandhabt. + if limit_arg is not None and isinstance(limit_arg, int) and limit_arg <= 0: + # Wenn ein Limit von 0 oder weniger angegeben wurde + logger.info(f"Limit {limit_arg} angegeben im Re-Eval Modus. Ueberspringe Verarbeitung.") + else: + # Rufe die Methode der DataProcessor Instanz auf (Block 25) + data_processor.process_reevaluation_rows( + row_limit=limit_arg, # Uebergibt das Limit (kann None sein) + clear_flag=True, # Standardmaessig das 'x'-Flag loeschen + # Uebergeben Sie die aus dem --steps Argument ermittelten Schritte (steps_to_run_set) + process_wiki_steps='wiki' in steps_to_run_set, + process_chatgpt_steps='chat' in steps_to_run_set, + process_website_steps='web' in steps_to_run_set, + process_ml_steps='ml_predict' in steps_to_run_set + # TODO: Weitere Schritt-Flags hier uebergeben + ) + + + # ---- Einzelne DIENSTPROGRAMME / SUCHEN ---- + elif selected_mode == "find_wiki_serp": # Nutzt process_find_wiki_serp (Block 30) + # find_wiki_serp sucht leere AY mit Groessenfilter. Nutzt limit, min_employees, min_umsatz. + # Start/Endzeile koennen manuell gesetzt werden oder werden automatisch ermittelt (erste leere AY). + data_processor.process_find_wiki_serp( + start_sheet_row=start_row_arg, # Kann manuell gesetzt werden + end_sheet_row=end_row_arg, # Kann manuell gesetzt werden + limit=limit_arg, # Kann manuell gesetzt werden + min_employees=args.min_employees, # Aus CLI Argument + min_umsatz=args.min_umsatz # Aus CLI Argument + ) + + elif selected_mode == "website_lookup": # Nutzt process_serp_website_lookup (Block 30) + # website_lookup sucht leere D. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden. + data_processor.process_serp_website_lookup( + start_sheet_row=start_row_arg, # Kann manuell gesetzt werden + end_sheet_row=end_row_arg, # Kann manuell gesetzt werden + limit=limit_arg # Kann manuell gesetzt werden + ) + + elif selected_mode == "contacts": # Nutzt process_contact_search (Block 30) + # contacts sucht leere AM. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden. + data_processor.process_contact_search( + start_sheet_row=start_row_arg, # Kann manuell gesetzt werden + end_sheet_row=end_row_arg, # Kann manuell gesetzt werden + limit=limit_arg # Kann manuell gesetzt werden + ) + + elif selected_mode == "update_wiki_suggestions": # Nutzt process_wiki_updates_from_chatgpt (Block 32) + # update_wiki_suggestions prueft Status S. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden. + data_processor.process_wiki_updates_from_chatgpt( + start_sheet_row=start_row_arg, # Kann manuell gesetzt werden + end_sheet_row=end_row_arg, # Kann manuell gesetzt werden + limit=limit_arg # Kann manuell gesetzt werden + ) + + elif selected_mode == "wiki_reextract_missing_an": # Nutzt process_wiki_reextract_missing_an (Block 32) + # wiki_reextract_missing_an sucht M gefuellt & AN leer. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden. + # Ruft intern _process_single_row mit steps={'wiki'} und force_reeval=True auf. + data_processor.process_wiki_reextract_missing_an( + start_sheet_row=start_row_arg, # Kann manuell gesetzt werden + end_sheet_row=end_row_arg, # Kann manuell gesetzt werden + limit=limit_arg # Kann manuell gesetzt werden + ) + + + elif selected_mode == "website_details": # EXPERIMENTELL - Nutzt process_website_details (Block 32) + # website_details sucht 'x' in A. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden. + data_processor.process_website_details( + start_sheet_row=start_row_arg, # Kann manuell gesetzt werden + end_sheet_row=end_row_arg, # Kann manuell gesetzt werden + limit=limit_arg # Kann manuell gesetzt werden + ) + + + elif selected_mode == "train_technician_model": # Nutzt train_technician_model (Block 31) + # training braucht keine Zeilenlimits im Sinne eines Bereichs oder der Anzahl zu verarbeitender Zeilen im Sheet. + # Es nutzt prepare_data_for_modeling (Block 31), die alle relevanten Zeilen filtert. + # Die output-Pfade werden aus CLI Argumenten genommen (args). + data_processor.train_technician_model( + model_out=args.model_out, # Aus CLI Argument + imputer_out=args.imputer_out, # Aus CLI Argument + patterns_out=args.patterns_out # Aus CLI Argument (JSON Datei) + ) + + elif selected_mode == "alignment": # Nutzt globale alignment_demo (Block 14) + # alignment_demo ist eine globale Funktion, die das sheet Objekt braucht. + # Sie braucht keine Zeilenlimits oder Start/Ende. + if sheet_handler and sheet_handler.sheet: + alignment_demo(sheet_handler.sheet) + else: + logger.error("Sheet-Handler oder Sheet-Objekt nicht verfuegbar fuer Alignment-Demo.") + + + # ---- Modus nicht gefunden (sollte durch Validierung oben abgefangen werden) ---- + else: + # Dieser Zweig sollte aufgrund der Validierung am Anfang nie erreicht werden. + logger.error(f"Unerwarteter Modus '{selected_mode}' erreichte das Ausfuehrungsende des Dispatchers.") + print(f"Interner Fehler: Unbekannter Modus '{selected_mode}'.") + + + # --- Ausnahmebehandlung fuer den gesamten Ausfuehrungsblock --- + except KeyboardInterrupt: + # Wenn der Benutzer das Skript manuell unterbricht (Ctrl+C) + logger.warning("Skript durch Benutzer unterbrochen (KeyboardInterrupt).") + print("\n! Skript wurde manuell beendet.") + except Exception as e: + # Dieser Block faengt alle unerwarteten Exceptions ab, die in den aufgerufenen + # Funktionen/Methoden passieren und nicht intern gefangen und behandelt werden. + logger.critical(f"FATAL: Unerwarteter Fehler waehrend der Ausfuehrung von Modus '{selected_mode}': {e}") + # exception() loggt den Fehlertyp, die Nachricht und den vollständigen Traceback. + logger.exception("Traceback des kritischen Fehlers:") + # Gebe eine Fehlermeldung an die Konsole aus, die auf das Log verweist. + print(f"\n! Ein kritischer Fehler ist aufgetreten: {type(e).__name__} - {e}") + print(f"Bitte pruefen Sie die Logdatei fuer Details: {LOG_FILE}") + + + # --- Abschluss der Skriptausfuehrung --- + end_process_time = time.time() # Zeitmessung fuer die Verarbeitung starten + duration = end_process_time - start_process_time # Berechne die Gesamtdauer + logger.info(f"Verarbeitung abgeschlossen um {datetime.now().strftime('%H:%M:%S')}.") + logger.info(f"Gesamtdauer: {duration:.2f} Sekunden.") + logger.info(f"===== Skript beendet =====") + + # Schliesse Logging Handler explizit + # Dies stellt sicher, dass alle gepufferten Logmeldungen in die Datei geschrieben werden. + logging.shutdown() + + # Logfile Pfad fuer den Nutzer auf der Konsole ausgeben + if LOG_FILE: + print(f"\nVerarbeitung abgeschlossen. Logfile: {LOG_FILE}") + else: + print("\nVerarbeitung abgeschlossen. Es konnte keine Logdatei erstellt werden.") + + +# ============================================================================== +# 7. ENTRY POINT +# ============================================================================== + +# Fuehrt die main-Funktion aus, wenn das Skript direkt gestartet wird. +if __name__ == '__main__': + # Die main() Funktion enthaltet nun die gesamte Logik und Initialisierung. + # Alle globalen imports (Block 1) und globalen Funktionen (Block 2-13) MÜSSEN VOR diesem Block definiert sein. + # Alle Klassen (Block 14-33) MÜSSEN VOR diesem Block definiert sein. + + main() - main() \ No newline at end of file