diff --git a/brancheneinstufung.py b/brancheneinstufung.py index f66288ce..ef168587 100644 --- a/brancheneinstufung.py +++ b/brancheneinstufung.py @@ -1,1228 +1,884 @@ -# ============================================================================== -# brancheneinstufung.py - Firmen-Datenanreicherungs-Skript -# Version 1.7.0 -# Dieses Skript automatisiert die Anreicherung, Validierung und Standardisierung -# von Unternehmensdaten in einem Google Sheet mittels Web Scraping und APIs. -# Es beinhaltet auch Datenvorbereitung für ein ML-Modell. -# ============================================================================== +#!/usr/bin/env python3 +""" +Automatisiertes Unternehmensbewertungs-Skript - Refactoring v1.7.0 +Basierend auf v1.6.x - Umstrukturierung in modulare Klassen und flexibles UI. +Dieses Skript dient der automatisierten Anreicherung, Validierung und Standardisierung +von Unternehmensdaten, primär aus einem Google Sheet, ergänzt durch Web Scraping, +Wikipedia, OpenAI (ChatGPT) und SerpAPI (Google Search, LinkedIn). + +Autor: [Ihr Name/Pseudonym] +Version: v1.7.0 + +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. +""" + +# ============================================================================== +# 1. IMPORTS +# ============================================================================== +# Standardbibliotheken import os import time import re +import csv +import json +import pickle +import threading +import traceback +import logging +import argparse +from datetime import datetime +from urllib.parse import urlparse, urlencode, unquote # unquote wird später benötigt + +# Externe Bibliotheken import gspread import wikipedia import requests import openai from bs4 import BeautifulSoup -from oauth2client.service_account import ServiceAccountCredentials -from datetime import datetime +from oauth2client.service_account import ServiceAccountCredentials # gspread dependency from difflib import SequenceMatcher import unicodedata -import csv -import gender_guesser.detector as gender -from urllib.parse import urlparse, urlencode, unquote -import argparse import pandas as pd import numpy as np from sklearn.model_selection import train_test_split, GridSearchCV from sklearn.impute import SimpleImputer from sklearn.tree import DecisionTreeClassifier, export_text from sklearn.metrics import accuracy_score, classification_report, confusion_matrix -import json -import pickle import concurrent.futures -import threading -import traceback # Importiere traceback für detailliertere Fehlermeldungen -import logging -import functools # Für retry decorator +# Spezifische externe Tools +import gender_guesser.detector as gender # Für Geschlechtserkennung # Optional: tiktoken für Token-Zählung (Modus 8) try: import tiktoken + print("tiktoken importiert.") # Debugging-Ausgabe except ImportError: tiktoken = None + print("tiktoken nicht gefunden. Token-Zählung wird geschätzt.") # Debugging-Ausgabe -# ==================== KONSTANTEN ==================== +# ============================================================================== +# 2. GLOBALE KONSTANTEN UND KONFIGURATION +# (Entspricht logisch etwa 'config.py') +# ============================================================================== + +# --- Dateipfade --- CREDENTIALS_FILE = "service_account.json" -API_KEY_FILE = "api_key.txt" +API_KEY_FILE = "api_key.txt" # OpenAI SERP_API_KEY_FILE = "serpApiKey.txt" GENDERIZE_API_KEY_FILE = "genderize_API_Key.txt" -BRANCH_MAPPING_FILE = "ziel_Branchenschema.csv" +BRANCH_MAPPING_FILE = "ziel_Branchenschema.csv" # Enthält Zielschema LOG_DIR = "Log" -# --- NEU: Dateinamen für Modell-Artefakte --- + +# --- 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 -# ==================== KONFIGURATION ==================== +# --- Globale Konfiguration Klasse --- class Config: - VERSION = "v1.7.0" # Versionsnummer erhöhen - LANG = "de" # Standard Wikipedia Sprache - SHEET_URL = "https://docs.google.com/spreadsheets/d/1u_gHr9JUfmV1-iviRzbSe3575QEp7KLhK5jFV_gJcgo" # Ihre Sheet URL - MAX_RETRIES = 3 # Maximale Anzahl von Wiederholungen für fehlgeschlagene Operationen - RETRY_DELAY = 5 # Wartezeit in Sekunden zwischen Retries (Basis) - SIMILARITY_THRESHOLD = 0.65 # Schwellenwert für Namensähnlichkeit (z.B. bei Wikipedia) - DEBUG = True # Steuerflag für debug_print (sollte konsequent auf logging umgestellt werden) - WIKIPEDIA_SEARCH_RESULTS = 5 # Anzahl der Suchergebnisse, die von wikipedia.search geprüft werden - HTML_PARSER = "html.parser" # HTML Parser für BeautifulSoup - TOKEN_MODEL = "gpt-3.5-turbo" # OpenAI Modell für Token-Zählung (und ggf. Standard für API Calls) - USER_AGENT = 'Mozilla/5.0 (compatible; Datenanreicherungsskript/1.0; +https://ihre-website.de/bot)' # User-Agent für Web-Anfragen + """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/IHR_TATSÄCHLICHE_SHEET_ID/edit" # <<< 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 + 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 # --- Konfiguration für Batching & Parallelisierung --- - BATCH_SIZE = 10 # Batch-Größe für Wiki Verification (_process_batch) - PROCESSING_BATCH_SIZE = 20 # Wie viele Zeilen pro Verarbeitungs-Batch sammeln (z.B. für paralleles Website Scraping) - OPENAI_BATCH_SIZE_LIMIT = 4 # Max. Texte pro OpenAI Call in summarize_batch_openai - MAX_SCRAPING_WORKERS = 10 # Threads für paralleles Website-Scraping - UPDATE_BATCH_ROW_LIMIT = 50 # Anzahl der Zeilen, nach denen gesammelte Sheet Updates gesendet werden (nicht überall konsistent verwendet) - MAX_BRANCH_WORKERS = 10 # Threads für parallele Branch-Bewertung - OPENAI_CONCURRENCY_LIMIT = 5 # Max. gleichzeitige OpenAI Calls für Branch Bewertung - PROCESSING_BRANCH_BATCH_SIZE = PROCESSING_BATCH_SIZE # Nutze dieselbe Batch-Größe wie Website, oder definiere neu (z.B. 20) + # 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 + SERPAPI_DELAY = 1.5 # Pause zwischen einzelnen SerpAPI-Aufrufen (Sekunden) + # --- API Schlüssel Speicherung --- API_KEYS = {} + @classmethod def load_api_keys(cls): """Lädt API-Schlüssel aus den definierten Dateien.""" - logging.info("Lade API-Schlüssel...") - # Annahme: Die Dateipfade sind korrekt und die Dateien existieren (oder werden behandelt) + # Der Logger ist hier noch nicht vollständig konfiguriert, verwenden Sie print oder debug_print + # logging.info wird nach Konfiguration des File Handlers korrekt funktionieren + print("Lade API-Schlüssel...") 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'): openai.api_key = cls.API_KEYS['openai'] - logging.info("OpenAI API Key erfolgreich geladen.") + print("OpenAI API Key erfolgreich geladen.") else: - logging.warning(f"OpenAI API Key konnte nicht geladen werden (Datei '{API_KEY_FILE}' fehlt oder ist leer?).") - if cls.API_KEYS.get('serpapi'): - logging.info("SerpAPI Key erfolgreich geladen.") - else: - logging.warning(f"SerpAPI Key konnte nicht geladen werden (Datei '{SERP_API_KEY_FILE}' fehlt oder ist leer?). Einige Modi/Funktionen könnten fehlschlagen.") - if cls.API_KEYS.get('genderize'): - logging.info("Genderize API Key erfolgreich geladen.") - else: - logging.warning(f"Genderize API Key konnte nicht geladen werden (Datei '{GENDERIZE_API_KEY_FILE}' fehlt oder ist leer?). Kontakt-Gender wird möglicherweise nicht ermittelt.") + print("WARNUNG: OpenAI API Key konnte nicht geladen werden (Datei fehlt oder ist leer?).") + + if not cls.API_KEYS.get('serpapi'): + print("WARNUNG: SerpAPI Key konnte nicht geladen werden (Datei fehlt oder ist leer?). Bestimmte Funktionen 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.") @staticmethod def _load_key_from_file(filepath): """Hilfsfunktion zum Laden eines Schlüssels aus einer Datei.""" try: - with open(filepath, "r", encoding="utf-8") as f: # Encoding hinzugefügt + with open(filepath, "r", encoding="utf-8") as f: key = f.read().strip() if key: - # logging.debug(f"Schlüssel aus '{filepath}' erfolgreich geladen.") # Zu detailliert für normale Läufe + # print(f"Schlüssel aus '{filepath}' erfolgreich geladen.") # Zu viel Lärm im Debug return key else: - # logging.warning(f"Datei '{filepath}' ist leer.") # Weniger Lärm + print(f"WARNUNG: Datei '{filepath}' ist leer.") return None except FileNotFoundError: # Info, da das Fehlen eines Keys nicht immer ein Fehler sein muss - # logging.info(f"API-Schlüsseldatei '{filepath}' nicht gefunden.") # Weniger Lärm + print(f"INFO: API-Schlüsseldatei '{filepath}' nicht gefunden.") return None except Exception as e: - # Error, wenn beim Lesen ein anderer Fehler auftritt - logging.error(f"Fehler beim Lesen der Schlüsseldatei '{filepath}': {e}") + print(f"FEHLER beim Lesen der Schlüsseldatei '{filepath}': {e}") return None -# Globales Mapping-Dictionary und Schema-String -BRANCH_MAPPING = {} +# --- 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. +COLUMN_MAP = { + "ReEval Flag": 0, # A - Markierungsspalte für 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 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 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) + "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 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 + "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) + "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) + "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) + "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) + "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! + +# --- 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." -ALLOWED_TARGET_BRANCHES = [] +ALLOWED_TARGET_BRANCHES = [] # Liste der erlaubten Kurzformen -# ==================== LOGGING SETUP (INITIAL) ==================== -# Initiales Logging Setup. File Handler wird in main() hinzugefügt. -# Root-Logger konfigurieren (noch ohne File Handler). -# handlers=[] verhindert default Console Handler, wir fügen ihn manuell hinzu. -logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)-8s - %(name)-15s - %(message)s', handlers=[]) -# Console Handler explizit hinzufügen -console_handler = logging.StreamHandler() -console_handler.setLevel(logging.DEBUG) # Debug Level an Konsole -console_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)-8s - %(name)-15s - %(message)s')) -logging.getLogger('').addHandler(console_handler) # Füge zum Root-Logger hinzu +# ============================================================================== +# 3. GLOBALE HELPER FUNCTIONS & DECORATORS +# (Entspricht logisch etwa 'utils.py') +# ============================================================================== -# debug_print wird beibehalten, um bestehenden Code schnell laufen zu lassen, -# sollte aber konsequent durch logging.debug/info/etc. ersetzt werden. -def debug_print(message): - """Gibt Nachrichten aus, wenn Config.DEBUG True ist. Schreibt auch in die Logdatei.""" - # Loggen direkt mit dem Standard logging-Modul als INFO - logging.info(message) +# Logger Setup (Wird in main() finalisiert) +# Erhalten Sie eine Logger-Instanz für dieses Modul +logger = logging.getLogger(__name__) -# ==================== RETRY DECORATOR ==================== +# --- Retry Decorator --- +# Übernommen aus Ihrem Code (Teil 1) def retry_on_failure(func): """ - Decorator, der eine Funktion mit exponentiellem Backoff wiederholt, - wenn eine Exception auftritt (insbesondere Netzwerk-/API-Fehler). + Decorator, der eine Funktion bei bestimmten Fehlern mehrmals wiederholt. + Implementiert exponentiellen Backoff. """ - @functools.wraps(func) # Behält Metadaten der Originalfunktion def wrapper(*args, **kwargs): func_name = func.__name__ - # Versuche, das 'self' Argument für Methoden zu extrahieren - # args[0] ist self bei Instanzmethoden - self_arg = args[0] if args and hasattr(args[0], func_name) and not isinstance(args[0], type) else None + # Versuche, das 'self' Argument für 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 effective_func_name = f"{self_arg.__class__.__name__}.{func_name}" if self_arg else func_name - for attempt in range(Config.MAX_RETRIES): + # Basiswartezeit und maximale Anzahl Versuche aus Config holen + max_retries = getattr(Config, 'MAX_RETRIES', 3) + base_delay = getattr(Config, 'RETRY_DELAY', 5) + + for attempt in range(max_retries): try: + # debug_print(f"Versuch {attempt + 1}/{max_retries} für '{effective_func_name}'...") # Zu viel Lärm return func(*args, **kwargs) + except (requests.exceptions.RequestException, gspread.exceptions.APIError, openai.error.OpenAIError, wikipedia.exceptions.WikipediaException) as e: + # Diese spezifischen Fehler werden für einen Retry behandelt + error_msg = str(e) + error_type = type(e).__name__ + + # Logge den Fehler auf WARN/ERROR Level + if isinstance(e, gspread.exceptions.APIError) and e.response.status_code == 429: + logger.warning(f"🚦 RATE LIMIT ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries}). {error_msg[:150]}...") + elif isinstance(e, requests.exceptions.Timeout): + logger.warning(f"⏰ TIMEOUT ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries}). {error_msg[:150]}...") + elif isinstance(e, requests.exceptions.RequestException): + logger.warning(f"🌐 NETZWERKFEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries}). {error_msg[:150]}...") + elif isinstance(e, openai.error.OpenAIError): + logger.warning(f"🤖 OPENAI FEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries}). {error_msg[:150]}...") + elif isinstance(e, wikipedia.exceptions.WikipediaException): + logger.warning(f"📚 WIKIPEDIA FEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries}). {error_msg[:150]}...") + else: # Andere gefangene Exceptions (sollten seltener sein, aber sicherheitshalber) + logger.warning(f"⚠️ BEHANDELTER FEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries}). {error_msg[:150]}...") + + if attempt < max_retries - 1: + # Wartezeit berechnen: Basis * (2^attempt) - Fügen Sie etwas Zufälligkeit hinzu, um Thundering Herd zu vermeiden + wait_time = base_delay * (2 ** attempt) + random.uniform(0, 1) # random importieren! + logger.info(f"Warte {wait_time:.2f}s vor Versuch {attempt+2}...") + time.sleep(wait_time) + else: + # Letzter Versuch fehlgeschlagen + logger.error(f"❌ ENDGÜLTIGER FEHLER bei '{effective_func_name}' nach {max_retries} Versuchen.") + # Den ursprünglichen Fehler erneut werfen, damit die aufrufende Logik ihn behandeln kann + # (z.B. einen Fehlerwert in das Sheet schreiben) + raise e except Exception as e: - error_msg = str(e) - # Spezifische Fehlerbehandlung (Beispiel) - is_rate_limit = False - if isinstance(e, gspread.exceptions.APIError): - if hasattr(e, 'response') and e.response.status_code == 429: # Google Rate Limit - is_rate_limit = True - logging.warning(f"⚠️ Google API Fehler bei {effective_func_name} (Versuch {attempt+1}/{Config.MAX_RETRIES}): Status {e.response.status_code} - {error_msg[:150]}...") - if hasattr(e, 'response') and e.response.text: logging.debug(f" Details: {e.response.text[:200]}...") - elif isinstance(e, requests.exceptions.RequestException): - if hasattr(e, 'response') and e.response is not None and e.response.status_code == 429: # HTTP 429 (auch SerpAPI, Genderize etc.) - is_rate_limit = True - logging.warning(f"⚠️ Netzwerkfehler bei {effective_func_name} (Versuch {attempt+1}/{Config.MAX_RETRIES}): {type(e).__name__} - {error_msg[:150]}...") - elif isinstance(e, openai.error.OpenAIError): - if hasattr(e, 'response') and e.response.status_code == 429: # OpenAI Rate Limit - is_rate_limit = True - logging.warning(f"⚠️ OpenAI Fehler bei {effective_func_name} (Versuch {attempt+1}/{Config.MAX_RETRIES}): {type(e).__name__} - {error_msg[:150]}...") - elif isinstance(e, wikipedia.exceptions.WikipediaException): - logging.warning(f"⚠️ Wikipedia Bibliothek Fehler bei {effective_func_name} (Versuch {attempt+1}/{Config.MAX_RETRIES}): {type(e).__name__} - {error_msg[:150]}...") - else: - logging.warning(f"⚠️ Unbekannter Fehler bei {effective_func_name} (Versuch {attempt+1}/{Config.MAX_RETRIES}): {type(e).__name__} - {error_msg[:150]}...") + # Nicht spezifizierte Fehler (z.B. Programmierfehler, unerwartete Ausnahmen) + # Diese sollten NICHT wiederholt werden, da sie wahrscheinlich strukturell sind. + logger.critical(f"💥 UNERWARTETER FEHLER ({type(e).__name__}) bei '{effective_func_name}'. KEIN RETRY VERSUCHT.") + logger.exception("Details zum unerwarteten Fehler:") # Logge den vollständigen Traceback + raise e # Fehler sofort weitergeben - if attempt < Config.MAX_RETRIES - 1: - wait_time = Config.RETRY_DELAY * (2 ** attempt if is_rate_limit else 1) # Exponential Backoff nur bei Rate Limits - logging.info(f" Warte {wait_time:.2f}s vor nächstem Versuch...") - time.sleep(wait_time) - else: - logging.error(f"❌ Endgültiger Fehler bei {effective_func_name} nach {Config.MAX_RETRIES} Versuchen.") - # Logge den Traceback beim letzten Versuch als ERROR - logging.exception(f"Traceback für endgültigen Fehler in {effective_func_name}:") - return None # Oder eine spezifische Fehlerkennung zurückgeben, je nach Funktion - return None # Sollte bei erfolgreichen Retries nie erreicht werden, aber zur Sicherheit - return wrapper + # Dieser Teil sollte nur erreicht werden, wenn die Retry-Schleife beendet ist, + # was nur passiert, wenn max_retries > 0 war und alle Versuche fehlschlugen (durch re-raise e am Ende). + # Oder wenn max_retries == 0 war. + # Wir können hier eine Standard-Rückgabe für den Fehlerfall einbauen, falls der Aufrufer dies erwartet. + # Das hängt davon ab, wie die aufgerufenen Funktionen Fehler signalisieren (None, "k.A.", Exception). + # Da wir am Ende eines fehlerhaften Retry-Zyklus ein 'raise e' haben, wird dieser Punkt normalerweise NICHT erreicht, + # wenn ein Fehler auftritt. + # Wenn kein Fehler auftritt, wird der Wert vom 'return func(*args, **kwargs)' zurückgegeben. + # Lassen wir es so, dass Exceptions durchgereicht werden, das ist oft sauberer. + # Wenn die aufrufende Funktion None erwartet bei Fehler, muss sie den raised Exception fangen und None zurückgeben. + pass # Sollte nicht erreicht werden -# ==================== GLOBAL HELPER FUNCTIONS ==================== -# Funktionen, die keine Instanz einer spezifischen Klasse benötigen. + +# Importieren Sie das 'random' Modul, da es im retry_on_failure Decorator verwendet wird. +import random + +# --- Token Count Funktion --- +# Übernommen aus Ihrem Code (Teil 5), leicht angepasst für Logger. +@retry_on_failure # Retry hier nicht sinnvoll, da es lokale Berechnung ist. Entferne Decorator. +def token_count(text, model=None): + """Zählt Tokens via tiktoken oder schätzt über Leerzeichen.""" + 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(text.split()) + else: + # Fallback Schätzung + return len(text.split()) + + +# --- Logging Helpers --- +# Übernommen aus Ihrem Code (Teil 3), leicht angepasst für Standard-Logger. +LOG_FILE = None # Wird in main() gesetzt def create_log_filename(mode): - """Erstellt einen eindeutigen Log-Dateinamen basierend auf Datum, Version und Modus.""" + """Erstellt einen zeitgestempelten Logdateinamen im LOG_DIR.""" + # Der Logger ist hier möglicherweise noch nicht voll konfiguriert. Verwenden Sie print. if not os.path.exists(LOG_DIR): - os.makedirs(LOG_DIR) - now = datetime.now().strftime("%Y-%m-%d_%H-%M") - ver_short = Config.VERSION.replace(".", "") - return os.path.join(LOG_DIR, f"{now}_{ver_short}_Modus{mode}.txt") + try: + os.makedirs(LOG_DIR, exist_ok=True) # exist_ok=True verhindert Fehler, wenn Dir existiert + print(f"Log-Verzeichnis '{LOG_DIR}' erstellt.") + except Exception as e: + print(f"FEHLER: Konnte Log-Verzeichnis '{LOG_DIR}' nicht erstellen: {e}") + # Versuche, die Datei im aktuellen Verzeichnis zu erstellen, wenn LOG_DIR fehlschlägt + LOG_DIR_FALLBACK = "." + print(f"Versuche, Logdatei im aktuellen Verzeichnis '{LOG_DIR_FALLBACK}' zu erstellen.") + try: + now = datetime.now().strftime("%d-%m-%Y_%H-%M") + ver_short = getattr(Config, 'VERSION', 'unknown').replace(".", "") + return os.path.join(LOG_DIR_FALLBACK, f"{now}_{ver_short}_Modus{mode}.txt") + except Exception as e_fallback: + print(f"FEHLER: Konnte Logdateinamen auch im Fallback-Verzeichnis nicht erstellen: {e_fallback}") + return None # Signalisiert Fehler + + + 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, filename) + +# 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. + + +# --- Text Normalisierung & Reinigung --- +# Übernommen aus Ihrem Code (Teil 3) +def simple_normalize_url(url): + """Normalisiert URL zu domain.tld oder k.A. (ohne www, ohne Pfad).""" + 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." + # Falls kein Schema vorhanden, hinzufügen (HTTPS bevorzugen) + if not url.lower().startswith(("http://", "https://")): url = "https://" + url + try: + parsed = urlparse(url) + 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:] + return domain_part if '.' in domain_part and domain_part.split('.')[-1].isalpha() else "k.A." # Einfache TLD Prüfung + except Exception as e: + logger.error(f"Fehler bei URL-Normalisierung für '{url[:100]}...': {e}") + return "k.A." + +def normalize_string(s): + """Normalisiert Umlaute und Sonderzeichen nach einer definierten Liste.""" + if not s or not isinstance(s, str): return "" + 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) + return s -# clean_text Funktion def clean_text(text): - """Bereinigt Text von Wikipedia etc. (Unicode, Referenzen, Whitespace).""" + """Bereinigt Text (Unicode, Referenzen, Whitespace, etc.).""" if text is None: return "k.A." try: text = str(text) if not text.strip(): return "k.A." text = unicodedata.normalize("NFC", text) - text = re.sub(r'\[\d+\]', '', text) - text = re.sub(r'\[Bearbeiten\s*\|\s*Quelltext bearbeiten\]', '', text, flags=re.IGNORECASE) - text = re.sub(r'\s+', ' ', text).strip() + 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 return text if text else "k.A." except Exception as e: - logging.error(f"Fehler bei clean_text für Input '{str(text)[:50]}...': {e}") + logger.error(f"Fehler bei clean_text für Input '{str(text)[:50]}...': {e}") return "k.A." -# simple_normalize_url Funktion -def simple_normalize_url(url): - """Normalisiert URL zu domain.tld (ohne www, schema, pfad) oder k.A..""" - 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." - if not url.lower().startswith(("http://", "https://")): url = "https://" + url - try: - parsed = urlparse(url) - domain_part = parsed.netloc - if not domain_part: return "k.A." - domain_part = domain_part.split(":", 1)[0] # Entferne Port - if '@' in domain_part: domain_part = domain_part.split('@', 1)[1] # Entferne Auth - try: domain_part = domain_part.encode('ascii').decode('idna') - except UnicodeDecodeError: pass - domain_part = domain_part.lower() - if domain_part.startswith("www."): domain_part = domain_part[4:] - return domain_part if domain_part and '.' in domain_part else "k.A." - except Exception as e: - logging.error(f"Fehler bei URL-Normalisierung für '{url}': {e}") - return "k.A." - -# normalize_string Funktion -def normalize_string(s): - """Normalisiert Umlaute und Sonderzeichen, entfernt führende/nachfolgende Leerzeichen.""" - if not s or not isinstance(s, str): return "" - s = str(s) - s = unicodedata.normalize("NFC", s) - 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' } - for src, target in replacements.items(): s = s.replace(src, target) - return s.strip() - -# normalize_company_name Funktion def normalize_company_name(name): - """Entfernt gängige Rechtsformzusätze, Interpunktion und generische Begriffe für Vergleiche.""" + """Entfernt gängige Rechtsformzusätze etc. für Vergleiche.""" if not name: return "" name = clean_text(name) - if name == "k.A.": return "" - 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\.?', r'limited', r'ltd\s*&\s*co\.?\s*kg', r's\.?a\.?r\.?l\.?', r'sàrl', r'sagl', r's\.?a\.?', r'société anonyme', r'sociedad anónima', r's\.?p\.?a\.?', r'società per azioni', r'b\.?v\.?', r'besloten vennootschap', r'n\.?v\.?', r'naamloze vennootschap', r'plc\.?', r'public limited company', r'inc\.?', r'incorporated', r'corp\.?', r'corporation', r'llc\.?', r'limited liability company', r'kgaa', r'kommanditgesellschaft auf aktien', r'se', r'societas europaea', r'e\.?g\.?', r'eingetragene genossenschaft', r'genossenschaft', r'genmbh', r'e\.?v\.?', r'eingetragener verein', r'verein', r'stiftung', r'ggmbh', r'gemeinnützige gmbh', r'gug', r'partg', r'partnerschaftsgesellschaft', r'partgmbb', r'og', r'o\.g\.', r'offene gesellschaft', r'e\.u\.', r'eingetragenes unternehmen', r'ges\.?n\.?b\.?r\.?', r'gesellschaft nach bürgerlichem recht', r'kollektivgesellschaft', r'einzelfirma', r'co\.', r'und co', r' & co', r'gruppe', r'group', 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' ] - forms_sorted = sorted(forms, key=len, reverse=True) - pattern = r'\b(' + '|'.join(forms_sorted) + r')\b' + 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\.?', r'limited', r'ltd\s*&\s*co\.?\s*kg', r's\.?a\.?r\.?l\.?', r'sàrl', r'sagl', r's\.?a\.?', r'société anonyme', r'sociedad anónima', r's\.?p\.?a\.?', r'società per azioni', r'b\.?v\.?', r'besloten vennootschap', r'n\.?v\.?', r'naamloze vennootschap', r'plc\.?', r'public limited company', r'inc\.?', r'incorporated', r'corp\.?', r'corporation', r'llc\.?', r'limited liability company', r'kgaa', r'kommanditgesellschaft auf aktien', r'se', r'societas europaea', r'e\.?g\.?', r'eingetragene genossenschaft', r'genossenschaft', r'genmbh', r'e\.?v\.?', r'eingetragener verein', r'verein', r'stiftung', r'ggmbh', r'gemeinnützige gmbh', r'gug', r'partg', r'partnerschaftsgesellschaft', r'partgmbb', r'og', r'o\.g\.', r'offene gesellschaft', r'e\.u\.', r'eingetragenes unternehmen', r'ges\.?n\.?b\.?r\.?', r'gesellschaft nach bürgerlichem recht', r'kollektivgesellschaft', r'einzelfirma', 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' ] + pattern = r'\b(' + '|'.join(forms) + r')\b' normalized = re.sub(pattern, '', name, flags=re.IGNORECASE) normalized = re.sub(r'[.,;:]', '', normalized) normalized = re.sub(r'[\-–/]', ' ', normalized) normalized = re.sub(r'\s+', ' ', normalized).strip() return normalized.lower() -# extract_numeric_value Funktion (für Wikipedia Infoboxen) -def extract_numeric_value(raw_value, is_umsatz=False): - """Extrahiert und normalisiert Zahlenwerte (Umsatz in Mio, Mitarbeiter). - Berücksichtigt jetzt auch Apostroph (') als Tausendertrenner und Einheiten.""" - if not raw_value: return "k.A." - raw_value_str = str(raw_value) - if not raw_value_str.strip() or raw_value_str.strip().lower() in ['k.a.', 'n/a', '-']: - return "k.A." - processed_value = clean_text(raw_value_str) - if processed_value == "k.A.": return "k.A." - logging.debug(f"extract_numeric_value: Verarbeite Wert: '{raw_value_str}' -> Bereinigt: '{processed_value}' (is_umsatz={is_umsatz})") - 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: - logging.debug(f"extract_numeric_value: Keine numerischen Zeichen gefunden nach Bereinigung/Vorverarbeitung: '{processed_value_final}' (aus '{raw_value_str}')") - return "k.A." - num_str = match.group(1) - try: - if not num_str or num_str == '.': raise ValueError("Leerer oder ungültiger Zahlenstring") - num = float(num_str) - except ValueError as e: - logging.error(f"extract_numeric_value: Fehler bei Float-Umwandlung des extrahierten Strings '{num_str}' (aus '{processed_value}'): {e}") - return "k.A." - original_lower = raw_value_str.lower() - multiplier = 1.0 - unit_found_log = "" - if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower): multiplier = 1000000000.0; unit_found_log = "Mrd" - elif re.search(r'\bmio\s*\b|\bmillionen\s*\b|\bmill\.\s*\b', original_lower): multiplier = 1000000.0; unit_found_log = "Mio" - elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): multiplier = 1000.0; unit_found_log = "Tsd" - num = num * multiplier - if unit_found_log: logging.debug(f" -> Multiplikator '{unit_found_log}' ({multiplier}) basierend auf Originalstring angewendet, Ergebnis: {num:.2f}") - else: logging.debug(f" -> Kein Multiplikator angewendet, Ergebnis: {num:.2f}") - if is_umsatz: - umsatz_mio = round(num / 1000000.0) - logging.info(f" -> Finaler Umsatz (Mio): {umsatz_mio}") - return str(int(umsatz_mio)) if umsatz_mio >= 0 else "k.A." - else: - mitarbeiter_int = round(num) - logging.info(f" -> Finale Mitarbeiterzahl: {mitarbeiter_int}") - return str(int(mitarbeiter_int)) if mitarbeiter_int >= 0 else "k.A." - -# get_numeric_filter_value Funktion (für Filterlogik) -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 zurück, wenn der Wert leer, k.A., nicht numerisch ist, oder 0 ergibt. - Beachtet Einheiten (Tsd, Mio, Mrd) für Umsatz und konvertiert zu Millionen € wenn is_umsatz=True. - Beachtet Tsd für Mitarbeiter und konvertiert zu Int wenn is_umsatz=False. - """ - if value_str is None or pd.isna(value_str) or str(value_str).strip() == '': return 0 - raw_value_str = str(value_str).strip() - # Füge '0' hinzu, um 0 als "leer" für die Filterlogik zu interpretieren, wie gewünscht - if raw_value_str.lower() in ['k.a.', 'n/a', '-', '0']: return 0 - try: - processed_value = clean_text(raw_value_str) - if processed_value == "k.A.": return 0 - 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 0 - num_str = match.group(1) - if not num_str or num_str == '.': return 0 - num = float(num_str) - original_lower = raw_value_str.lower() - if is_umsatz: - if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower): num = num * 1000.0 - elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): num = num / 1000.0 - return num if num > 0 else 0 - else: - if re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): num = num * 1000.0 - return round(num) if round(num) > 0 else 0 - except Exception as e: - logging.debug(f"Fehler in get_numeric_filter_value für Wert '{raw_value_str}': {e}") - return 0 - -# fuzzy_similarity Funktion def fuzzy_similarity(str1, str2): """Berechnet Ähnlichkeit zwischen 0 und 1 (case-insensitive).""" if not str1 or not str2: return 0.0 return SequenceMatcher(None, str(str1).lower(), str(str2).lower()).ratio() -# Token Count Funktion (optional) -@retry_on_failure # Kann fehlschlagen, wenn OpenAI API nicht erreichbar ist (obwohl nur ein lokales Lib) -def token_count(text): - """Zählt Tokens via tiktoken oder schätzt über Leerzeichen.""" - if not text or not isinstance(text, str): return 0 - if tiktoken: - try: - if not hasattr(token_count, 'enc_cache'): token_count.enc_cache = {} - current_model_for_token = getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo') # Nutze Config - if current_model_for_token not in token_count.enc_cache: - token_count.enc_cache[current_model_for_token] = tiktoken.encoding_for_model(current_model_for_token) - enc = token_count.enc_cache[current_model_for_token] - return len(enc.encode(text)) - except Exception as e: - # Fehler loggen, aber keinen Fehler werfen, Fallback nutzen - logging.warning(f"Fehler beim Token-Counting mit tiktoken für Modell '{current_model_for_token}': {e}") - # Fallback zur Schätzung - return len(text.split()) +# --- Numerische Extraktion --- +# Übernommen aus Ihrem Code (Teil 4), leicht angepasst für Logger. +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. + """ + if not raw_value: return "k.A." + raw_value_str = str(raw_value).strip() + if not raw_value_str or raw_value_str.lower() in ['k.a.', 'n/a', '-', '0']: + return "k.A." # 0 ist hier wie k.A. + + # Bereinigungsschritte wie in clean_text und vorheriger Implementierung + processed_value = clean_text(raw_value_str) + if processed_value == "k.A.": return "k.A." + + # 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|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() # Nimm nur den ersten Teil bei Spannen + + # Entferne Tausendertrenner (Punkt, Apostroph) und ersetze Komma durch Punkt für Dezimal + 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: + logger.debug(f"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") + num = float(num_str) + except ValueError as e: + 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 --- + 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 + # logger.debug(" -> Einheit: Mrd gefunden") + elif re.search(r'\bmio\s*\b|\bmillionen\s*\b|\bmill.\s*\b', original_lower): + 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") + + num = num * multiplier + + # Konvertiere zu Zielformat und runde ggf. + if is_umsatz: + # Umsatz wird in Millionen € gespeichert (gerundet auf ganze Mio) + umsatz_mio = round(num / 1000000.0) + return str(int(umsatz_mio)) if umsatz_mio > 0 else "k.A." # Nur positive Ergebnisse else: - # Fallback Schätzung - return len(text.split()) -# ==================== API HELPER FUNCTIONS (Global) ==================== -# Funktionen, die spezifische APIs aufrufen. + # Mitarbeiterzahl wird als ganze Zahl gespeichert (gerundet) + mitarbeiter_int = round(num) + return str(int(mitarbeiter_int)) if mitarbeiter_int > 0 else "k.A." # Nur positive Ergebnisse -# call_openai_chat Funktion -@retry_on_failure # Wichtig, dass dieser Decorator angewendet wird -def call_openai_chat(prompt, temperature=0.3, model=None): - """Zentrale Funktion für OpenAI Chat API Aufrufe.""" - if not Config.API_KEYS.get('openai'): - logging.error("Fehler: OpenAI API Key nicht konfiguriert.") - return None - if not prompt: - logging.error("Fehler: Leerer Prompt für OpenAI.") - return None - current_model = model if model else Config.TOKEN_MODEL +# --- Numerische Extraktion für FILTERLOGIK (gibt 0 statt k.A. zurück) --- +# Übernommen aus Ihrem Code (Teil 2), leicht angepasst für Logger. +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 zurück, wenn der Wert leer, k.A., nicht numerisch ist, oder 0 ergibt. + Beachtet Einheiten (Tsd, Mio, Mrd) für Umsatz. + """ + if value_str is None or pd.isna(value_str) or str(value_str).strip() == '': + return 0 # Leer oder k.A. -> 0 + + raw_value_str = str(value_str).strip() + if raw_value_str.lower() in ['k.a.', 'n/a', '-']: + return 0 try: - # Token zählen vor dem Senden (optional, gut für Debugging) - # try: prompt_tokens = token_count(prompt) - # except Exception as e_tc: prompt_tokens = -1; logging.debug(f"Token count error: {e_tc}") - # logging.debug(f"Sende Prompt an OpenAI ({current_model})... Tokens: {prompt_tokens}") + processed_value = clean_text(raw_value_str) + if processed_value == "k.A.": return 0 + 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(',', '.') - response = openai.ChatCompletion.create( - model=current_model, - messages=[{"role": "user", "content": prompt}], - temperature=temperature - ) - result = response.choices[0].message.content.strip() + match = re.search(r'([\d.]+)', processed_value_final) + if not match: + # logger.debug(f"get_numeric_filter_value: Keine numerischen Zeichen gefunden in '{processed_value_final}'") # Zu viel Lärm + return 0 - # Token zählen für die Antwort (optional) - # try: total_tokens = response.usage.total_tokens - # except: total_tokens = -1 - # logging.debug(f"OpenAI Antwort erhalten. Gesamt Tokens: {total_tokens}") + num_str = match.group(1) + if not num_str or num_str == '.': return 0 + + num = float(num_str) + + # --- Einheiten-Skalierung --- + original_lower = raw_value_str.lower() + # Diese Logik muss den Vergleich mit dem *Schwellenwert* in Mio/Integer berücksichtigen. + # Der Schwellenwert für Umsatz (min_umsatz) ist in Mio. + # Der Schwellenwert für MA (min_employees) ist eine ganze Zahl. + # Ziel: Den Wert aus dem Sheet in die Einheit des Schwellenwerts konvertieren. + + 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) + + 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): + 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 + return num if num > 0 else 0 - return result - except openai.error.InvalidRequestError as e: - # InvalidRequestError deutet oft auf Token Limit hin oder ungültigen Input - logging.error(f"OpenAI Invalid Request Error: {e}") - if "maximum context length" in str(e): - logging.error("FEHLER: Prompt war zu lang (Token Limit).") - return None # Bei diesem Fehler keinen Retry versuchen (hängt vom Prompt ab) - except openai.error.RateLimitError as e: - # RateLimitError wird vom retry_on_failure Decorator behandelt - logging.warning(f"OpenAI Rate Limit Error: {e}") - raise e # Fehler weitergeben für Retry - except openai.error.AuthenticationError as e: - logging.critical(f"OpenAI Authentication Error: {e}. Überprüfen Sie Ihren API-Schlüssel!") - return None # Kein Retry bei Auth Error - except openai.error.OpenAIError as e: # Fängt andere APIError etc. ab - logging.error(f"OpenAI API Fehler: {e}") - raise e # Fehler weitergeben für Retry except Exception as e: - logging.error(f"Allgemeiner Fehler bei OpenAI-Aufruf: {e}") - raise e # Fehler weitergeben + logger.debug(f"Fehler in get_numeric_filter_value für Wert '{raw_value_str[:50]}...': {e}") + return 0 -# summarize_website_content Funktion -# @retry_on_failure # Summarize Batch Funktion nutzt Retry -def summarize_website_content(raw_text): - """Erstellt Zusammenfassung von Website-Rohtext via OpenAI.""" - if not raw_text or raw_text == "k.A." or raw_text.strip() == "": - return "k.A." +# --- Gender und Email Helpers --- +# Übernommen aus Ihrem Code (Teil 4), leicht angepasst für Logger. +# Annahme: gender_guesser ist installiert +gender_detector = gender.Detector() # Instanz außerhalb der Funktion erstellen - # Kürze den Rohtext, falls er sehr lang ist - max_raw_length = 3000 # Zeichenlimit für den Input der Zusammenfassung - if len(raw_text) > max_raw_length: - logging.debug(f"Kürze Rohtext für Zusammenfassung von {len(raw_text)} auf {max_raw_length} Zeichen.") - raw_text = raw_text[:max_raw_length] - - prompt = ( - "Du bist ein KI-Assistent, der Webinhalte analysiert.\n" - "Fasse den folgenden Text einer Unternehmenswebsite prägnant zusammen. " - "Konzentriere dich auf:\n" - "- Haupttätigkeitsfeld 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):" - ) - # call_openai_chat hat bereits retry - summary = call_openai_chat(prompt, temperature=0.2) - return summary if summary else "k.A. (Summarization Failed)" - - -# evaluate_branche_chatgpt Funktion -# @retry_on_failure # Wird vom Batch Caller oder _process_single_row mit Retry gehandhabt? Nein, call_openai_chat hat Retry. -def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kategorien, website_summary): - """ - Ordnet das Unternehmen basierend auf Infos exakt einer Branche des Ziel-Schemas zu. - Validiert Vorschlag und nutzt Fallback auf CRM-Kurzform, falls ungültig. - """ - global ALLOWED_TARGET_BRANCHES, TARGET_SCHEMA_STRING - - if not ALLOWED_TARGET_BRANCHES: - logging.critical("FEHLER in evaluate_branche_chatgpt: Ziel-Branchenschema nicht geladen.") - return {"branch": crm_branche, "consistency": "error_schema_missing", "justification": "Fehler: Ziel-Schema nicht geladen"} - - allowed_branches_lookup = {b.lower(): b for b in ALLOWED_TARGET_BRANCHES} - - prompt_parts = [TARGET_SCHEMA_STRING] - prompt_parts.append("\nOrdne das Unternehmen anhand folgender Angaben exakt einer Branche des Ziel-Branchenschemas (Kurzformen) zu:") - - info_count = 0 - if crm_branche and crm_branche != "k.A.": prompt_parts.append(f"- CRM-Branche (Referenz): {crm_branche}"); info_count += 1 - if beschreibung and beschreibung != "k.A.": prompt_parts.append(f"- Beschreibung: {beschreibung[:500]}..."); info_count += 1 - if wiki_branche and wiki_branche != "k.A.": prompt_parts.append(f"- Wikipedia-Branche: {wiki_branche[:300]}"); info_count += 1 - if wiki_kategorien and wiki_kategorien != "k.A.": prompt_parts.append(f"- Wikipedia-Kategorien: {wiki_kategorien[:500]}..."); info_count += 1 - if website_summary and website_summary != "k.A.": prompt_parts.append(f"- Website-Zusammenfassung: {website_summary[:500]}..."); info_count += 1 - - if info_count < 2: - logging.warning("Warnung in evaluate_branche_chatgpt: Zu wenige Informationen (<2) für Branchenevaluierung.") - return {"branch": crm_branche, "consistency": "error_no_info", "justification": "Fehler: Zu wenige Informationen für eine Einschätzung"} - - prompt_parts.append("\nWICHTIG: Antworte NUR mit dem exakten Kurznamen einer Branche aus der obigen Liste. Verwende KEINE Präfixe wie 'Hersteller / Produzenten >' oder 'Service provider (Dienstleister) >'.") - prompt_parts.append("\nAntworte ausschließlich im folgenden Format (keine Einleitung, kein Schlusssatz):") - prompt_parts.append("Branche: ") - # Prompt entfernt Übereinstimmung, da wir das selbst berechnen - prompt_parts.append("Begründung: ") - - prompt = "\n".join(prompt_parts) - # logging.debug(f"Erstellter Prompt für Branchenevaluierung:\n---\n{prompt}\n---") # Zu ausführlich - - chat_response = call_openai_chat(prompt, temperature=0.0) # Niedrige Temperatur für konsistente Zuordnung - - if not chat_response: - logging.error("Fehler in evaluate_branche_chatgpt: Keine Antwort von OpenAI erhalten.") - return {"branch": crm_branche, "consistency": "error_api_no_response", "justification": "Fehler: Keine Antwort von API"} - - # logging.debug(f"OpenAI Antwort für Branchenevaluierung: {chat_response}") # Zu ausführlich - - lines = chat_response.strip().split("\n") - result = {"branch": None, "consistency": None, "justification": ""} - suggested_branch = "" - parsed_branch = False - for line in lines: - line_lower = line.lower() - if line_lower.startswith("branche:"): - suggested_branch = line.split(":", 1)[1].strip().strip('"\'') - parsed_branch = True - elif line_lower.startswith("begründung:"): - result["justification"] = line.split(":", 1)[1].strip() - - if not parsed_branch or not suggested_branch: - logging.error(f"Fehler in evaluate_branche_chatgpt: Konnte 'Branche:' nicht oder nur leer aus Antwort parsen: {chat_response}") - return {"branch": crm_branche, "consistency": "error_parsing", "justification": f"Fehler: Parsing der API Antwort fehlgeschlagen. Antwort: {chat_response[:200]}..."} - - final_branch = None - suggested_branch_lower = suggested_branch.lower() - - if suggested_branch_lower in allowed_branches_lookup: - final_branch = allowed_branches_lookup[suggested_branch_lower] - # logging.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist gültig ('{final_branch}').") # Zu ausführlich - result["consistency"] = "pending_comparison" # Temporärer Status - else: - # logging.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist NICHT im Ziel-Schema. Starte Fallback...") # Zu ausführlich - - crm_short_branch = "k.A." - if crm_branche and ">" in crm_branche: crm_short_branch = crm_branche.split(">", 1)[1].strip() - elif crm_branche and crm_branche != "k.A.": crm_short_branch = crm_branche.strip() - - # logging.debug(f" Fallback: Prüfe extrahierte CRM-Kurzform: '{crm_short_branch}'") # Zu ausführlich - crm_short_branch_lower = crm_short_branch.lower() - - if crm_short_branch != "k.A." and crm_short_branch_lower in allowed_branches_lookup: - final_branch = allowed_branches_lookup[crm_short_branch_lower] - result["consistency"] = "fallback_crm_valid" - fallback_reason = f"Fallback: Ungültiger GPT-Vorschlag ('{suggested_branch}'). Gültige CRM-Kurzform '{final_branch}' verwendet." - result["justification"] = f"{fallback_reason} (GPT Begründung war: {result.get('justification', 'Keine')})" - logging.info(f"Fallback auf gültige CRM-Kurzform erfolgreich: '{final_branch}'") - else: - final_branch = suggested_branch # Behalte ungültigen Vorschlag - result["consistency"] = "fallback_invalid" - error_reason = f"Fehler: Ungültiger GPT-Vorschlag ('{suggested_branch}') und keine gültige CRM-Kurzform ('{crm_short_branch}') als Fallback verfügbar." - result["justification"] = f"{error_reason} (GPT Begründung war: {result.get('justification', 'Keine')})" - logging.warning(f"Fallback fehlgeschlagen. Ungültiger Vorschlag: '{final_branch}', Ungültige CRM-Kurzform: '{crm_short_branch}'") - final_branch = "FEHLER - UNGÜLTIGE ZUWEISUNG" # Setze finalen Branch auf Fehler - - result["branch"] = final_branch - - # --- Konsistenzprüfung (Finale Bewertung) --- - crm_short_to_compare = "k.A." - if crm_branche and ">" in crm_branche: crm_short_to_compare = crm_branche.split(">", 1)[1].strip() - elif crm_branche and crm_branche != "k.A.": crm_short_to_compare = crm_branche.strip() - - if result["branch"] != "FEHLER - UNGÜLTIGE ZUWEISUNG": - if result["branch"].lower() == crm_short_to_compare.lower(): - if result["consistency"] == "pending_comparison": result["consistency"] = "ok" - elif result["consistency"] == "pending_comparison": result["consistency"] = "X" - - if result["consistency"] == "pending_comparison": - logging.warning("Konsistenzprüfung blieb im Status 'pending_comparison', setze auf 'error_comparison_failed'.") - result["consistency"] = "error_comparison_failed" - elif result["consistency"] is None: - logging.error("Konsistenz blieb unerwartet None, setze auf 'error_unknown_state'.") - result["consistency"] = "error_unknown_state" - - # logging.debug(f"Finale Branch-Evaluation: {result}") # Zu ausführlich - - return result - - -# is_valid_wikipedia_article_url Funktion -@retry_on_failure # Apply retry to the specific API call -def is_valid_wikipedia_article_url(wiki_url): - """ - Prüft über die MediaWiki API, ob eine gegebene Wikipedia-URL - auf einen existierenden Artikel verweist (und keine Weiterleitung/Begriffsklärung ist). - """ - if not wiki_url or not isinstance(wiki_url, str) or not wiki_url.lower().startswith(("http://", "https://")) or "wikipedia.org/wiki/" not in wiki_url.lower(): - logging.debug(f"is_valid_wikipedia_article_url: Ungültiges Format oder keine Wikipedia-URL: '{wiki_url}'") - return False - title = "URL_PARSE_ERROR" - try: - title_part = wiki_url.split('/wiki/', 1)[1] - title = unquote(title_part).replace('_', ' ') - api_url = f"https://{getattr(Config, 'LANG', 'de')}.wikipedia.org/w/api.php" - params = { "action": "query", "titles": title, "format": "json", "formatversion": 2, "prop": "info|pageprops", "redirects": 1 } - logging.debug(f"is_valid_wikipedia_article_url: Prüfe Titel '{title}' via MediaWiki API an {api_url}...") - response = requests.get(api_url, params=params, timeout=10, headers={'User-Agent': getattr(Config, 'USER_AGENT', 'Mozilla/5.0')}) - response.raise_for_status() - data = response.json() - # logging.debug(f" -> API Antwort für '{title}': {str(data)[:200]}...") # Too verbose - if 'query' in data and 'pages' in data['query']: - pages = data['query']['pages'] - if pages: - page_info = pages[0] - if page_info.get('missing', False): logging.debug(f" API Check für '{title}': Seite fehlt."); return False - if page_info.get('invalid', False): logging.debug(f" API Check für '{title}': Titel ungültig."); return False - if 'pageprops' in page_info and 'disambiguation' in page_info['pageprops']: logging.debug(f" API Check für '{title}': Seite ist eine Begriffsklärung."); return False - logging.info(f" API Check für '{title}': Scheint ein valider Artikel zu sein.") - return True - else: logging.warning(f" API Check für '{title}': Leere 'pages'-Liste."); return False - else: logging.warning(f" API Check für '{title}': Unerwartetes API-Antwortformat."); return False - except requests.exceptions.RequestException as e: logging.error(f" API Check für '{title}': Netzwerkfehler - {e}"); raise e - except Exception as e: logging.error(f" API Check für '{title}': Allgemeiner Fehler - {e}"); raise e - -# serp_website_lookup Funktion -@retry_on_failure -def serp_website_lookup(company_name): - """Ermittelt Website via SERP API (Google Suche).""" - serp_key = Config.API_KEYS.get('serpapi') - if not serp_key: logging.error("SerpAPI Key nicht verfügbar für Website Lookup."); return "k.A." - if not company_name: logging.warning("serp_website_lookup: Kein Firmenname."); return "k.A." - blacklist = ["bloomberg.com", "northdata.de", "finanzen.net", "handelsblatt.com", "wikipedia.org", "linkedin.com", "xing.com", "kununu.com", "firmenwissen.de", "gelbeseiten.de", "cylex.de", "companyme.com"] - query = f'{company_name} offizielle Website' - params = { "engine": "google", "q": query, "api_key": serp_key, "hl": "de", "gl": "de", "num": 10 } # Top 10 Ergebnisse prüfen - api_url = "https://serpapi.com/search" - try: - response = requests.get(api_url, params=params, timeout=15) - response.raise_for_status() - data = response.json() - if "knowledge_graph" in data and "website" in data["knowledge_graph"]: - kg_url = data["knowledge_graph"]["website"] - if kg_url and not any(bad_domain in kg_url.lower() for bad_domain in blacklist): - normalized_url = simple_normalize_url(kg_url) - if normalized_url != "k.A.": - logging.info(f"SERP Lookup: Website '{normalized_url}' aus Knowledge Graph für '{company_name}' gefunden.") - return normalized_url - if "organic_results" in data: - for result in data["organic_results"]: - url = result.get("link", "") - if url and not any(bad_domain in url.lower() for bad_domain in blacklist) and url.lower().startswith("http"): - normalized_url = simple_normalize_url(url) - if normalized_url != "k.A.": - # Zusätzliche Plausibilitätsprüfung: Enthält die Domain Teile des Firmennamens? - domain_part = normalized_url.replace('www.', '').split('.')[0] - # Sehr einfache Prüfung: Erstes Wort der Domain vs Erstes Wort des Firmennamens - norm_company_first_word = normalize_company_name(company_name).split()[0] if normalize_company_name(company_name) else "" - if norm_company_first_word and norm_company_first_word in domain_part: - logging.info(f"SERP Lookup: Website '{normalized_url}' aus Organic Results für '{company_name}' gefunden (Domain-Match).") - return normalized_url - # Zweite Chance: Wenn kein Namensmatch, aber es der erste organische Treffer ist ODER sehr hohe Titelähnlichkeit - elif result.get("position", 999) == 1 or fuzzy_similarity(result.get("title", ""), company_name) > 0.8: # Hohe Titelähnlichkeit als Signal - logging.info(f"SERP Lookup: Website '{normalized_url}' aus Organic Results für '{company_name}' gefunden (Top Result / Titelähnlichkeit).") - return normalized_url - else: - logging.debug(f"SERP Lookup: URL '{normalized_url}' übersprungen (Domain '{domain_part}' passt nicht gut zu '{company_name}', kein Top Result).") - - logging.info(f"SERP Lookup: Keine passende Website für '{company_name}' gefunden.") - return "k.A." - except requests.exceptions.RequestException as e: logging.error(f"Fehler bei SERP API Website Lookup für '{company_name}': {e}"); raise e - except Exception as e: logging.error(f"Allgemeiner Fehler bei SERP API Website Lookup für '{company_name}': {e}"); return "k.A." - -# serp_wikipedia_lookup Funktion -@retry_on_failure -def serp_wikipedia_lookup(company_name, website=None, min_score=0.4): - """ - Sucht über SerpAPI (Google) nach dem wahrscheinlichsten Wikipedia-Artikel. - Verwendet flexible Query, sammelt Top-10-Kandidaten, bewertet nach Titelähnlichkeit - und Keywords, bevorzugt deutsche/englische Artikel. - - Args: - company_name (str): Der Name des Unternehmens. - website (str, optional): Die Website des Unternehmens. Defaults to None. - min_score (float, optional): Mindest-Score (Kombination aus Ähnlichkeit - und Boni) für einen gültigen Treffer. Defaults to 0.4. - - Returns: - str: Die URL des relevantesten Wikipedia-Artikels oder None. - """ - serp_key = Config.API_KEYS.get('serpapi') - if not serp_key: logging.error("SerpAPI Key nicht verfügbar für Wikipedia Lookup."); return None - if not company_name: logging.warning("serp_wikipedia_lookup: Kein Firmenname."); return None - query = f'{company_name} Wikipedia' - logging.info(f"Starte SerpAPI Wikipedia-Suche für '{company_name}' mit Query: '{query}'") - params = { "engine": "google", "q": query, "api_key": serp_key, "hl": getattr(Config, 'LANG', 'de'), "gl": getattr(Config, 'LANG', 'de'), "num": 10 } - api_url = "https://serpapi.com/search" - try: - response = requests.get(api_url, params=params, timeout=15) - response.raise_for_status() - data = response.json() - candidates = [] - if "organic_results" in data: - logging.debug(f" -> Prüfe {len(data['organic_results'])} organische Ergebnisse...") - for result in data["organic_results"]: - link = result.get("link") - # Filtere gültige Wiki-Artikel-Links (de oder en oder konfigurierte Sprache) - if link and "wikipedia.org/wiki/" in link.lower() \ - and (link.startswith(f"https://{getattr(Config, 'LANG', 'de')}.wikipedia.org") or link.startswith("https://en.wikipedia.org")) \ - and not any(x in link for x in ['Datei:', 'Spezial:', 'Portal:', 'Hilfe:', 'Diskussion:']): - try: - title = unquote(link.split('/wiki/', 1)[1].split('#')[0]).replace('_', ' ') - candidates.append({'url': link, 'title': title}) - logging.debug(f" -> Kandidat gefunden: '{title}' ({link})") - except Exception as e_title_extract: logging.warning(f" -> Fehler beim Extrahieren des Titels aus Link {link}: {e_title_extract}"); continue - if not candidates: logging.warning(f" -> SerpAPI: Keine de/en Wikipedia-Kandidaten-URLs gefunden für '{company_name}'."); return None - - best_match_url = None; highest_score = -1.0 - normalized_search_name = normalize_company_name(company_name) - logging.debug(f" -> Bewerte {len(candidates)} Kandidaten...") - for cand in candidates: - url = cand['url']; title = cand['title'] - try: normalized_title = normalize_company_name(title); title_lower = title.lower(); - except Exception as e_norm: logging.warning(f"Fehler beim Normalisieren des Titels '{title}': {e_norm}"); continue - - similarity = SequenceMatcher(None, normalized_title, normalized_search_name).ratio() - score = similarity - logging.debug(f" -> Kandidat '{title}': Basis-Ähnlichkeit={similarity:.2f}") - - bonus = 0.0 - if "(unternehmen)" in title_lower: bonus += 0.2; logging.debug(" -> Bonus +0.2 für '(unternehmen)'") - elif any(form in title_lower for form in [' gmbh', ' ag', ' kg', ' ltd', ' inc', ' corp', ' s.a.', ' se', ' group']): bonus += 0.1; logging.debug(" -> Bonus +0.1 für Rechtsform/Gruppen-Keyword") - if url.startswith(f"https://{getattr(Config, 'LANG', 'de')}.wikipedia.org"): bonus += 0.05; logging.debug(f" -> Bonus +0.05 für {getattr(Config, 'LANG', 'de')}.wikipedia.org") - - total_score = score + bonus - logging.debug(f" -> Gesamtscore für '{title}': {total_score:.3f} (Ähnlichkeit={similarity:.2f}, Bonus={bonus:.2f})") - - if total_score > highest_score and total_score >= min_score: - highest_score = total_score - best_match_url = url - logging.debug(f" ====> Neuer bester Kandidat: {best_match_url} (Score: {highest_score:.3f}) ====") - - if best_match_url: logging.info(f" -> SerpAPI: Bester relevanter Wikipedia-Link ausgewählt: {best_match_url} (Score: {highest_score:.3f})"); return best_match_url - else: logging.warning(f" -> SerpAPI: Keiner der {len(candidates)} Kandidaten erreichte den Mindestscore ({min_score}) für '{company_name}'."); return None - - except requests.exceptions.RequestException as e: logging.error(f"Fehler bei der SerpAPI Wikipedia Suche für '{company_name}': {e}"); raise e - except Exception as e: logging.error(f"Allgemeiner Fehler bei der SerpAPI Wikipedia Suche für '{company_name}': {e}"); return None - -# search_linkedin_contacts Funktion -@retry_on_failure -def search_linkedin_contacts(company_name, website, position_query, crm_kurzform, num_results=10): - """Sucht LinkedIn Kontakte via SERP API.""" - serp_key = Config.API_KEYS.get('serpapi') - if not serp_key: logging.error("SerpAPI Key nicht verfügbar für LinkedIn Suche."); return [] - if not all([company_name, position_query, crm_kurzform]): return [] - - # Query anpassen für bessere Ergebnisse - query = f'site:linkedin.com/in "{position_query}" "{crm_kurzform}"' # Suche nach Kurzform im Titel - params = { "engine": "google", "q": query, "api_key": serp_key, "hl": getattr(Config, 'LANG', 'de'), "gl": getattr(Config, 'LANG', 'de'), "num": num_results } - api_url = "https://serpapi.com/search" - - try: - response = requests.get(api_url, params=params, timeout=15) - response.raise_for_status() - data = response.json() - contacts = [] - - if "organic_results" in data: - for result in data["organic_results"]: - title = result.get("title", "") - linkedin_url = result.get("link", "") - - if not linkedin_url or "linkedin.com/in/" not in linkedin_url: continue - if crm_kurzform.lower() not in title.lower(): - logging.debug(f"LinkedIn Treffer übersprungen: Kurzform '{crm_kurzform}' nicht in Titel '{title}'") - continue - - name_part = ""; pos_part = position_query - separators = ["–", "-", "|", " at ", " bei "] - title_cleaned = title.replace("...", "").strip() - - found_sep = False - for sep in separators: - if sep in title_cleaned: - parts = title_cleaned.split(sep, 1) - name_part = parts[0].strip().replace(" | LinkedIn", "").replace(" - LinkedIn", "").replace(" - Profil", "").strip() - potential_pos = parts[1].strip() - if crm_kurzform.lower() in potential_pos.lower(): potential_pos = potential_pos.replace(crm_kurzform, "", 1).strip() - potential_pos = potential_pos.split(" | LinkedIn")[0].split(" - LinkedIn")[0].strip() - pos_part = potential_pos if potential_pos else position_query - found_sep = True; break - - if not found_sep: - name_part = title_cleaned.split(" | LinkedIn")[0].split(" - LinkedIn")[0].strip() - if position_query.lower() in name_part.lower(): name_part = name_part.replace(position_query, "", 1).strip() - - firstname = ""; lastname = "" - name_parts = name_part.split() - if len(name_parts) > 1: firstname = name_parts[0]; lastname = " ".join(name_parts[1:]) - elif len(name_parts) == 1: firstname = name_parts[0] - - if not firstname: logging.debug(f"Kontakt übersprungen: Name konnte nicht extrahiert werden aus Titel '{title}'"); continue - - contact_data = { "Firmenname": company_name, "CRM Kurzform": crm_kurzform, "Website": website, "Vorname": firstname, "Nachname": lastname, "Position": pos_part, "LinkedInURL": linkedin_url } - contacts.append(contact_data) - logging.debug(f"Gefundener LinkedIn Kontakt: {firstname} {lastname} - {pos_part}") - - logging.info(f"LinkedIn Suche für '{position_query}' bei '{crm_kurzform}' ergab {len(contacts)} Kontakte.") - return contacts - - except requests.exceptions.RequestException as e: logging.error(f"Fehler bei der SERP API LinkedIn Suche: {e}"); raise e - except Exception as e: logging.error(f"Allgemeiner Fehler bei der SERP API LinkedIn Suche: {e}"); return [] - - -# get_gender Funktion def get_gender(firstname): - """Ermittelt Geschlecht via gender-guesser und Fallback Genderize API (mit Retry).""" + """Ermittelt Geschlecht via gender-guesser und Fallback Genderize API.""" if not firstname or not isinstance(firstname, str): return "unknown" firstname_clean = firstname.strip().split(" ")[0] if not firstname_clean: return "unknown" + + # 1. Versuch: gender-guesser (nutzt globale Instanz) try: - d = gender.Detector(case_sensitive=False) - result_gg = d.get_gender(firstname_clean) - if result_gg in ["andy", "unknown", "mostly_male", "mostly_female"]: result_gg = d.get_gender(firstname_clean, country='germany') - logging.debug(f"GenderGuesser für '{firstname_clean}': {result_gg}") - except Exception as e_gg: logging.warning(f"Fehler bei gender-guesser für '{firstname_clean}': {e_gg}"); result_gg = "unknown" - - @retry_on_failure - def call_genderize(name): - genderize_key = Config.API_KEYS.get('genderize') - if not genderize_key: logging.debug("Genderize API-Schlüssel nicht verfügbar."); return None - params = {"name": name, "apikey": genderize_key, "country_id": "DE"} - api_url = "https://api.genderize.io" - logging.debug(f"Genderize API-Anfrage für '{name}'...") - response = requests.get(api_url, params=params, timeout=5) - response.raise_for_status() - data = response.json() - logging.debug(f" -> Genderize Antwort: {data}") - return data + result_gg = gender_detector.get_gender(firstname_clean) + # logger.debug(f"GenderGuesser für '{firstname_clean}': {result_gg}") # Zu viel Lärm + except Exception as e_gg: + logger.warning(f"Fehler bei gender-guesser für '{firstname_clean}': {e_gg}") + result_gg = "unknown" + # 2. Fallback: Genderize API (nur wenn gender-guesser unsicher ist) if result_gg in ["andy", "unknown", "mostly_male", "mostly_female"]: - genderize_data = call_genderize(firstname_clean) - if genderize_data: - api_gender = genderize_data.get("gender"); probability = genderize_data.get("probability", 0) - if api_gender and probability and probability > 0.7: logging.debug(f" -> Übernehme Genderize Ergebnis '{api_gender}' (Prob: {probability})"); return api_gender - else: logging.debug(f" -> Genderize unsicher/kein Ergebnis. Nutze Fallback: '{result_gg}'"); return result_gg if result_gg.startswith("mostly_") else "unknown" - else: logging.debug(f" -> Genderize API Call fehlgeschlagen. Nutze Fallback: '{result_gg}'"); return result_gg if result_gg.startswith("mostly_") else "unknown" - else: return result_gg + genderize_key = Config.API_KEYS.get('genderize') + 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 + + params = {"name": firstname_clean, "apikey": genderize_key, "country_id": "DE"} + try: + # logger.debug(f"Genderize API-Anfrage für '{firstname_clean}'...") # Zu viel Lärm + response = requests.get("https://api.genderize.io", params=params, timeout=5) + response.raise_for_status() + data = response.json() + # logger.debug(f" -> Genderize Antwort: {data}") # Zu viel Lärm + + api_gender = data.get("gender") + probability = data.get("probability", 0) + if api_gender and probability is not None and probability > 0.7: + logger.debug(f" -> Übernehme Genderize Ergebnis '{api_gender}' (Prob: {probability}) für '{firstname_clean}'") + return api_gender + else: + # logger.debug(f" -> Genderize unsicher/kein Ergebnis. Nutze Fallback: '{result_gg}'") # Zu viel Lärm + return result_gg if result_gg.startswith("mostly_") else "unknown" + + except requests.exceptions.RequestException as e: + logger.error(f"Fehler bei der Genderize API-Anfrage für '{firstname_clean}': {e}") + return result_gg if result_gg.startswith("mostly_") else "unknown" + except Exception as e: + logger.error(f"Allgemeiner Fehler bei Genderize für '{firstname_clean}': {e}") + return result_gg if result_gg.startswith("mostly_") else "unknown" + else: + return result_gg -# get_email_address Funktion 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 "" + if not all([firstname, lastname, website]) or not all(isinstance(x, str) for x in [firstname, lastname, website]): + return "" + domain = simple_normalize_url(website) if domain == "k.A." or not '.' in domain: return "" + + # Normalisiere Vor- und Nachname, Kleinbuchstaben, nur erlaubte Zeichen normalized_first = normalize_string(firstname).lower() normalized_last = normalize_string(lastname).lower() - normalized_first = re.sub(r'[^\w]+', '-', normalized_first) - normalized_last = re.sub(r'[^\w]+', '-', normalized_last) - normalized_first = re.sub(r'-+', '-', normalized_first).strip('-') - normalized_last = re.sub(r'-+', '-', normalized_last).strip('-') - if normalized_first and normalized_last and domain: return f"{normalized_first}.{normalized_last}@{domain}" - else: return "" -# load_target_schema Funktion (für Branch Mapping) + # Ersetze Leerzeichen und mehrere Bindestriche durch einen einzelnen + normalized_first = re.sub(r'\s+', '-', normalized_first) + normalized_last = re.sub(r'\s+', '-', normalized_last) + + # Erlauben: alphanumerische Zeichen, Bindestrich, Punkt (nur intern, nicht am Anfang/Ende) + # Hier erlauben wir erstmal nur alphanumerische und Bindestrich nach Normalisierung + 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 + normalized_first = normalized_first.strip('-') + normalized_last = normalized_last.strip('-') + + + if normalized_first and normalized_last and domain: + return f"{normalized_first}.{normalized_last}@{domain}" + else: + return "" + +# --- 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 def load_target_schema(csv_filepath=BRANCH_MAPPING_FILE): - """Lädt Liste erlaubter Ziele (Kurzformen) aus Spalte A der CSV.""" + """Lädt Liste erlaubter Ziel-Branchen (Kurzformen) aus Spalte A der CSV.""" global BRANCH_MAPPING, TARGET_SCHEMA_STRING, ALLOWED_TARGET_BRANCHES - BRANCH_MAPPING = {} + # 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 + allowed_branches_set = set() - logging.info(f"Lade Ziel-Schema aus '{csv_filepath}'...") + # Verwenden Sie logger, da die Logging-Konfiguration in main() erfolgt + logger.info(f"Lade Ziel-Schema (Kurzformen) aus '{csv_filepath}' Spalte A...") + line_count = 0 try: - with open(csv_filepath, encoding="utf-8-sig") as f: + # Verwenden Sie 'utf-8-sig' für Dateien mit BOM + with open(csv_filepath, "r", encoding="utf-8-sig") as f: reader = csv.reader(f) + # Optional: Header überspringen, wenn die erste Zeile kein valider Branch ist + # In vielen CSVs ist die erste Zeile der Header. Wir können diese heuristisch überspringen. + # Oder man macht eine explizite Konfiguration. + # Einfachster Ansatz: Erste Zeile ist Header, ignoriere sie. + try: + header_row = next(reader) + # logger.debug(f"Überspringe Header-Zeile: {header_row}") # Zu viel Lärm + except StopIteration: + 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 + for row in reader: + line_count += 1 + # logger.debug(f"Schema-Laden: Lese Zeile {line_count}: {row}") # Zu viel Lärm + if len(row) >= 1: target = row[0].strip() - if target: allowed_branches_set.add(target) - except FileNotFoundError: logging.critical(f"Fehler: Schema-Datei '{csv_filepath}' nicht gefunden."); ALLOWED_TARGET_BRANCHES = [] - except Exception as e: logging.critical(f"Fehler beim Laden des Ziel-Schemas aus '{csv_filepath}': {e}"); ALLOWED_TARGET_BRANCHES = [] + if target: # Nur nicht-leere Einträge hinzufügen + allowed_branches_set.add(target) + # logger.debug(f" -> '{target}' zum Set hinzugefügt.") # Zu viel Lärm + + except FileNotFoundError: + 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 + except Exception as e: + 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 = sorted(list(allowed_branches_set), key=str.lower) - logging.info(f"Ziel-Schema geladen. {len(ALLOWED_TARGET_BRANCHES)} eindeutige Zielbranchen gefunden.") + logger.info(f"Ziel-Schema geladen. {len(ALLOWED_TARGET_BRANCHES)} eindeutige Zielbranchen gefunden.") + 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):"] schema_lines.extend(f"- {branch}" for branch in ALLOWED_TARGET_BRANCHES) - schema_lines.append("WICHTIG: Antworte NUR mit dem exakten Kurznamen einer Branche aus der obigen Liste. Verwende KEINE Präfixe.") + 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):") - schema_lines.append("Branche: ") - schema_lines.append("Begründung: ") + schema_lines.append("Branche: ") + schema_lines.append("Übereinstimmung: ") + schema_lines.append("Begründung: ") + TARGET_SCHEMA_STRING = "\n".join(schema_lines) - else: TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar." + # logger.debug(f"Generierter TARGET_SCHEMA_STRING:\n{TARGET_SCHEMA_STRING}") # Zu viel Lärm + 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.") -# map_external_branch Funktion (kann global bleiben oder in DataProcessor, wenn sie Sheet-Daten nutzt) -# Wenn sie nur String-Mapping macht, global lassen. Wenn sie Logik mit Sheet-Spalten verbindet, in DP. -# Basierend auf Beschreibung macht sie nur String-Mapping -> Global lassen. -def map_external_branch(external_branch): - """ - Versucht, eine externe Branchenbezeichnung mithilfe des Mappings in das Ziel-Schema zu überführen. - Nutzt Normalisierung und Teilstring-Matching als Fallback. - (Diese Funktion scheint im aktuellen Code nicht verwendet zu werden, da evaluate_branche_chatgpt das Mapping direkt gegen das Zielschema prüft) - """ - logging.warning("map_external_branch wurde aufgerufen, scheint aber unbenutzt zu sein.") - return external_branch # Unverändert zurückgeben, da Logik nicht implementiert/genutzt +# map_external_branch ist in dieser Struktur nicht mehr notwendig, +# da die Branchenevaluation über ChatGPT (evaluate_branche_chatgpt) +# direkt gegen ALLOWED_TARGET_BRANCHES validiert. +# Entferne die Funktion map_external_branch. -# alignment_demo Funktion (Schreibt direkt ins Sheet, braucht sheet Objekt) -def alignment_demo(sheet): - """Schreibt die Header-Struktur (Zeilen 1-5, jetzt bis Spalte AY) ins angegebene Sheet.""" - new_headers = [ - [ # Spaltenname (Zeile 1) - "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" - ], - [ # Quelle der Daten (Zeile 2) - "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" - ], - [ # Feldkategorie (Zeile 3) - "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" - ], - [ # Kurze Beschreibung (Zeile 4) - "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).", "Geschätzter Bucket (1-7) für Servicetechniker...", "Konsolidierter Umsatz (Mio €) nach Priorität Wiki > CRM...", "Konsolidierte Mitarbeiterzahl nach Priorität Wiki > CRM...", "Timestamp der letzten Wiki-Verifikation (Spalten S-Y).", "Timestamp der letzten SerpAPI-Suche nach fehlender Wiki-URL (Modus find_wiki_serp)." - ], - [ # Aufgabe / Funktion (Zeile 5) - "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." - ] - ] - num_cols = len(new_headers[0]) - def colnum_string(n): - string = "" - while n > 0: n, remainder = divmod(n - 1, 26); string = chr(65 + remainder) + string - return string - end_col_letter = colnum_string(num_cols) - header_range = f"A1:{end_col_letter}{len(new_headers)}" - try: - sheet.update(values=new_headers, range_name=header_range) - logging.info(f"Alignment-Demo abgeschlossen: Header in Bereich {header_range} geschrieben.") - except Exception as e: - logging.error(f"FEHLER beim Schreiben der Alignment-Demo Header: {e}") - -# --- Neue Kriterien Funktionen (Global) --- -# Diese Funktionen prüfen eine Zeile und geben True/False zurück -# Annahme: COLUMN_MAP ist global verfügbar und korrekt - -def criteria_m_filled_an_empty(row_data): - """Kriterium: Wiki URL (M) gefüllt (nicht leer/k.A.) UND Wiki Timestamp (AN) leer.""" - m_idx = COLUMN_MAP.get("Wiki URL") - an_idx = COLUMN_MAP.get("Wikipedia Timestamp") - - if m_idx is None or an_idx is None: - logging.error("Kriterium 'm_filled_an_empty': Benötigte Spalten nicht in COLUMN_MAP gefunden.") - return False - - # Sicherstellen, dass die Zeile lang genug ist, um auf die Spalten zuzugreifen - max_idx = max(m_idx, an_idx) if m_idx is not None and an_idx is not None else None - if max_idx is not None and len(row_data) <= max_idx: - # logging.debug(f"Kriterium 'm_filled_an_empty': Zeile zu kurz für Spalten.") # Zu laut - return False - elif max_idx is None: # Kann Indizes nicht finden - return False - - - m_value = row_data[m_idx] if m_idx is not None and len(row_data) > m_idx else "" - an_value = row_data[an_idx] if an_idx is not None and len(row_data) > an_idx else "" - - m_is_filled = bool(str(m_value).strip()) and str(m_value).strip().lower() not in ["k.a.", "kein artikel gefunden"] - an_is_empty = not bool(str(an_value).strip()) - - return m_is_filled and an_is_empty - -# Beispiel für weitere Kriterien-Funktionen (basierend auf Logik aus process_find_wiki_with_serp) -def criteria_size_meets_threshold(row_data, min_employees=500, min_umsatz=200): - """Kriterium: Unternehmensgröße erfüllt Schwelle (Umsatz CRM > min_umsatz MIO € ODER Mitarbeiter CRM > min_employees).""" - umsatz_idx = COLUMN_MAP.get("CRM Umsatz") - ma_idx = COLUMN_MAP.get("CRM Anzahl Mitarbeiter") - - if umsatz_idx is None or ma_idx is None: - logging.error("Kriterium 'size_meets_threshold': Benötigte Spalten (Umsatz/MA) nicht in COLUMN_MAP.") - return False - - # Sicherstellen, dass die Zeile lang genug ist - max_idx = max(umsatz_idx, ma_idx) - if len(row_data) <= max_idx: - # logging.debug(f"Kriterium 'size_meets_threshold': Zeile zu kurz.") # Zu laut - return False - - umsatz_val_str = row_data[umsatz_idx] - ma_val_str = row_data[ma_idx] - - # Nutze die globale get_numeric_filter_value Funktion - 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) - - meets_criteria = umsatz_val_mio > min_umsatz or ma_val_num > min_employees - - # Optional: Log, wenn Kriterium nicht erfüllt ist (kann laut sein) - # if not meets_criteria: - # logging.debug(f"Kriterium 'size_meets_threshold' nicht erfüllt. Umsatz (Mio): {umsatz_val_mio:.2f}, MA: {ma_val_num}. Schwellen: Umsatz > {min_umsatz} Mio, MA > {min_employees}.") - - return meets_criteria - -def criteria_ao_empty(row_data): - """Kriterium: Timestamp letzte Prüfung (AO) leer.""" - ao_idx = COLUMN_MAP.get("Timestamp letzte Prüfung") - if ao_idx is None: - logging.error("Kriterium 'ao_empty': Benötigte Spalte nicht in COLUMN_MAP.") - return False - if len(row_data) <= ao_idx: return False - ao_value = row_data[ao_idx] - return not bool(str(ao_value).strip()) - -def criteria_ar_empty(row_data): - """Kriterium: Website Rohtext (AR) leer (oder k.A. Varianten).""" - ar_idx = COLUMN_MAP.get("Website Rohtext") - if ar_idx is None: - logging.error("Kriterium 'ar_empty': Benötigte Spalte nicht in COLUMN_MAP.") - return False - if len(row_data) <= ar_idx: return False - ar_value = row_data[ar_idx] - empty_values_for_ar = ["", "k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"] - return str(ar_value).strip().lower() in empty_values_for_ar - -def criteria_ax_empty(row_data): - """Kriterium: Wiki Verif. Timestamp (AX) leer.""" - ax_idx = COLUMN_MAP.get("Wiki Verif. Timestamp") - if ax_idx is None: - logging.error("Kriterium 'ax_empty': Benötigte Spalte nicht in COLUMN_MAP.") - return False - if len(row_data) <= ax_idx: return False - ax_value = row_data[ax_idx] - return not bool(str(ax_value).strip()) - -# --- Ende Neue Kriterien Funktionen --- - - -# --- Temporäre Funktion für wiki_reextract Modus (bis Kriterien-Modus in DP implementiert ist) --- -# Diese Funktion wird nur von der main Funktion in diesem Übergangsszenario aufgerufen. -# Annahme: sheet_handler und data_processor sind initialisierte Instanzen. -# Annahme: _process_single_row ist eine Methode der DataProcessor Klasse und akzeptiert die Prozess-Flags. -# Annahme: criteria_m_filled_an_empty ist global definiert. -def process_wiki_reextract_missing_an(sheet_handler, data_processor, limit=None): - """ - Findet Zeilen, bei denen Wiki URL (M) gefüllt ist und Wiki Timestamp (AN) fehlt. - Führt für diese Zeilen eine forcierte Wiki-Extraktion (nur Wiki-Schritt) durch. - - Args: - sheet_handler (GoogleSheetHandler): Initialisierte Instanz. - data_processor (DataProcessor): Initialisierte Instanz. - limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None. - """ - logging.info(f"Starte Modus: Prozessiere Wiki Re-Extraction für Zeilen mit M gefüllt & AN leer. Limit: {limit if limit is not None else 'Unbegrenzt'}") - - # Daten neu laden - if not sheet_handler.load_data(): - logging.error("Fehler beim Laden der Daten.") - return - - all_data = sheet_handler.get_all_data_with_headers() - header_rows = 5 - if not all_data or len(all_data) <= header_rows: - logging.warning("Keine Daten zum Verarbeiten gefunden.") - return - - rows_to_process = [] - logging.info("Suche nach Zeilen, die dem Kriterium 'M gefüllt & AN leer' entsprechen...") - # Iteriere über alle Datenzeilen (ab Zeile 6) - 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 - - # Prüfen, ob die Zeile dem Kriterium entspricht - try: - if criteria_m_filled_an_empty(row_data): # Nutze die Kriterien-Funktion - rows_to_process.append({'row_num': row_num_in_sheet, 'data': row_data}) - except Exception as e_crit: - # Fehler im Kriterium selbst abfangen - logging.error(f"FEHLER beim Prüfen des Kriteriums für Zeile {row_num_in_sheet}: {e_crit}") - # Diese Zeile wird nicht für die Verarbeitung ausgewählt. - - - found_count = len(rows_to_process) - logging.info(f"{found_count} Zeilen entsprechen dem Kriterium 'M gefüllt & AN leer'.") - - if found_count == 0: - logging.info("Keine Zeilen gefunden, die dem Kriterium entsprechen.") - return - - processed_count = 0 - for task in rows_to_process: - if limit is not None and processed_count >= limit: - logging.info(f"Limit ({limit}) für Verarbeitung erreicht. Breche weitere Verarbeitung ab.") - break - - row_num = task['row_num'] - row_data = task['data'] - try: - # Rufe _process_single_row auf der data_processor Instanz auf - # Setze process_wiki=True, aber alle anderen auf False - # Setze force_reeval=True, um die AN Timestamp-Prüfung in _process_single_row zu überspringen - # Die _process_single_row Logik für force_reeval wird dann die URL in M nutzen/validieren. - data_processor._process_single_row( - row_num_in_sheet = row_num, - row_data = row_data, - process_wiki = True, # NUR Wiki-Verarbeitung - process_chatgpt = False, # Keine ChatGPT-Schritte - process_website = False, # Keine Website-Schritte - force_reeval = True # Timestamps IGNORIEREN - ) - processed_count += 1 - - except Exception as e_proc: - logging.exception(f"FEHLER bei Verarbeitung einer Kriterium-Zeile ({row_num}): {e_proc}") - # Fährt fort mit der nächsten Zeile - - logging.info(f"Prozess 'M gefüllt & AN leer' abgeschlossen. {processed_count} von {found_count} gefundenen Zeilen bearbeitet (Limit war: {limit}).") - -# --- Ende Temporäre Funktion --- -# ==================== GOOGLE SHEET HANDLER ==================== -# Annahmen: Globale Variablen/Konstanten: retry_on_failure, Config, CREDENTIALS_FILE, Config.SHEET_URL, COLUMN_MAP -# Annahme: logging ist konfiguriert +# ============================================================================== +# 4. GOOGLE SHEET HANDLER CLASS +# (Entspricht logisch etwa 'google_sheet_handler.py') +# ============================================================================== class GoogleSheetHandler: """ - Verwaltet die Interaktion mit dem Google Sheet (Authentifizierung, Lesen, Schreiben). + 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, verbindet und lädt initiale Daten.""" + """ + 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 = [] - self.headers = [] # Speichert die erste Zeile als Header-Namen + # 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: - self._connect() # Versucht Verbindung bei Initialisierung + # Verbindung wird bei der Initialisierung aufgebaut + self._connect() + # Daten werden ebenfalls bei der Initialisierung geladen if self.sheet: - # Erste Datenladung bei Initialisierung (kann optional sein, wenn load_data explizit aufgerufen wird) - # Es ist oft besser, load_data explizit in den Modi aufzurufen. - # Belassen wir es vorerst, wenn es das aktuelle Verhalten ist. - self.load_data() + 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: - # Kritischer Fehler, Skript sollte hier stoppen, wenn keine Verbindung - logging.critical(f"FATAL: Fehler bei Initialisierung von GoogleSheetHandler: {e}") - raise ConnectionError(f"Google Sheet Handler Init failed: {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 # Decorator wird hier angewendet + @retry_on_failure def _connect(self): """Stellt Verbindung zum Google Sheet her.""" - self.sheet = None # Sicherstellen, dass sheet vor try None ist - logging.info("Verbinde mit Google Sheets...") + self.sheet = None # Setze sheet vor dem Versuch auf None + logger.info("Versuche Verbindung mit Google Sheets herstellen...") try: - # Annahme: CREDENTIALS_FILE existiert und ist korrekt - scope = ["https://www.googleapis.com/auth/spreadsheets"] - creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope) - gc = gspread.authorize(creds) - # Annahme: Config.SHEET_URL existiert und ist korrekt - sh = gc.open_by_url(Config.SHEET_URL) - self.sheet = sh.sheet1 # Greift auf das erste Blatt zu (Annahme) - logging.info("Verbindung zu Google Sheets erfolgreich.") - except gspread.exceptions.APIError as e: - logging.error(f"FEHLER bei Google API Verbindung: Status {e.response.status_code} - {e.response.text[:200]}") - raise e # Fehler weitergeben, damit retry greift - except FileNotFoundError: - logging.critical(f"FEHLER: Service Account Credentials File '{CREDENTIALS_FILE}' nicht gefunden.") - raise FileNotFoundError(f"Service Account Credentials File '{CREDENTIALS_FILE}' not found.") + # 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: - logging.error(f"FEHLER bei der Google Sheets Verbindung: {type(e).__name__} - {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 # Decorator wird hier angewendet + @retry_on_failure def load_data(self): - """Lädt alle Daten aus dem Sheet und aktualisiert self.sheet_values und self.headers.""" + """Lädt alle Daten aus dem Sheet und aktualisiert self.sheet_values.""" if not self.sheet: - logging.error("Fehler: Keine Sheet-Verbindung zum Laden der Daten.") - self.sheet_values = [] - self.headers = [] + logger.error("Fehler: Keine Sheet-Verbindung zum Laden der Daten.") + self.sheet_values = [] # Stelle sicher, dass die Datenliste leer ist return False # Signalisiert Fehler - logging.info("Lade Daten aus Google Sheet...") + logger.info("Lade Daten aus Google Sheet...") try: - # Annahme: 'Tabelle1' ist der korrekte Blattname, oder dynamisch ermitteln? - # Ihre aktuelle get_all_values() nutzt default sheet1 - self.sheet_values = self.sheet.get_all_values() # Daten neu holen + # Nutze get_all_values() für alle Daten + self.sheet_values = self.sheet.get_all_values() if not self.sheet_values: - logging.warning("Warnung: Google Sheet scheint leer zu sein oder keine Daten zurückgegeben.") + 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 # Kein Fehler beim Laden, aber keine Daten + 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}") - # Annahme: Erste Zeile sind Header - if len(self.sheet_values) >= 1: - self.headers = self.sheet_values[0] # Speichere die erste Zeile als Header - else: - self.headers = [] # Sollte nicht passieren, wenn sheet_values nicht leer war - logging.info(f"Daten neu geladen: {len(self.sheet_values)} Zeilen insgesamt.") return True # Signalisiert Erfolg - except gspread.exceptions.APIError as e: - logging.error(f"Google API Fehler beim Laden der Sheet Daten: Status {e.response.status_code} - {e.response.text[:200]}") - raise e # Damit retry greift + + # 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: - logging.error(f"Allgemeiner Fehler beim Laden der Google Sheet Daten: {e}") - raise e # Damit retry greift + 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 X Header-Zeilen). - Annahme: Es gibt 5 Header-Zeilen. + Gibt die aktuell im Handler gespeicherten Datenzeilen zurück + (ohne die ersten N Header-Zeilen). """ - header_rows = 5 - if not self.sheet_values or len(self.sheet_values) <= header_rows: - if self.sheet_values: - logging.debug(f"Warnung in get_data: Nur {len(self.sheet_values)} Zeilen vorhanden, weniger als {header_rows} Header-Zeilen erwartet.") + 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 [] - return self.sheet_values[header_rows:] + # 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: - logging.debug("Warnung in get_all_data_with_headers: Keine Daten im Handler gespeichert.") + logger.debug("get_all_data_with_headers: Keine Daten im Handler gespeichert.") return [] - return self.sheet_values + return self.sheet_values.copy() # Rückgabe als Kopie + def _get_col_letter(self, col_idx_1_based): - """ Konvertiert 1-basierten Spaltenindex in Buchstaben (A, B, ..., Z, AA, ...). """ + """ + 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 - if n < 1: return None # Ungültiger Index while n > 0: n, remainder = divmod(n - 1, 26) string = chr(65 + remainder) + string @@ -1231,7 +887,7 @@ class GoogleSheetHandler: def get_start_row_index(self, check_column_key, min_sheet_row=7): """ - Findet den 0-basierten Index der ersten Zeile IN DEN DATEN (ohne Header), + 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. @@ -1244,2492 +900,1674 @@ class GoogleSheetHandler: 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). """ - if not self.load_data(): return -1 # Fehlerindikator, load_data loggt intern - header_rows = 5 - all_data_with_headers = self.get_all_data_with_headers() - if not all_data_with_headers or len(all_data_with_headers) <= header_rows: - logging.warning(f"get_start_row_index: Nicht genügend Daten im Sheet (<= {header_rows} Zeilen).") - return 0 # Start bei Index 0, wenn keine echten Daten da sind + # 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: - logging.critical(f"FEHLER: Schlüssel '{check_column_key}' nicht in COLUMN_MAP gefunden!") - return -1 + 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) - # Konvertiere min_sheet_row in 0-basierten Index für die gesamte Liste (inkl. Header) - search_start_index_in_all_data = max(header_rows, min_sheet_row - 1) # Suche beginnt frühestens nach Headern + 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})...") - logging.info(f"get_start_row_index: Suche ab Sheet-Zeile {search_start_index_in_all_data + 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 gesamte Liste ab dem berechneten Startindex - for i in range(search_start_index_in_all_data, len(all_data_with_headers)): - row = all_data_with_headers[i] - current_sheet_row = i + 1 # 1-basierte Zeilennummer + # 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: cell_value bleibt "", is_exactly_empty bleibt True (korrekt) + else: + # Wenn die Zeile nicht lang genug ist, gilt die Zelle in der Spalte als leer + is_exactly_empty = True - # Detailliertes Logging nur für die ersten paar Zeilen und alle 1000 Zeilen - log_debug = (i < search_start_index_in_all_data + 5 or (i - search_start_index_in_all_data) % 1000 == 0 or is_exactly_empty) + # 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: - logging.debug(f" -> Prüfe Sheet-Zeile {current_sheet_row} (Index {i}): Wert in {actual_col_letter}='{cell_value}' (Typ: {type(cell_value)}). Ist exakt leer ('')? {is_exactly_empty}") + 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: - # Geben Sie den 0-basierten Index IN DER DATENLISTE (ohne Header) zurück - data_index = i - header_rows - logging.info(f"Erste Zeile ab {min_sheet_row} mit EXAKT LEEREM Wert in Spalte {actual_col_letter} gefunden: Sheet-Zeile {current_sheet_row} (Daten-Index {data_index})") - return data_index + 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 endet, wurden keine leeren Zeilen ab dem Startindex gefunden. - # Geben Sie den Index NACH der letzten Datenzeile zurück. - last_data_index = len(all_data_with_headers) - header_rows - logging.info(f"Alle Zeilen ab Sheet-Zeile {search_start_index_in_all_data + 1} haben einen nicht-leeren Wert in Spalte {actual_col_letter}.") - logging.info(f"Nächster 0-basierter Daten-Index wäre {last_data_index}.") - return last_data_index + # 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 # Decorator wird hier angewendet + @retry_on_failure def batch_update_cells(self, update_data): """ - Führt ein Batch-Update im Google Sheet durch. + Führt ein Batch-Update im Google Sheet durch. Beinhaltet robustere + Fehlerbehandlung. Args: - update_data (list): Eine Liste von Dictionaries, jedes mit 'range' und 'values'. + 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, False bei Fehler nach Retries. + bool: True bei Erfolg (nach allen Retries), False bei endgültigem Fehler. """ if not self.sheet: - logging.error("FEHLER: Keine Sheet-Verbindung für Batch-Update.") + logger.error("FEHLER: Keine Sheet-Verbindung für Batch-Update.") return False if not update_data: - # logging.debug("Keine Daten für Batch-Update vorhanden.") # Weniger Lärm - return True + # 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. - success = False try: - # Begrenze die Größe des Batch-Updates, falls die Liste sehr lang wird - # Die API hat ein Limit pro Batch-Update Call. Es ist besser, die Liste hier zu chunking, - # auch wenn die aufrufende Funktion schon sammelt. update_batch_row_limit könnte hier genutzt werden. - # Aktuell senden wir die gesamte Liste, was bei sehr großen Listen schief gehen kann. - # Fürs Erste behalten wir das bei. - logging.debug(f" -> Versuche sheet.batch_update mit {len(update_data)} Operationen...") + # 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') - success = True - # logging.debug(f" -> sheet.batch_update erfolgreich abgeschlossen.") # Aufrufer loggt Erfolg - except gspread.exceptions.APIError as e: - logging.error(f" -> FEHLER (Google API Error) beim Batch-Update: Status {e.response.status_code}") - try: error_details = e.response.json(); logging.error(f" -> Details: {str(error_details)[:500]}...") - except: logging.error(f" -> Raw Response Text: {e.response.text[:500]}...") - raise e # Fehler weitergeben für Retry + # 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: - logging.error(f" -> FEHLER (Allgemein) beim Batch-Update: {type(e).__name__} - {e}") - logging.exception("Traceback beim Batch-Update:") - raise e # Fehler weitergeben + # 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 - return success - -# --- Ende GoogleSheetHandler Klasse --- - - -# ==================== WIKIPEDIA SCRAPER ==================== -# Annahmen: Globale Helfer Funktionen wie clean_text, normalize_company_name, extract_numeric_value, simple_normalize_url, fuzzy_similarity, is_valid_wikipedia_article_url sind global definiert. -# Annahme: Config, logging sind verfügbar. +# ============================================================================== +# 5. WIKIPEDIA SCRAPER CLASS +# (Entspricht logisch etwa 'wikipedia_scraper.py') +# ============================================================================== class WikipediaScraper: """ - Handles searching Wikipedia articles and extracting relevant company data. + 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. + 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. """ - self.logger = logging.getLogger(__name__ + ".WikipediaScraper") # Spezifischer Logger - self.user_agent = user_agent or getattr(Config, 'USER_AGENT', 'Mozilla/5.0 (compatible; Datenanreicherungsskript/1.0)') + # 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: - # Annahme: wikipedia Bibliothek ist importiert wiki_lang = getattr(Config, 'LANG', 'de') wikipedia.set_lang(wiki_lang) - wikipedia.set_rate_limiting(True) - self.logger.info(f"Wikipedia library language set to '{wiki_lang}'. Rate limiting enabled.") - except Exception as e: self.logger.warning(f"Fehler beim Setzen der Wikipedia-Sprache oder Rate Limiting: {e}") + # 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}") - # _get_full_domain (kann global bleiben oder private helper) - Belassen wir global, wird auch von anderen Helfern genutzt. - # _generate_search_terms (kann global bleiben oder private helper) - Belassen wir global. + # --- Interne Helfermethoden --- - @retry_on_failure + 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.startswith("http"): self.logger.warning(f"_get_page_soup: Ungültige URL '{url}'."); return None + 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}") - response = self.session.get(url, timeout=20) - response.raise_for_status() - response.encoding = response.apparent_encoding # Bessere Erkennung + # 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 - except requests.exceptions.RequestException as e: self.logger.error(f"_get_page_soup: Netzwerkfehler beim Abrufen von HTML von {url}: {e}"); raise e - except Exception as e: self.logger.error(f"_get_page_soup: Fehler beim Parsen von HTML von {url}: {e}"); raise e + 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.""" - if not page or not company_name: return False - self.logger.debug(f"Validiere Artikel '{page.title}' für Firma '{company_name}' (Website: {website})...") - full_domain = simple_normalize_url(website) # Globaler Helper - normalized_company = normalize_company_name(company_name) # Globaler Helper - normalized_title = normalize_company_name(page.title) # Globaler Helper - if not normalized_company or not normalized_title: self.logger.warning("Validierung nicht möglich, da Normalisierung fehlschlug."); return False + """ + 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: {similarity:.2f} ('{normalized_title}' vs '{normalized_company}')") - company_tokens = normalized_company.split(); title_tokens = normalized_title.split() - first_word_match = False; first_two_words_match = False + 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 - if len(company_tokens) > 1 and len(title_tokens) > 1 and company_tokens[1] == title_tokens[1]: first_two_words_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 - if full_domain: - self.logger.debug(f" -> Suche nach Domain '{full_domain}' in Links von {page.url}...") - soup = self._get_page_soup(page.url) # Ruft eigene Methode auf (mit Retry) - if soup: - # Vereinfachte Link-Prüfung: Suche in Infobox oder externen Links - infobox = soup.select_one('table[class*="infobox"]') - if infobox: # Prüfe Infobox Links mit Keywords - website_links = infobox.find_all('a', href=True) - for link in website_links: - href = link.get('href', '') - if href.startswith('http') and full_domain in simple_normalize_url(href): - link_text = link.get_text(strip=True).lower() - th = link.find_previous(['th', 'td']) # Prüfe vorheriges TH oder TD - th_text = th.get_text(strip=True).lower() if th else "" - if any(kw in link_text for kw in ['website', 'webseite', 'offizielle']) or any(kw in th_text for kw in ['website', 'webseite', 'webauftritt']): - logging.debug(f" -> Domain '{full_domain}' in Infobox-Link gefunden (mit Keyword).") - domain_found = True; break - if not domain_found: # Wenn in Infobox, aber ohne Keyword - for link in website_links: - href = link.get('href', '') - if href.startswith('http') and full_domain in simple_normalize_url(href): - logging.debug(f" -> Domain '{full_domain}' in Infobox-Link gefunden (ohne Keyword).") - domain_found = True; break + 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 not domain_found: # Suche in allen externen Links, wenn nicht in Infobox gefunden - self.logger.debug(" -> Domain nicht in Infobox-Links gefunden, suche in externen Links...") - all_external_links = soup.find_all('a', href=True, class_=re.compile(r'.*\bexternal\b.*')) - if all_external_links: # Bevorzuge external class - for link in all_external_links: - href = link.get('href', '') - if href.startswith('http') and full_domain in simple_normalize_url(href): - if not any(site in href for site in ['wikipedia.org', 'wikimedia.org', 'wikidata.org', 'archive.org', 'webcitation.org']): - logging.debug(f" -> Domain '{full_domain}' in externem Link gefunden.") - domain_found = True; break - if not domain_found: # Suche in allen Links als Fallback - all_links = soup.find_all('a', href=True) - for link in all_links: - href = link.get('href', '') - if href.startswith('http') and full_domain in simple_normalize_url(href): - if not any(site in href for site in ['wikipedia.org', 'wikimedia.org', 'wikidata.org', 'archive.org', 'webcitation.org']): - logging.debug(f" -> Domain '{full_domain}' in irgendeinem Link gefunden (Fallback).") - domain_found = True; break + 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.") - else: self.logger.warning(f" -> Konnte HTML für Link-Prüfung von {page.url} nicht laden.") + # 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" - # Dynamische Schwellenwert-Entscheidung - standard_threshold = getattr(Config, 'SIMILARITY_THRESHOLD', 0.65) - is_valid = False; reason = "Keine Validierungsregel traf zu" - if similarity >= standard_threshold: is_valid = True; reason = f"Gesamt-Ähnlichkeit >= {standard_threshold:.2f}" - elif domain_found and first_two_words_match and similarity >= 0.30: is_valid = True; reason = f"Domain gefunden UND erste 2 Worte stimmen überein UND Ähnlichkeit >= 0.30" - elif domain_found and first_word_match and similarity >= 0.35: is_valid = True; reason = f"Domain gefunden UND erstes Wort stimmt überein UND Ähnlichkeit >= 0.35" - elif first_two_words_match and similarity >= 0.40: is_valid = True; reason = f"Erste zwei Worte stimmen überein UND Ähnlichkeit >= 0.40" - elif domain_found and similarity >= 0.45: is_valid = True; reason = f"Domain gefunden UND Ähnlichkeit >= 0.45" - elif first_word_match and similarity >= 0.50: is_valid = True; reason = f"Erstes Wort stimmt überein UND Ähnlichkeit >= 0.50" 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 - # extract_categories Funktion - def extract_categories(self, soup): - """Extrahiert Wikipedia-Kategorien aus dem Soup-Objekt.""" - if not soup: return "k.A." - cats_filtered = [] - try: - cat_div = soup.find('div', id="mw-normal-catlinks") - if cat_div: - ul = cat_div.find('ul') - if ul: - cats = [clean_text(li.get_text()) for li in ul.find_all('li')] - 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' 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." + # --- Extraktionsmethoden --- - # _extract_first_paragraph_from_soup Funktion 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 - # Finde erste p-Tags, die keine Block-Elemente als direkte Kinder haben - paragraphs = [p for p in search_area.find_all('p', recursive=False) if not p.find(['img', 'table', 'figure', 'div'], recursive=False)] - if not paragraphs: # Fallback: alle p-Tags suchen - paragraphs = [p for p in search_area.find_all('p', recursive=True) if not p.find(['img', 'table', 'figure', 'div'], recursive=False)] + 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 - self.logger.debug(f"Suche ersten Absatz in {len(paragraphs)} gefundenen

-Tags...") - for idx, p in enumerate(paragraphs): - # Entferne störende Elemente wie Referenzen oder Koordinaten + # 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', id='coordinates'): span.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 - text = clean_text(p.get_text(separator=' ', strip=True)) # Globaler Helper + # Extrahiere und bereinige den Text (nutzt globale Funktion clean_text) + text = clean_text(p.get_text(separator=' ', strip=True)) - # Prüfe, ob der Text nach Bereinigung lang und aussagekräftig ist - if text and len(text) > 40: # Min. 40 Zeichen - self.logger.debug(f" -> Ersten gültigen Absatz (Index {idx}) gefunden: {text[:100]}...") - paragraph_text = text[:1000] # Begrenze Länge - break # Stoppe nach dem ersten passenden Absatz - else: - self.logger.debug(f" -> Überspringe

Tag (Index {idx}), Text zu kurz oder leer nach clean_text: '{text[:50]}...'") + # 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}") - if paragraph_text == "k.A.": self.logger.warning("Kein passender erster Absatz gefunden.") return paragraph_text - # _extract_infobox_value Funktion + + 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. + Extrahiert gezielt Branche, Umsatz oder Mitarbeiter aus der Infobox + eines Wikipedia-Artikels Soup-Objekts. Berücksichtigt Header in oder fett formatierten . """ 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}") - infobox = soup.select_one('table[class*="infobox"]') - if not infobox: self.logger.debug(" -> KEINE Infobox gefunden."); return "k.A." + + # 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): - # self.logger.debug(f" --- Prüfe Roh-HTML Zeile {idx}: {str(row)[:150]}...") # Zu ausführlich + # 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 + 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] - # self.logger.debug(f" -> Zeile {idx}: Struktur TH + TD erkannt.") # Zu ausführlich + # 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': - # Prüfe, ob das erste TD Header-ähnlich formatiert ist (fett) 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 or '800' in style or '900' in style): first_cell_is_header_like = True - elif cells[0].find(['b', 'strong'], recursive=False): first_cell_is_header_like = True + if 'font-weight' in style and ('bold' in style or '700' in style): + first_cell_is_header_like = True + # Prüfe auf fettgedruckten Inhalt ( oder ) + 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] - # self.logger.debug(f" -> Zeile {idx}: Struktur TD(Header-like) + TD erkannt.") # Zu ausführlich - # else: self.logger.debug(f" -> Zeile {idx}: Struktur TD + TD, aber erstes TD nicht als Header erkannt.") # Zu ausführlich - # else: self.logger.debug(f" -> Zeile {idx}: Übersprungen (Struktur passt nicht).") # Zu ausführlich - + # 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: - # self.logger.debug(f" -> Verarbeite Zeile {idx} mit Header='{header_text}'") # Zu ausführlich + # 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 + if kw in header_text_lower: + matched_keyword = kw + break + # Wenn ein Keyword gefunden wurde, extrahiere den Wert if matched_keyword: - self.logger.debug(f" --> Keyword '{matched_keyword}' gefunden in Header '{header_text}'!") - # --- Debugging Logik hinzugefügt --- - self.logger.debug(f" -> Roh-HTML der Value Cell vor Decompose: {str(value_cell)[:200]}...") - # --- Ende Debugging Logik --- - - # Entferne bekannte störende Elemente (Referenzen, versteckter Text) + # 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']): - self.logger.debug(f" -> Entferne störendes Element: {sup.get_text(strip=True)}") - sup.decompose() - - # --- Debugging Logik hinzugefügt --- - self.logger.debug(f" -> Roh-HTML der Value Cell nach Decompose: {str(value_cell)[:200]}...") - # --- Ende Debugging Logik --- + # 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) - self.logger.debug(f" -> Roher TD/Value-Text nach get_text: '{raw_value_text[:100]}'") - cleaned_raw_value = clean_text(raw_value_text) # Globaler Helper - self.logger.debug(f" -> Bereinigter Value-Text nach clean_text: '{cleaned_raw_value[:100]}'") + # 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 kann komplexe Formate haben, oft vor Klammern oder Newlines - clean_val = re.sub(r'\s*\([^)]*\)', '', cleaned_raw_value).strip() # Entferne Text in Klammern - clean_val = clean_val.split('\n')[0].strip() # Nimm nur die erste Zeile bei Newlines + # 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': - # extract_numeric_value kümmert sich um Einheiten und Format - # Globaler Helper - numeric_val = extract_numeric_value(cleaned_raw_value, is_umsatz=True) - value_found = numeric_val - self.logger.info(f" --> Umsatz extrahiert (aus '{cleaned_raw_value[:50]}'): '{value_found}'") + # 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': - # extract_numeric_value kümmert sich um Einheiten und Format - # Globaler Helper - numeric_val = extract_numeric_value(cleaned_raw_value, is_umsatz=False) - value_found = numeric_val - self.logger.info(f" --> Mitarbeiter extrahiert (aus '{cleaned_raw_value[:50]}'): '{value_found}'") + # 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}'") - # Wenn ein Wert für den gesuchten Target gefunden wurde, stoppen Sie die Suche in der Infobox. - # Wenn wir z.B. nach Umsatz suchen und den finden, wollen wir nicht weitersuchen. - # Wenn wir aber Branche, Umsatz UND Mitarbeiter extrahieren wollen, müssen wir alle Zeilen durchgehen. - # Die aktuelle Struktur ruft _extract_infobox_value 3x auf. Das ist OK. - # Wenn ein Wert FÜR DIESEN TARGET gefunden wurde, kann die Schleife über die Zeilen abgebrochen werden. - if value_found != "k.A.": - self.logger.debug(f" -> Wert für '{target}' in Zeile {idx} gefunden. Stoppe Infobox-Suche für dieses Target.") - break + # Da wir den Wert gefunden haben, können wir die Schleife über die Zeilen abbrechen + break - if value_found != "k.A.": self.logger.debug(f" -> Finaler Wert für '{target}': '{value_found}'") - else: self.logger.debug(f" -> Kein passender Eintrag für '{target}' in der Infobox gefunden.") + # 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." + return "k.A." # Bei Fehler "k.A." zurückgeben + return value_found - # extract_company_data Funktion - def extract_company_data(self, page_url): - """ - Extrahiert Firmendaten von einer Wikipedia-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.'} - if not page_url or not isinstance(page_url, str) or "wikipedia.org" not in page_url.lower(): - self.logger.warning(f"extract_company_data: Ungültige URL '{page_url}'.") - return default_result - self.logger.info(f"Extrahiere Daten für Wiki-URL: {page_url}") - soup = self._get_page_soup(page_url) # Ruft eigene Methode auf (mit Retry) - if not soup: - self.logger.error(f" -> Fehler: Konnte Seite {page_url} nicht laden oder parsen.") - default_result['url'] = page_url # Behalte die URL, auch wenn Extraktion fehlschlägt - return default_result + # --- Hauptmethoden --- - # Extrahieren der Daten (ruft eigene Methoden auf) - self.logger.debug(" -> Extrahiere ersten 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...") - 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') - - result = { - 'url': page_url, 'first_paragraph': first_paragraph, 'branche': branche_val, - 'umsatz': umsatz_val, 'mitarbeiter': mitarbeiter_val, 'categories': categories_val - } - self.logger.info(f" -> Extrahierte Daten: P={result['first_paragraph'][:30]}..., B='{result['branche']}', U='{result['umsatz']}', M='{result['mitarbeiter']}', C={result['categories'][:30]}...") - return result - - - @retry_on_failure # Decorator wird hier angewendet + # 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 und gibt das page-Objekt zurück. - Behandelt explizit Begriffsklärungsseiten. + 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."); return None - # _generate_search_terms ist global - search_terms = _generate_search_terms(company_name, website) - if not search_terms: self.logger.warning(f"Keine Suchbegriffe für '{company_name}' generiert."); return 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}") - processed_titles = set() # Verfolgt bereits geprüfte Titel - # Lokale Helferfunktion für die rekursive Prüfung und Validierung + # 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): - if title_to_check in processed_titles: self.logger.debug(f" -> Titel '{title_to_check}' bereits geprüft, überspringe."); return None + """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: - self.logger.debug(f" -> Prüfe potenziellen Artikel: '{title_to_check}'") - # wikipedia.page hat eigenes Retry-Verhalten und Exceptions - # auto_suggest=False um unerwünschte automatische Korrekturen zu vermeiden + # 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) - # Ruft eigene Methode auf + + # 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: self.logger.debug(f" -> Seite '{title_to_check}' nicht gefunden (PageError)."); return None + 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: - self.logger.info(f" -> Begriffsklärung '{title_to_check}' gefunden. Prüfe Optionen...") - self.logger.debug(f" Optionen: {e_inner.options}") + # 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 - # Prüfe die Optionen der Begriffsklärungsseite rekursiv + + # Gehe durch die Optionen der Begriffsklärungsseite for option in e_inner.options: - # Einfache Heuristik zur Identifizierung potenzieller Firmen option_lower = option.lower() - is_company_candidate = False - if "(unternehmen)" in option_lower: is_company_candidate = True - elif any(form in option_lower for form in [' gmbh', ' ag', ' kg', ' ltd', ' inc', ' corp', ' s.a.', ' se', ' group']): is_company_candidate = True - # Prüfe auch auf hohe Namensähnlichkeit mit dem Originalnamen - elif SequenceMatcher(None, normalize_company_name(option), normalize_company_name(company_name)).ratio() > 0.7: is_company_candidate = True + # 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 - if is_company_candidate: - # Rekursiver Aufruf von check_page für die Option - validated_option_page = check_page(option) - if validated_option_page: - self.logger.info(f" -> Option '{option}' erfolgreich validiert!") - if best_option_page is None: best_option_page = validated_option_page # Nimm den ersten validen als "besten" - if best_option_page: return best_option_page - else: self.logger.warning(f" -> Keine passende/validierte Unternehmens-Option in Begriffsklärung '{title_to_check}' gefunden."); return None - except requests.exceptions.RequestException as e_req: self.logger.warning(f" -> Netzwerkfehler beim Laden/Validieren von '{title_to_check}': {e_req}. Überspringe Titel."); time.sleep(1); return None # Fehler hier abfangen, um Suche nicht abzubrechen - except Exception as e_page: self.logger.error(f" -> Fehler bei Verarbeitung von Titel '{title_to_check}': {type(e_page).__name__} - {e_page}"); return None # Fehler hier abfangen, um Suche nicht abzubrechen + # 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 --- + # --- 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 + 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}'...") - # wikipedia.search hat eigenes Retry-Verhalten und Exceptions + # 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: continue + + 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) # Ruft lokale Helferfunktion auf - if validated_page: return validated_page - time.sleep(0.1) # Kleines Delay zwischen Titelprüfungen + validated_page = check_page(title) + if validated_page: + return validated_page # Ersten validierten Artikel gefunden! - except requests.exceptions.RequestException as e_search_req: self.logger.error(f"Netzwerkfehler während Wikipedia-Suche für '{term}': {e_search_req}"); time.sleep(2); raise e_search_req # Fehler weitergeben für Retry - except Exception as e_search: self.logger.error(f"Allgemeiner Fehler während Wikipedia-Suche für '{term}': {e_search}"); continue # Nächsten Begriff versuchen + # 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 - -# --- Ende WikipediaScraper Klasse --- + return None # Signalisiert, dass kein passender Artikel gefunden wurde -# ==================== BATCH PROCESSING HELPER (Global) ==================== -# Diese Funktion wird von DataProcessor.process_verification_batch aufgerufen. -# Sie kann global bleiben oder eine private Methode von DataProcessor werden. -# Wenn sie global bleibt, benötigt sie das sheet Objekt. -def _process_batch(sheet, batches, row_numbers): - """ - Hilfsfunktion für process_verification_batch: Verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen. - Aktualisiert NUR die Spalten S bis U. Zeitstempel werden von der aufrufenden Funktion gesetzt. + # 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: - sheet (gspread.Worksheet): Das Worksheet-Objekt zum Schreiben. - batches (list): Eine Liste von Strings, jeder ist der Prompt-Teil für eine Zeile. - row_numbers (list): Liste der zugehörigen Sheet-Zeilennummern (1-basiert). - """ - if not batches: return - # --- 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 : \n\n" - "Mögliche Antworten:\n" - "- 'OK' (wenn der Artikel gut passt)\n" - "- 'X | Alternativer Artikel: | Begründung: ' (wenn der Artikel nicht passt, aber ein besserer gefunden wurde)\n" - "- 'X | Kein passender Artikel gefunden | Begründung: ' (wenn der Artikel nicht passt und kein besserer gefunden wurde)\n\n" - "Einträge:\n" - "----------\n" - ) - aggregated_prompt += "".join(batches) - aggregated_prompt += "----------\nBitte nur die 'Eintrag X: Antwort'-Zeilen ausgeben." + Args: + page_url (str): Die URL des Wikipedia-Artikels. - logging.info(f"Verarbeite Verifizierungs-Batch für Zeilen {row_numbers[0]} bis {row_numbers[-1]} ({len(batches)} Einträge).") - prompt_tokens = token_count(aggregated_prompt) # Annahme: token_count ist global - logging.debug(f"Token-Zahl für Verifizierungs-Batch: {prompt_tokens}") + 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.'} - chat_response = call_openai_chat(aggregated_prompt, temperature=0.0) # Annahme: call_openai_chat ist global mit Retry + # 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 - if not chat_response: - logging.error(f"Fehler: Keine Antwort von OpenAI für Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]}.") - return # Batch Verarbeitung fehlgeschlagen + self.logger.info(f"Extrahiere Daten für Wiki-URL: {page_url}") - # Parse die aggregierte Antwort - answers = {} # {row_num: answer_text} - lines = chat_response.strip().split('\n') - for line in lines: - match = re.match(r"Eintrag (\d+): (.*)", line.strip()) - if match: - row_num = int(match.group(1)) - answer_text = match.group(2).strip() - if row_num in row_numbers: answers[row_num] = answer_text + # 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 - # Bereite Batch-Update für Spalten S-U vor - updates = [] - # Benötigte Spaltenindizes (Annahme: COLUMN_MAP ist global) - s_idx = COLUMN_MAP.get("Chat Wiki Konsistenzprüfung"); u_idx = COLUMN_MAP.get("Chat Vorschlag Wiki Artikel"); t_idx = COLUMN_MAP.get("Chat Begründung Wiki Inkonsistenz") - if None in [s_idx, u_idx, t_idx]: logging.error("FEHLER: Spaltenindizes für S, T, U fehlen in COLUMN_MAP."); return # Kann nicht schreiben + # Extrahiere die einzelnen Datenpunkte + self.logger.debug(" -> Extrahiere erster Absatz...") + first_paragraph = self._extract_first_paragraph_from_soup(soup) - # Konvertiere Indizes in Buchstaben - s_l = GoogleSheetHandler()._get_col_letter(s_idx + 1) # Temporäre Nutzung der Methode, da GoogleSheetHandler global nicht direkt zugänglich - t_l = GoogleSheetHandler()._get_col_letter(t_idx + 1) - u_l = GoogleSheetHandler()._get_col_letter(u_idx + 1) + 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') - for row_num in row_numbers: - answer = answers.get(row_num, "k.A. (Keine Antwort im Batch)") # Fallback + # 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 + } - wiki_confirm = ""; alt_article = ""; wiki_explanation = "" + # 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]}...'") - if answer.upper() == "OK": wiki_confirm = "OK" - 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 # Unerwartetes Format im 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 # Unerwartetes Format in Begründung - else: # Unerwartetes Format oder "Kein Wikipedia-Eintrag vorhanden." (sollte durch Suche vorher abgefangen sein) - wiki_confirm, wiki_explanation = "?", f"Unerwartetes Format: {answer[:100]}" - alt_article = "" + return result - # Füge Updates für S, T, U hinzu (basierend auf Spaltenbeschreibung: S=Konstistenz, T=Begründung, U=Vorschlag) - updates.append({'range': f'{s_l}{row_num}', 'values': [[wiki_confirm]]}) - updates.append({'range': f'{t_l}{row_num}', 'values': [[wiki_explanation]]}) # T ist Begründung - updates.append({'range': f'{u_l}{row_num}', 'values': [[alt_article]]}) # U ist Vorschlag - - # Führe das Batch-Update für S-U durch - if updates: - # sheet wird übergeben, nutze es direkt - try: - sheet.batch_update(updates, value_input_option='USER_ENTERED') - logging.info(f"Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]} (S-U) erfolgreich in Google Sheet aktualisiert.") - except Exception as e: - logging.error(f"FEHLER beim Batch-Update (S-U) für Batch {row_numbers[0]}-{row_numbers[-1]}: {e}") - # Hier sollte der Fehler für Retry an den Aufrufer (process_verification_batch) weitergegeben werden. - # Da _process_batch als globaler Helfer konzipiert ist, werfen wir den Fehler hier nicht direkt. - # Die retry-Logik muss in process_verification_batch um den Aufruf von _process_batch herum sein. - # Oder _process_batch wird eine Methode und nutzt den @retry_on_failure Decorator. - # Aktuell ist es ein globaler Helfer. Lassen wir es so, der Aufrufer muss retryen. - pass # Nicht reraisen, um andere Batches nicht zu blockieren - - -# --- Ende Batch Processing Helper --- -# ==================== DATA PROCESSOR ==================== -# Annahmen: GoogleSheetHandler, WikipediaScraper Klassen sind definiert -# Annahmen: Alle globalen Helper-Funktionen (clean_text, get_numeric_filter_value, etc.) sind definiert -# Annahme: COLUMN_MAP, Config, logging sind verfügbar. +# ============================================================================== +# 6. DATA PROCESSOR CLASS (PART 1: Init & Status-Checker) +# (Entspricht logisch dem Beginn von 'data_processor.py') +# ============================================================================== class DataProcessor: """ - Verarbeitet Daten aus dem Google Sheet, führt verschiedene Anreicherungs- - und Analyseprozesse durch, inklusive Timestamp-basierter Überspringung, - erzwungener Neuverarbeitung und granularer Schrittauswahl. Orchestriert - die verschiedenen Verarbeitungsmodi (sequenziell, batch, re-eval, kriterien). - Enthält auch die Datenvorbereitung und das Training des ML-Modells. + 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): + def __init__(self, sheet_handler, wiki_scraper): # Akzeptiert benötigte Worker-Instanzen """ - Initialisiert den DataProcessor mit Handlern. + 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 Handler/Scraper hinzu, falls nötig (z.B. WebsiteScraper falls separat) + # Fügen Sie hier weitere benötigte Handler/Worker hinzu (z.B. OpenAIHandler, SerpAPIHandler), + # falls diese als eigene Klassen ausgelagert werden. """ - if sheet_handler is None: - logging.critical("DataProcessor Init FEHLER: Kein gültiger sheet_handler übergeben!") - raise ValueError("DataProcessor benötigt einen gültigen GoogleSheetHandler.") - if wiki_scraper is None: - logging.critical("DataProcessor Init FEHLER: Kein gültiger wiki_scraper übergeben!") - raise ValueError("DataProcessor benötigt einen gültigen WikipediaScraper.") + # 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 # Speichert den übergebenen wiki_scraper - # Fügen Sie hier Instanzvariablen für weitere Handler hinzu: - # self.website_scraper = website_scraper # Beispiel - # self.api_client = api_client # Beispiel + self.wiki_scraper = wiki_scraper + # self.openai_handler = openai_handler # Beispiel, falls ausgelagert + # self.serpapi_handler = serpapi_handler # Beispiel, falls ausgelagert - logging.info("DataProcessor initialisiert.") + self.logger.info("DataProcessor initialisiert mit Handlern.") - # --- Private Helfermethode: Zugriff auf Zellwert mit row_data --- - # Diese Methode gehört in die Klasse und nimmt die rohe Zeilendatenliste entgegen - def _get_cell_value(self, row_data, key): - """Lokale Hilfsfunktion zum sicheren Zugriff auf Zellwerte innerhalb von Methoden, die row_data als Parameter erhalten.""" - idx = COLUMN_MAP.get(key) - if idx is not None and len(row_data) > idx: - return row_data[idx] if row_data[idx] is not None else '' - return "" + # 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. - # --- Private Helfermethode: Prüft ob ein Schritt nötig ist (basierend auf Timestamp/Status) --- - # Diese Methode gehört in die Klasse - def _is_step_processing_needed(self, row_data, step_key, force_reeval, related_inputs_updated=False): + # --- 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 spezifischer Verarbeitungsschritt für diese Zeile ausgeführt werden soll, - basierend auf Timestamp, force_reeval und ob Eingangsdaten aktualisiert wurden. + 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 rohen Daten für die Zeile. - step_key (str): Schlüssel des Timestamps in COLUMN_MAP, der diesen Schritt markiert (z.B. "Wikipedia Timestamp", "Timestamp letzte Prüfung"). Für Schritte ohne dedizierten Timestamp kann None übergeben werden, dann ist das Kriterium NUR related_inputs_updated oder force_reeval. - force_reeval (bool): Erzwingt die Ausführung, ignoriert Timestamps. - related_inputs_updated (bool, optional): Flag, ob Eingangsdaten für diesen Schritt gerade aktualisiert wurden (z.B. Wiki Daten für Branch Eval). Defaults to False. + 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 ausgeführt werden soll, sonst False. + 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: - # logging.debug(f" -> Step Check '{step_key}': True (force_reeval aktiv)") # Zu laut + # self.logger.debug(" -> Website-Schritt nötig (force_reeval=True)") # Zu viel Lärm return True - # Schritt hat keinen spezifischen Timestamp - if step_key is None: - # Logik: Wenn kein Timestamp, ist der Schritt nötig, wenn Inputs aktualisiert wurden. - # (Oder man definiert eine andere Logik, z.B. immer laufen wenn Flags gesetzt?) - # Für Abhängigkeiten ist related_inputs_updated der Trigger. - # Ohne force_reeval und ohne Timestamp ist der Schritt nur nötig, wenn Inputs neu sind. - needs_processing = related_inputs_updated - # logging.debug(f" -> Step Check '{step_key}' (Ohne TS): Nötig? {needs_processing} (Inputs aktualisiert? {related_inputs_updated})") # Zu laut - return needs_processing + # 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 - timestamp_col_index = COLUMN_MAP.get(step_key) - if timestamp_col_index is None: - logging.error(f" -> Step Check Fehler: Timestamp Schlüssel '{step_key}' nicht in COLUMN_MAP gefunden.") - return False # Kann nicht geprüft werden - - ts_value = row_data[timestamp_col_index] if len(row_data) > timestamp_col_index else "" - ts_is_set = bool(str(ts_value).strip()) - - # Ein Schritt ist nötig, wenn der Timestamp fehlt ODER relevante Inputs gerade aktualisiert wurden - needs_processing = not ts_is_set or related_inputs_updated - - # logging.debug(f" -> Step Check '{step_key}': Nötig? {needs_processing} (TS gesetzt? {ts_is_set}, Inputs aktualisiert? {related_inputs_updated})") # Zu laut - return needs_processing - - - # --- Die zentrale Methode zur Verarbeitung einer einzelnen Zeile --- - # Diese Methode gehört in die Klasse - # @retry_on_failure # Retry macht hier wenig Sinn, besser auf den einzelnen API Calls innerhalb - def _process_single_row(self, row_num_in_sheet, row_data, - process_wiki=True, process_chatgpt=True, process_website=True, - force_reeval=False): + def _needs_wiki_processing(self, row_data, force_reeval): """ - Verarbeitet die Daten für eine einzelne Zeile basierend auf ausgewählten Schritten - und Timestamp/Status-Logik (falls nicht force_reeval). Diese ist die zentrale - Logik für sequenzielle, re-eval und kriterienbasierte Modi. + 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): + """ + 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. - process_wiki (bool, optional): Soll die Gruppe Wiki-Schritte ausgeführt werden?. Defaults to True. - process_chatgpt (bool, optional): Soll die Gruppe ChatGPT-Schritte ausgeführt werden?. Defaults to True. - process_website (bool, optional): Soll die Gruppe Website-Schritte ausgeführt werden?. Defaults to True. - force_reeval (bool, optional): Ignoriert Timestamps und erzwingt Ausführung ausgewählter Schritte. Defaults to False. + 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. """ - # Logge welche Gruppen von Schritten für DIESE Zeile versucht werden sollen (basierend auf den Flags) - groups_to_attempt_log = [] - if process_website: groups_to_attempt_log.append("Website") - if process_wiki: groups_to_attempt_log.append("Wiki") - if process_chatgpt: groups_to_attempt_log.append("ChatGPT") - # Hinweis: Dies sind nur Gruppen-Flags. Detailliertere Step-Flags können im Refactoring hier verwendet werden. - # z.B. flags_for_steps = {'process_wiki_extraction': True, 'process_branch_evaluation': False, ...} - - logging.info(f"--- Starte Verarbeitung für Zeile {row_num_in_sheet} ({'Re-Eval' if force_reeval else 'Standard'}) - Gruppen: {', '.join(groups_to_attempt_log) if groups_to_attempt_log else 'Keine ausgewählt!'} ---") - - updates = [] + 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 überhaupt etwas in dieser Zeile verarbeitet wurde (für Version-Timestamp) - - # Flags, die signalisieren, ob ein VORHERIGER Schritt erfolgreich aktualisiert wurde - # Dies wird für NACHFOLGENDE Schritte als related_inputs_updated verwendet - wiki_data_updated_in_this_run = False # Hat Wiki Search/Extract Daten in diesem Lauf geändert? - website_data_updated_in_this_run = False # Hat Website Scraping/Summarize in diesem Lauf Daten geändert? + 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) - # Initiale Werte lesen (aus den erhaltenen row_data) - Nutze private Helfermethode - company_name = self._get_cell_value(row_data, "CRM Name") - website_url_crm = self._get_cell_value(row_data, "CRM Website") # CRM Website URL (Initial) - crm_branche = self._get_cell_value(row_data, "CRM Branche") - crm_beschreibung = self._get_cell_value(row_data, "CRM Beschreibung") - konsistenz_s = self._get_cell_value(row_data, "Chat Wiki Konsistenzprüfung").strip() # Trimme hier schon + # 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 - # Lade aktuelle Daten (könnten alt sein, werden ggf. überschrieben) für Inputs nachfolgender Schritte - # Wir lesen hier die Werte, die ZU BEGINN der Verarbeitung dieser Zeile im Sheet stehen. - # Wenn ein Schritt Daten aktualisiert (z.B. Wiki-Daten), wird die lokale Variable (z.B. final_wiki_data) aktualisiert. - # Nachfolgende Schritte in DIESER Zeile nutzen dann die aktualisierte lokale Variable. + # 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(row_data, "Wiki URL") or 'k.A.', - 'first_paragraph': self._get_cell_value(row_data, "Wiki Absatz") or 'k.A.', - 'branche': self._get_cell_value(row_data, "Wiki Branche") or 'k.A.', - 'umsatz': self._get_cell_value(row_data, "Wiki Umsatz") or 'k.A.', - 'mitarbeiter': self._get_cell_value(row_data, "Wiki Mitarbeiter") or 'k.A.', - 'categories': self._get_cell_value(row_data, "Wiki Kategorien") or 'k.A.' + '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.' } - # Erstelle lokale Variablen für die aktuellen Daten, die im Lauf aktualisiert werden können - final_wiki_data = dict(current_wiki_data) + 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 - current_website_raw = self._get_cell_value(row_data, "Website Rohtext") or "k.A." - current_website_summary = self._get_cell_value(row_data, "Website Zusammenfassung") or "k.A." - # Lokale Variablen, die im Lauf aktualisiert werden können - final_website_raw = current_website_raw - final_website_summary = current_website_summary + # --- 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. - # --- 1. Website Handling (Lookup, Scraping AR, Summarize AS) --- - # Dieser Block wird nur ausgeführt, wenn die GRUPPE "Website" ausgewählt ist - if process_website: + run_website_step = 'web' in steps_to_run + website_processing_needed_based_on_status = self._needs_website_processing(row_data, force_reeval) - # Website Scraping (AR) & Summarize (AS) sind nötig, wenn: - # (_is_step_processing_needed für AT ODER force_reeval) UND Website-URL vorhanden. - # Hier prüfen wir den Timestamp AT. Website-Lookup hat keinen eigenen Timestamp. - website_scrape_needed = self._is_step_processing_needed(row_data, "Website Scrape Timestamp", force_reeval, related_inputs_updated=False) # related_inputs_updated für Website ist immer False + 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'})") - if website_scrape_needed: - any_processing_done = True - logging.info(f"Zeile {row_num_in_sheet}: Starte Website Verarbeitung (Scraping/Summarize) (Grund: {'Re-Eval' if force_reeval else 'AT fehlt'})...") - - # Website Lookup (D) nur, wenn URL fehlt ( unabhängig von steps-Flags, da es ein Pre-Requisite ist) - # UND der Website-Step überhaupt ausgewählt ist. - website_url_to_process = website_url_crm # Starte mit der CRM URL - if not website_url_crm or website_url_crm.strip().lower() == "k.a.": - logging.debug(" -> Suche Website via SERP (URL fehlt)...") - new_website = serp_website_lookup(company_name) # Globaler Funktion mit Retry + # 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_to_process = new_website # Update die URL für die Verarbeitung - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["CRM Website"] + 1)}{row_num_in_sheet}', 'values': [[website_url_to_process]]}) - logging.info(f" -> Neue Website gefunden und für Update vorgemerkt: {website_url_to_process}") + 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: - logging.warning(f" -> Keine neue Website via SERP gefunden für '{company_name}'.") - # website_url_to_process bleibt die ursprüngliche (fehlende) URL + 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) - if website_url_to_process and website_url_to_process.strip().lower() not in ["k.a.", ""]: - logging.debug(f" -> Scrape Rohtext von {website_url_to_process}...") - final_website_raw = get_website_raw(website_url_to_process) # Globaler Funktion mit Retry + # 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]]}) - # Website Summary (AS) wird gemacht, wenn Scraping erfolgreich war. - if final_website_raw != "k.A." and final_website_raw.strip(): - logging.debug(f" -> Fasse Rohtext zusammen (Länge: {len(final_website_raw)})...") - final_website_summary = summarize_website_content(final_website_raw) # Globaler Funktion mit Retry - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[final_website_summary]]}) else: - logging.warning(" -> Kein gültiger Rohtext zum Zusammenfassen vorhanden.") - final_website_summary = "k.A." # Sicherstellen, dass lokale Variable korrekt ist + 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.']]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [[final_website_raw]]}) # Rohtext immer schreiben (k.A. oder Inhalt) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Scrape Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # Timestamp AT setzen + # 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]]}) - # Flag setzen, da Website-Daten aktualisiert wurden (AR und/oder AS) - website_data_updated_in_this_run = True + 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: - logging.warning(f" -> Keine gültige Website URL vorhanden/gefunden für '{company_name}'. Website Verarbeitung (Scraping/Summarize) übersprungen.") - final_website_raw, final_website_summary = "k.A.", "k.A." # Sicherstellen, dass lokale Vars gesetzt sind - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Scrape Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # AT trotzdem setzen, um Versuch zu markieren - - # --- 2. Wikipedia Handling (Search M, Extract N-R, Verify S-U) --- - # Dieser Block wird nur ausgeführt, wenn die GRUPPE "Wiki" ausgewählt ist - if process_wiki: - - # Wiki Search & Extraction (M, N-R) ist nötig, wenn: - # (_is_step_processing_needed für AN ODER S='X (URL Copied)'?) - wiki_search_extract_needed = self._is_wiki_search_extract_needed(row_data, force_reeval) # _is_wiki_search_extract_needed prüft AN und S='X' und force_reeval - - if wiki_search_extract_needed: - any_processing_done = True - # Grund-Message wird von _is_wiki_search_extract_needed implizit geprüft, hier im Log wiederholen - logging.info(f"Zeile {row_num_in_sheet}: Starte Wikipedia Verarbeitung (Search/Extract) (Grund: {'Re-Eval' if force_reeval else 'Standard (AN fehlt oder S=X)'})...") - - url_in_m = self._get_cell_value(row_data, "Wiki URL").strip() # Lese URL, die ZU BEGINN da war - url_to_extract = None - search_was_needed = False - - # --- Logik für URL-Bestimmung (wie zuvor, mit kleinen Anpassungen) --- - # Wenn force_reeval: Nutze M direkt, wenn gültig. Sonst Suche. - # Wenn S="X (URL Copied)": Ignoriere M, mache Suche. - # Wenn AN fehlt (Standard): Wenn M gültig, valide M. Sonst Suche. - # Beachte: Wir nutzen hier die URL, die ZU BEGINN der Zeilenverarbeitung in M stand. - - if force_reeval: - logging.debug(" -> Wiki Search/Extract: Re-Eval Modus aktiv.") - if url_in_m and url_in_m.lower() not in ["k.a.", "kein artikel gefunden"] and url_in_m.lower().startswith("http"): - logging.info(f" -> Re-Eval: Nutze vorhandene URL aus Spalte M direkt: {url_in_m}") - url_to_extract = url_in_m - else: - logging.warning(f" -> Re-Eval: Spalte M ist leer oder ungültig ('{url_in_m}'). Starte neue Suche...") - search_was_needed = True - elif konsistenz_s.upper() == "X (URL COPIED)": # S='X (URL Copied)' ist ein direkter Such-Trigger - logging.warning(f" -> Status S ist 'X (URL Copied)', ignoriere URL '{url_in_m}' in M und starte neue Suche...") - search_was_needed = True - elif not self._get_cell_value(row_data, "Wikipedia Timestamp").strip(): # Nur wenn AN fehlt und S nicht 'X(Copied)' ist, UND kein reeval - if url_in_m and url_in_m.lower() not in ["k.a.", "kein artikel gefunden"] and url_in_m.lower().startswith("http"): - logging.debug(f" -> AN fehlt, prüfe Validität der URL aus M: {url_in_m}") - try: - # Extrahiere Titel für wikipedia.page (kann fehlschlagen) - title_from_url = url_in_m.split('/wiki/')[-1].replace('_', ' ') - # Nutze self.wiki_scraper Methode für Validierung - if self.wiki_scraper._validate_article(None, company_name, website_url_crm): # validate_article braucht page obj, aber wir haben nur URL - # Das ist kompliziert. is_valid_wikipedia_article_url prüft ob es ein Artikel ist. - # validate_article prüft ob der Artikel ZUR FIRMA passt. - # Wenn AN fehlt und M da ist: Prüfen, ob M auf einen validen Artikel verweist, UND ob dieser Artikel zur Firma passt. - # is_valid_wikipedia_article_url (global) prüft nur auf Artikel. - # validate_article (scraper Methode) prüft auf Passung zur Firma. - # Ideal wäre: check_page() aus search_company_article, die beides tut. - # Da check_page lokal ist: Duplizieren wir die Logik hier oder machen check_page zu einer scraper Methode. - # Machen wir check_page zu einer Scraper Methode. - - # NEU: Rufe Scraper Methode auf, die URL prüft UND validiert - validated_page = self.wiki_scraper.check_url_and_validate(url_in_m, company_name, website_url_crm) # <<< NEUE SCRAPER METHODE NÖTIG - if validated_page: - url_to_extract = validated_page.url - logging.info(f" -> Vorhandene URL aus M '{url_to_extract}' ist valide und passt zur Firma.") - else: - logging.warning(f" -> Vorhandene URL aus M '{url_in_m}' ist NICHT valide oder passt nicht zur Firma. Starte neue Suche...") - search_was_needed = True - - except Exception as e_val_m: # Fängt Fehler bei URL parsing oder check_url_and_validate ab - logging.warning(f" -> Fehler/Problem bei Prüfung der URL aus M '{url_in_m}': {type(e_val_m).__name__} - {e_val_m}. Starte neue Suche...") - search_was_needed = True - - else: - logging.info(f" -> AN fehlt und M leer/ungültig. Starte Wikipedia-Suche für '{company_name}'...") - search_was_needed = True - - # Führe Suche aus, wenn search_was_needed True ist - if search_was_needed: - # Nutze self.wiki_scraper - validated_page = self.wiki_scraper.search_company_article(company_name, website_url_crm) # Nutze CRM Website URL - if validated_page: - url_to_extract = validated_page.url - else: - final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'} - wiki_data_updated_in_this_run = True - logging.warning(f" -> Wikipedia Suche für '{company_name}' fand keinen validen Artikel.") - - - # Datenextraktion, wenn eine URL zum Extrahieren bestimmt wurde (kann auch "Kein Artikel gefunden" sein) - if url_to_extract: # Dies ist der URL, der *jetzt* in M stehen sollte (oder Kein Artikel gefunden) - logging.info(f" -> Extrahiere Daten von URL: {url_to_extract}...") - # Prüfe, ob die URL überhaupt valide ist, bevor extrahiert wird - if url_to_extract != 'Kein Artikel gefunden' and url_to_extract.lower().startswith("http"): - # Nutze self.wiki_scraper - extracted_data = self.wiki_scraper.extract_company_data(url_to_extract) - if extracted_data: - # Aktualisiere die lokale Variable final_wiki_data - final_wiki_data.update(extracted_data) - wiki_data_updated_in_this_run = True - logging.info(f" -> Datenextraktion erfolgreich.") - else: - logging.error(f" -> Fehler bei Datenextraktion von {url_to_extract}. Setze extrahierte Daten auf 'k.A.'") - # URL bleibt, aber extrahierte Felder werden auf k.A. gesetzt - final_wiki_data.update({'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}) - wiki_data_updated_in_this_run = True # Markiere als aktualisiert, auch wenn mit k.A. - else: - # Wenn url_to_extract "Kein Artikel gefunden" ist oder ungültig, setze extrahierte Felder auf k.A. - # URL (M) wird ja oben schon gesetzt - logging.info(f" -> Keine gültige URL zum Extrahieren ({url_to_extract}). Setze extrahierte Daten auf 'k.A.'.") - final_wiki_data.update({'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}) - # wiki_data_updated_in_this_run = True # Bereits oben gesetzt, wenn search_was_needed - - - # Sheet Updates für M-R und AN (nur wenn dieser Wiki Search/Extract Schritt lief) - # Diese Updates spiegeln die final_wiki_data am Ende dieses Blocks wider. - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki URL"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('url', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Absatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('first_paragraph', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Branche"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('branche', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('umsatz', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('mitarbeiter', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Kategorien"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('categories', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wikipedia Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # AN Timestamp setzen - - - # Setze S ('Chat Wiki Konsistenzprüfung') und AX zurück, wenn eine Neubewertung nötig ist - # S und AX werden durch die Wiki Verify Batch Logik gesetzt/geprüft. - # Hier setzen wir S und AX nur zurück, wenn sich die URL geändert hat ODER force_reeval war - # ODER S vorher X(Copied) war. Das triggert die Wiki Verify Batch Logik später. - # Lese die URL, die ZU BEGINN in M stand, für diesen Vergleich - url_changed = (self._get_cell_value(row_data, "Wiki URL").strip() != final_wiki_data.get('url', 'k.A.')) - if force_reeval or konsistenz_s.upper() == "X (URL COPIED)" or url_changed: # konsistenz_s ist der Wert ZU BEGINN - s_idx = COLUMN_MAP.get("Chat Wiki Konsistenzprüfung"); ax_idx = COLUMN_MAP.get("Wiki Verif. Timestamp") - if s_idx is not None: - s_let = self.sheet_handler._get_col_letter(s_idx + 1) - updates.append({'range': f'{s_let}{row_num_in_sheet}', 'values': [["?"]]}) # Fragezeichen für Neubewertung - grund_message_parts = [] - if force_reeval: grund_message_parts.append('Re-Eval') - if konsistenz_s.upper() == "X (URL COPIED)": grund_message_parts.append("S='X (URL Copied)'") - if url_changed: grund_message_parts.append('URL geändert') - grund_message = ", ".join(grund_message_parts) - logging.info(f" -> Status S zurückgesetzt auf '?' für erneute Verifikation (Grund: {grund_message}).") - if ax_idx is not None: - ax_let = self.sheet_handler._get_col_letter(ax_idx + 1) - updates.append({'range': f'{ax_let}{row_num_in_sheet}', 'values': [['']]}) # AX leeren, triggert Wiki Verify Batch - - - # --- 3. Wikipedia Verifizierung (S-U, AX) --- - # Dies ist ein UNTER-Schritt der Wiki-Verarbeitungsgruppe. - # Er wird ausgeführt, wenn die GRUPPE "Wiki" ausgewählt ist (process_wiki=True) - # UND (_is_step_processing_needed für AX ODER Wiki Daten gerade aktualisiert wurden) - # Wir müssen hier prüfen, ob der spezifische Verify-Schritt ausgewählt ist, falls wir granularer steuern wollen. - # Mit den aktuellen Flags (process_wiki, process_chatgpt, process_website) ist Verify Teil von process_wiki. - # Also: Wenn process_wiki True UND (_is_step_processing_needed für AX ODER wiki_data_updated_in_this_run) - - wiki_verify_needed = process_wiki and self._is_step_processing_needed(row_data, "Wiki Verif. Timestamp", force_reeval, wiki_data_updated_in_this_run) - - - if wiki_verify_needed: - any_processing_done = True - logging.info(f"Zeile {row_num_in_sheet}: Starte Wikipedia Verifizierung (Grund: {'Re-Eval' if force_reeval else f'AX fehlt oder Wiki Daten aktualisiert ({wiki_data_updated_in_this_run})'})...") - - # Hier ist die Logik, die den ChatGPT-Call für die Verifizierung macht (zeilenweise) - # Annahme: call_openai_chat ist global mit Retry - # Annahme: COLUMN_MAP Indizes für S, T, U sind vorhanden - - # Daten für die Verifizierung sammeln (nutze final_wiki_data) - company_name = self._get_cell_value(row_data, "CRM Name") - crm_desc = self._get_cell_value(row_data, "CRM Beschreibung") - - entry_text = ( - f"Eintrag {row_num_in_sheet}:\n" - f" Firmenname: {company_name}\n" - f" CRM-Beschreibung: {crm_desc[:200]}...\n" - f" Wikipedia-URL: {final_wiki_data.get('url', 'k.A.')}\n" # Nutze final_wiki_data - f" Wiki-Absatz: {final_wiki_data.get('first_paragraph', 'k.A.')[:200]}...\n" # Nutze final_wiki_data - f" Wiki-Kategorien: {final_wiki_data.get('categories', 'k.A.')[:200]}...\n" # Nutze final_wiki_data - f"----\n" - ) - - # Prompt für EINE Zeile erstellen - prompt = ( # ... Prompt Definition wie oben in Teil 3 ... ) - "Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln für Unternehmen.\n" - "Prüfe, ob der folgende Wikipedia-Artikel plausibel zum Firmennamen und zur Beschreibung passt.\n" - "Gib das Ergebnis ausschließlich im folgenden Format aus:\n" - "Antwort: \n\n" - "Mögliche Antworten (Kurzform):\n" - "- 'OK' (wenn der Artikel gut passt)\n" - "- 'X | Alternativer Artikel: | Begründung: '\n" - "- 'X | Kein passender Artikel gefunden | Begründung: '\n\n" - "Eintrag:\n" - "----------\n" - f"{entry_text}" - "----------\nBitte nur die 'Antwort: ...'-Zeile ausgeben." - ) - - chat_response = call_openai_chat(prompt, temperature=0.0) - - wiki_confirm = ""; alt_article = ""; wiki_explanation = "" - - if chat_response: # <= ZEILE 2262 - match = re.match(r"Antwort: (.*)", chat_response.strip()) # <= ZEILE 2263 - if match: # <= ZEILE 2264 - answer_text = match.group(1).strip() # <= ZEILE 2265 - logging.debug(f"Zeile {row_num_in_sheet} Verifizierungsantwort: '{answer_text}'") # <= ZEILE 2266 - - if answer_text.upper() == "OK": # <= ZEILE 2267 - wiki_confirm = "OK" - elif answer_text.startswith("X |"): # <= ZEILE 2268 - parts = answer_text.split("|", 2) # <= ZEILE 2269 - wiki_confirm = "X" # <= ZEILE 2270 - alt_article = "" # Initialisieren (oder "" von oben nutzen) - wiki_explanation = "" # Initialisieren - - if len(parts) > 1: # <= ZEILE 2271 - detail = parts[1].strip() # <= ZEILE 2272 - # Annahme: wiki_explanation kann von parts[2] gesetzt werden - if len(parts) > 2: # <= ZEILE 2273 - # wiki_explanation = parts[2].split(":", 1)[1].strip() if parts[2].strip().startswith("Begründung:") else parts[2].strip() # Alte Logik - reason_part = parts[2].strip() # <= ZEILE 2274 - if reason_part.lower().startswith("begründung:"): # <= ZEILE 2275 - wiki_explanation = reason_part.split(":", 1)[1].strip() # <= ZEILE 2276 - else: - # Wenn es nicht mit "Begründung:" anfängt, nehmen wir den ganzen Rest als Begründung - wiki_explanation = reason_part # <= ZEILE 2277 - - # Verarbeite den Detail-Teil (parts[1]) unabhängig von len(parts)>2 - if detail.lower().startswith("alternativer artikel:"): # <= ZEILE 2274 (im Screenshot falsch eingerückt?) - alt_article = detail.split(":", 1)[1].strip() # <= ZEILE 2275 (im Screenshot falsch eingerückt?) - elif detail.lower() == "kein passender artikel gefunden": # <= ZEILE 2276 (im Screenshot falsch eingerückt?) - alt_article = detail # Der Text selbst ist der Vorschlag - else: - # Wenn der Detail-Teil weder URL noch "Kein passender Artikel" ist, behandeln wir ihn als Begründung, - # falls noch keine Begründung aus parts[2] gefunden wurde. - # Oder loggen wir es als unerwartet? Loggen ist sicherer. - logging.warning(f"Zeile {row_num_in_sheet}: Unerwartetes Detail-Format nach 'X |': '{detail}'") - # Setze es als Begründung nur, wenn parts[2] nicht existierte oder leer war? - # Vereinfacht: Wenn Detail-Teil nicht URL/Kein gefunden, füge ihn ZUR Begründung hinzu. - if wiki_explanation: wiki_explanation += f" | Unerw. Detail: {detail}" - else: wiki_explanation = f"Unerw. Detail: {detail}" - - - # ELSE für if answer_text.upper() == "OK": und elif answer_text.startswith("X |"): - # Dies wird erreicht, wenn die Antwort kein "OK" und kein "X |" Format hat. - else: # <= ZEILE 2280 - # Korrigierte Einrückung für diesen Block: muss unter das 'else:' gehören - wiki_confirm = "?" # <= ZEILE 2281 (korrekt eingerückt) - wiki_explanation = f"Unerwartetes Format: {answer_text[:100]}..." # <= ZEILE 2281 (als separate Zeile) - alt_article = "" # <= ZEILE 2281 (als separate Zeile) - # Loggen Sie den unerwarteten Fall - logging.error(f"Zeile {row_num_in_sheet}: Unerwartetes Verifizierungs-Antwortformat: '{answer_text}'.") # <= ZEILE 2282 (korrekt eingerückt) - - - # ELSE für if match: (wenn der Regex "Antwort: (.*)" nicht matchte) - else: # <= ZEILE 2284 - # Korrigierte Einrückung für diesen Block: muss unter das 'else:' gehören - wiki_confirm = "?" # <= ZEILE 2285 (korrekt eingerückt) - wiki_explanation = f"Parsing Fehler: {chat_response[:100]}..." # <= ZEILE 2285 (als separate Zeile) - alt_article = "" # <= ZEILE 2285 (als separate Zeile) - logging.error(f"Zeile {row_num_in_sheet}: Parsing Fehler für Verifizierungsantwort (Regex Match fehlgeschlagen): {chat_response}.") # <= ZEILE 2286 (korrekt eingerückt) - - - # ELSE für if chat_response: (wenn call_openai_chat None zurückgab) - else: # <= ZEILE 2284 (anderes else, gehört zu if chat_response:) - # Korrigierte Einrückung für diesen Block: muss unter das 'else:' gehören - wiki_confirm = "Fehler" # <= ZEILE 2285 (korrekt eingerückt) - wiki_explanation = "API Fehler oder keine Antwort" # <= ZEILE 2285 (als separate Zeile) - alt_article = "" # <= ZEILE 2285 (als separate Zeile) - logging.error(f"Zeile {row_num_in_sheet}: API Fehler oder keine Antwort für Verifizierungs-Prompt.") # <= ZEILE 2286 (korrekt eingerückt) - - - # Füge Updates für S, T, U hinzu (basierend auf Spaltenbeschreibung: S=Konstistenz, T=Begründung, U=Vorschlag) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Wiki Konsistenzprüfung"] + 1)}{row_num_in_sheet}', 'values': [[wiki_confirm]]}) # <= ZEILE 2288 - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Wiki Inkonsistenz"] + 1)}{row_num_in_sheet}', 'values': [[wiki_explanation]]}) # T ist Begründung <= ZEILE 2289 - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Wiki Artikel"] + 1)}{row_num_in_sheet}', 'values': [[alt_article]]}) # U ist Vorschlag <= ZEILE 2290 - - # Setze AX Timestamp, wenn dieser Schritt gemacht wurde - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Verif. Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # <= ZEILE 2293 - - - # --- 4. ChatGPT Evaluationen (Branch W-Y, FSM Z-AA, MA AB-AD, Umsatz AG-AH) --- - # Dieser Block wird nur ausgeführt, wenn die GRUPPE "ChatGPT" ausgewählt ist - # ... (Rest der _process_single_row Methode, wie in Teil 4/6 von 18:55 Uhr) ... - if process_chatgpt: - - # Branch Evaluation (W-Y) ist nötig, wenn: (_is_step_processing_needed für AO ODER Inputs (Wiki/Web) wurden aktualisiert) - chat_ts_ao_missing = not self._get_cell_value(row_data, "Timestamp letzte Prüfung").strip() - branch_eval_needed = self._is_step_processing_needed(row_data, "Timestamp letzte Prüfung", force_reeval, wiki_data_updated_in_this_run or website_data_updated_in_this_run) - - if branch_eval_needed: - any_processing_done = True - logging.info(f"Zeile {row_num_in_sheet}: Starte Branchen Evaluation (Grund: {'Re-Eval' if force_reeval else f'AO fehlt oder Inputs aktualisiert ({wiki_data_updated_in_this_run or website_data_updated_in_this_run})'})...") - - # Annahme: evaluate_branche_chatgpt existiert (global) und nutzt logging/retry - # Nutze die (ggf. neu extrahierten) final_wiki_data und final_website_summary - branch_result = evaluate_branche_chatgpt( - crm_branche, crm_beschreibung, - final_wiki_data.get('branche', 'k.A.'), # Nutze final_wiki_data - final_wiki_data.get('categories', 'k.A.'), # Nutze final_wiki_data - final_website_summary # Nutze final_website_summary - ) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('branch', 'Fehler')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenz Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('consistency', 'Fehler')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('justification', 'Fehler')]]}) - - # Setze AO Timestamp, wenn Branch Evaluation gemacht wurde - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Timestamp letzte Prüfung"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) - - - # --- Weitere ChatGPT-Schätzungen und Konsistenzprüfungen --- - # Diese laufen JETZT mit, wenn die GRUPPE "ChatGPT" ausgewählt war UND Branch Eval nötig war. - # Im Refactoring werden dies granularere, wählbare Schritte mit eigenen Flags. - - # FSM Evaluation (Z-AA) - # Annahme: evaluate_fsm_suitability existiert (global) und nutzt logging/retry - fsm_result = evaluate_fsm_suitability(company_name, {'wiki': final_wiki_data, 'web_summary': final_website_summary, 'crm_desc': crm_beschreibung}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Prüfung FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[fsm_result.get('suitability', 'Fehler')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung für FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[fsm_result.get('justification', 'Fehler')]]}) - - # Mitarbeiterzahl Schätzung (AB) - # Annahme: process_employee_estimation existiert (global) und nutzt logging/retry - # Benötigt Wiki Paragraph, CRM Employee als Input - estimated_emp_value_str = process_employee_estimation(company_name, final_wiki_data.get('first_paragraph', 'k.A.'), self._get_cell_value(row_data, "CRM Anzahl Mitarbeiter")) # Ergebnis wird in AB geschrieben - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schätzung Anzahl Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[estimated_emp_value_str]]}) # Annahme: gibt String zurück - - - # Mitarbeiter Konsistenzprüfung (AC, AD) - # Annahme: process_employee_consistency existiert (global) - # Braucht CRM, Wiki, und geschätzte MA (nehme den geschätzten Wert aus updates oder row_data, hier updates besser) - # Finden Sie den geschätzten Wert aus den Updates, falls vorhanden, sonst nehmen Sie den alten Wert aus row_data - estimated_emp_value_for_consistency = next((item['values'][0][0] for item in updates if item['range'].startswith(self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schätzung Anzahl Mitarbeiter"] + 1))), self._get_cell_value(row_data, "Chat Schätzung Anzahl Mitarbeiter")) - emp_consistency = process_employee_consistency(self._get_cell_value(row_data, "CRM Anzahl Mitarbeiter"), final_wiki_data.get('mitarbeiter', 'k.A.'), estimated_emp_value_for_consistency) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenzprüfung Mitarbeiterzahl"] + 1)}{row_num_in_sheet}', 'values': [[emp_consistency.get('consistency', 'Fehler')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Mitarbeiterzahl"] + 1)}{row_num_in_sheet}', 'values': [[emp_consistency.get('justification', 'Fehler')]]}) - - - # Umsatz Schätzung (AG) - # Annahme: evaluate_umsatz_chatgpt existiert (global) - # Benötigt Wiki Umsatz (aus extrahierten Daten) als Input - estimated_umsatz_value_str = evaluate_umsatz_chatgpt(company_name, final_wiki_data.get('umsatz', 'k.A.')) # Ergebnis wird in AG geschrieben - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schätzung Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[estimated_umsatz_value_str]]}) # Annahme: gibt String zurück - - - # Umsatz Konsistenzprüfung (AH) - # Annahme: evaluate_umsatz_chatgpt_consistency existiert (global) - # Braucht CRM, Wiki, und geschätzten Umsatz - estimated_umsatz_value_for_consistency = next((item['values'][0][0] for item in updates if item['range'].startswith(self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schätzung Umsatz"] + 1))), self._get_cell_value(row_data, "Chat Schätzung Umsatz")) - umsatz_consistency = evaluate_umsatz_chatgpt_consistency(self._get_cell_value(row_data, "CRM Umsatz"), final_wiki_data.get('umsatz', 'k.A.'), estimated_umsatz_value_for_consistency) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[umsatz_consistency.get('justification', 'Fehler')]]}) - - # --- 5. ML Schätzung Servicetechniker (AU) --- - # Dieses sollte ein separater Prozess sein, der NACHDEM die Inputs (W, AV, AW) verfügbar sind, läuft. - # Also NICHT hier in _process_single_row. - - - # --- 6. Abschließende Updates --- - # Version wird gesetzt, wenn IRGENDEINE Verarbeitung in dieser Zeile stattgefunden hat - if any_processing_done: - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Version"] + 1)}{row_num_in_sheet}', 'values': [[Config.VERSION]]}) - - # --- 7. Batch Update für diese Zeile --- - if updates: - logging.info(f"Zeile {row_num_in_sheet}: Sende Batch-Update mit {len(updates)} Operationen für diese Zeile...") - success = self.sheet_handler.batch_update_cells(updates) # Nutze self.sheet_handler - if not success: logging.error(f"Zeile {row_num_in_sheet}: FEHLER beim Batch-Update.") - else: - if not any_processing_done: - logging.info(f"Zeile {row_num_in_sheet}: Keine Updates zum Schreiben (alle relevanten Schritte übersprungen).") - - logging.info(f"--- Verarbeitung für Zeile {row_num_in_sheet} abgeschlossen ---") - # Kleine Pause nach der Verarbeitung jeder Zeile, um API-Limits zu respektieren - time.sleep(max(0.1, getattr(Config, 'RETRY_DELAY', 5) / 20)) - - # --- Private Helfer für Timestamp/Status Checks --- - # Diese werden von _process_single_row aufgerufen. - # Stellen Sie sicher, dass diese Methoden IN der Klasse DataProcessor sind. - - def _get_cell_value(self, row_data, key): # Implementierung wurde oben kopiert, muss hier in die Klasse - """Lokale Hilfsfunktion zum sicheren Zugriff auf Zellwerte innerhalb von Methoden, die row_data als Parameter erhalten.""" - idx = COLUMN_MAP.get(key) - if idx is not None and len(row_data) > idx: - return row_data[idx] if row_data[idx] is not None else '' - return "" - - def _is_step_processing_needed(self, row_data, step_key, force_reeval, related_inputs_updated=False): # Implementierung wurde oben kopiert, muss hier in die Klasse - """ - Prüft, ob ein spezifischer Verarbeitungsschritt für diese Zeile ausgeführt werden soll, - basierend auf Timestamp, force_reeval und ob Eingangsdaten aktualisiert wurden. - """ - if force_reeval: return True - if step_key is None: return related_inputs_updated # Ohne Timestamp, nur wenn Inputs neu - - timestamp_col_index = COLUMN_MAP.get(step_key) - if timestamp_col_index is None: logging.error(f" -> Step Check Fehler: Timestamp Schlüssel '{step_key}' nicht in COLUMN_MAP gefunden."); return False - - ts_value = row_data[timestamp_col_index] if len(row_data) > timestamp_col_index else "" - ts_is_set = bool(str(ts_value).strip()) - - needs_processing = not ts_is_set or related_inputs_updated - return needs_processing - - # _is_wiki_search_extract_needed Helfer (spezifisch für AN & S='X (URL Copied)') - def _is_wiki_search_extract_needed(self, row_data, force_reeval): # related_inputs_updated hier nicht relevant - """Prüft, ob Wikipedia Search/Extraction nötig ist (AN Timestamp oder S='X (URL Copied)' oder force_reeval).""" - # Wiki Search/Extraction ist nötig, wenn: force_reeval ODER AN fehlt ODER S='X (URL Copied)' - # Nutze private Helfermethode _get_cell_value - wiki_ts_an_missing = not self._get_cell_value(row_data, "Wikipedia Timestamp").strip() - status_s_indicates_reparse = self._get_cell_value(row_data, "Chat Wiki Konsistenzprüfung").strip().upper() == "X (URL COPIED)" - - return force_reeval or wiki_ts_an_missing or status_s_indicates_reparse - - # _is_wiki_verification_needed Helfer (spezifisch für AX) - def _is_wiki_verification_needed(self, row_data, force_reeval, wiki_data_updated_in_this_run): # Abhängig von wiki_data_updated_in_this_run - """Prüft, ob Wikipedia Verifizierung nötig ist (AX Timestamp oder Wiki Daten aktualisiert).""" - # Wiki Verifizierung (S-U, AX) ist nötig, wenn: force_reeval ODER AX fehlt ODER Wiki Daten gerade aktualisiert wurden - return self._is_step_processing_needed(row_data, "Wiki Verif. Timestamp", force_reeval, wiki_data_updated_in_this_run) - - # _is_branch_evaluation_needed Helfer (spezifisch für AO) - def _is_branch_evaluation_needed(self, row_data, force_reeval, inputs_updated_in_this_run): # Abhängig von wiki_data_updated_in_this_run ODER website_data_updated_in_this_run - """Prüft, ob Branch Evaluation nötig ist (AO Timestamp oder Inputs (Wiki/Web) aktualisiert).""" - # Branch Evaluation ist nötig, wenn: force_reeval ODER AO fehlt ODER Inputs (Wiki/Web) wurden aktualisiert - return self._is_step_processing_needed(row_data, "Timestamp letzte Prüfung", force_reeval, inputs_updated_in_this_run) - - # Fügen Sie hier weitere _is_xxx_needed Methoden für andere Schritte hinzu (FSM, MA, Umsatz Schätzung) - # Diese prüfen jeweils ihren spezifischen Trigger (eigenen Timestamp ODER Inputs). - # Z.B. FSM hat keinen eigenen TS, wird getriggert wenn Branch Eval inputs (Wiki/Web) aktualisiert ODER Branch Eval selbst neu gemacht wurde. - - - # --- Methode für den Re-Eval Modus --- - # Diese Methode gehört in die Klasse - def process_reevaluation_rows(self, row_limit=None, clear_flag=True, - process_wiki_steps=True, # <-- Flags als Parameter - process_chatgpt_steps=True, # <-- Flags als Parameter - process_website_steps=True): # <-- Flags als Parameter - """ - 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 die Gruppe Wiki-Schritte ausgeführt werden?. Defaults to True. - process_chatgpt_steps (bool, optional): Soll die Gruppe ChatGPT-Schritte ausgeführt werden?. Defaults to True. - process_website_steps (bool, optional): Soll die Gruppe Website-Schritte ausgeführt werden?. Defaults to True. - """ - logging.info(f"Starte Re-Evaluierungsmodus (Spalte A = 'x'). Max. Zeilen: {row_limit if row_limit is not None else 'Unbegrenzt'}") - selected_steps_log = [] - if process_wiki_steps: selected_steps_log.append("Wiki") - if process_chatgpt_steps: selected_steps_log.append("ChatGPT") - if process_website_steps: selected_steps_log.append("Website") - logging.info(f"Ausgewählte Schritte für Re-Eval: {', '.join(selected_steps_log) if selected_steps_log else 'Keine ausgewählt!'} (force_reeval=True)") - - if not self.sheet_handler.load_data(): return logging.error("Fehler beim Laden der Daten für Re-Evaluation.") - all_data = self.sheet_handler.get_all_data_with_headers() - header_rows = 5 - if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten für Re-Evaluation gefunden.") - - reeval_col_idx = COLUMN_MAP.get("ReEval Flag") - if reeval_col_idx is None: return logging.critical("FEHLER: 'ReEval Flag' Spaltenindex nicht in COLUMN_MAP gefunden.") - - 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 - if len(row_data) > reeval_col_idx and str(row_data[reeval_col_idx]).strip().lower() == "x": - rows_to_process.append({'row_num': row_num_in_sheet, 'data': row_data}) - - found_count = len(rows_to_process) - logging.info(f"{found_count} Zeilen mit ReEval-Flag 'x' gefunden.") - if found_count == 0: return logging.info("Keine Zeilen zur Re-Evaluation markiert.") - - processed_count = 0 - updates_clear_flag = [] - rows_actually_processed = [] - - for task in rows_to_process: - if limit is not None and processed_count >= limit: - logging.info(f"Zeilenlimit ({limit}) für Re-Evaluation erreicht. Breche weitere Verarbeitung ab.") - break - - row_num = task['row_num'] - row_data = task['data'] - try: - # Rufe _process_single_row mit force_reeval=True und den ausgewählten Flags auf - self._process_single_row( - row_num_in_sheet = row_num, - row_data = row_data, - process_wiki = process_wiki_steps, # <<< ÜBERGIBT DIE STEUERUNG - process_chatgpt = process_chatgpt_steps, # <<< ÜBERGIBT DIE STEUERUNG - process_website = process_website_steps, # <<< ÜBERGIBT DIE STEUERUNG - force_reeval = True # <<< BLEIBT HIER TRUE FÜR RE-EVAL MODUS - ) - processed_count += 1 - rows_actually_processed.append(row_num) - - 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: logging.error(f"Fehler: Konnte Spaltenbuchstaben für 'ReEval Flag' ({reeval_col_idx+1}) nicht ermitteln.") - - except Exception as e_proc: logging.exception(f"FEHLER bei Re-Evaluation von Zeile {row_num}: {e_proc}") - - if clear_flag and updates_clear_flag: - logging.info(f"Lösche ReEval-Flags für {len(updates_clear_flag)} erfolgreich verarbeitete Zeilen ({rows_actually_processed})...") - success = self.sheet_handler.batch_update_cells(updates_clear_flag) - if success: logging.info("ReEval-Flags erfolgreich gelöscht.") - else: logging.error("FEHLER beim Löschen der ReEval-Flags.") - logging.info(f"Re-Evaluierung abgeschlossen. {processed_count} Zeilen verarbeitet (Limit war: {limit}, Gefunden: {found_count}).") - - # --- Methode für sequenzielle Verarbeitung (full_run) --- - # Diese Methode gehört in die Klasse - def process_sequential(self, start_sheet_row, num_to_process, - process_wiki=True, process_chatgpt=True, process_website=True): - """ - Verarbeitet eine feste Anzahl von Zeilen beginnend bei einer bestimmten Sheet-Zeile - sequenziell, eine nach der anderen, unter Verwendung von _process_single_row. - _process_single_row prüft dabei die Timestamps/Status (force_reeval=False). - - Args: - start_sheet_row (int): Die 1-basierte Startzeilennummer im Sheet. - num_to_process (int): Die maximale Anzahl der zu verarbeitenden Zeilen. - process_wiki (bool, optional): Soll die Gruppe Wiki-Schritte ausgeführt werden?. Defaults to True. - process_chatgpt (bool, optional): Soll die Gruppe ChatGPT-Schritte ausgeführt werden?. Defaults to True. - process_website (bool, optional): Soll die Gruppe Website-Schritte ausgeführt werden?. Defaults to True. - """ - header_rows = 5 # Annahme - - logging.info(f"Starte sequenzielle Verarbeitung von {num_to_process} Zeilen ab Sheet-Zeile {start_sheet_row}...") - groups_to_attempt_log = [] - if process_website: groups_to_attempt_log.append("Website") - if process_wiki: groups_to_attempt_log.append("Wiki") - if process_chatgpt: groups_to_attempt_log.append("ChatGPT") - logging.info(f"Ausgewählte Schritte: {', '.join(groups_to_attempt_log) if groups_to_attempt_log else 'Keine ausgewählt!'} (Standard-Timestamp/Status-Logik)") - - - if not self.sheet_handler.load_data(): logging.error("Fehler beim Laden der Daten für sequenzielle Verarbeitung."); return - all_data = self.sheet_handler.get_all_data_with_headers() - total_sheet_rows = len(all_data) - - if start_sheet_row > total_sheet_rows or start_sheet_row <= header_rows: - logging.warning(f"Start-Sheet-Zeile {start_sheet_row} liegt außerhalb des gültigen Datenbereichs ({header_rows+1} bis {total_sheet_rows}). Keine Verarbeitung.") - return - - end_sheet_row_inclusive = min(start_sheet_row + num_to_process - 1, total_sheet_rows) - - logging.info(f"Sequenzielle Verarbeitung geplant für Sheet-Zeilen {start_sheet_row} bis {end_sheet_row_inclusive}.") - if start_sheet_row > end_sheet_row_inclusive: logging.warning("Start nach Ende (berechnet nach Limit). Keine Verarbeitung."); return - - processed_count = 0 - for i in range(start_sheet_row, end_sheet_row_inclusive + 1): - row_num_in_sheet = i - row_data = all_data[i - 1] - - try: - self._process_single_row( - row_num_in_sheet = row_num_in_sheet, - row_data = row_data, - process_wiki = process_wiki, # <<< ÜBERGIBT DIE STEUERUNG - process_chatgpt = process_chatgpt, # <<< ÜBERGIBT DIE STEUERUNG - process_website = process_website, # <<< ÜBERGIBT DIE STEUERUNG - force_reeval = False # <<< WICHTIG: Standard-Timestamp/Status-Logik - ) - processed_count += 1 - - except Exception as e_proc: logging.exception(f"FEHLER bei sequenzieller Verarbeitung von Zeile {row_num_in_sheet}: {e_proc}") - - logging.info(f"Sequenzielle Verarbeitung abgeschlossen. {processed_count} Zeilen bearbeitet im Bereich [{start_sheet_row}, {end_sheet_row_inclusive}].") - - # --- Methode zum Prozessieren von Zeilen nach Kriterien (NEU) --- - # Diese Methode gehört in die Klasse - def process_rows_matching_criteria(self, criteria_func, limit=None, - process_wiki=True, process_chatgpt=True, process_website=True, - force_step_reeval=False): - """ - Sucht Zeilen im Sheet, die ein gegebenes Kriterium erfüllen (definiert durch criteria_func). - Verarbeitet eine begrenzte Anzahl dieser passenden Zeilen unter Verwendung von _process_single_row. - - Args: - criteria_func (callable): Eine Funktion, die eine Zeile (list) nimmt und True zurückgibt, wenn das Kriterium erfüllt ist. - limit (int, optional): Maximale Anzahl passender Zeilen, die verarbeitet werden sollen. Defaults to None (alle passenden). - process_wiki (bool, optional): Soll die Gruppe Wiki-Schritte ausgeführt werden?. Defaults to True. - process_chatgpt (bool, optional): Soll die Gruppe ChatGPT-Schritte ausgeführt werden?. Defaults to True. - process_website (bool, optional): Soll die Gruppe Website-Schritte ausgeführt werden?. Defaults to True. - force_step_reeval (bool, optional): Bestimmt, ob _process_single_row mit force_reeval=True aufgerufen wird (ignoriert Timestamps für ausgewählte Schritte). Defaults to False. - """ - logging.info(f"Starte Verarbeitung von Zeilen nach Kriterien. Limit: {limit if limit is not None else 'Unbegrenzt'}") - logging.info(f"Verwendetes Kriterium: {criteria_func.__name__}") - groups_to_attempt_log = [] - if process_website: groups_to_attempt_log.append("Website") - if process_wiki: groups_to_attempt_log.append("Wiki") - if process_chatgpt: groups_to_attempt_log.append("ChatGPT") - logging.info(f"Ausgewählte Schritte: {', '.join(groups_to_attempt_log) if groups_to_attempt_log else 'Keine ausgewählt!'}") - logging.info(f"force_reeval für Schritte: {force_step_reeval}") - - - if not self.sheet_handler.load_data(): logging.error("Fehler beim Laden der Daten für kriterienbasierte Verarbeitung."); return - all_data = self.sheet_handler.get_all_data_with_headers() - header_rows = 5 - if not all_data or len(all_data) <= header_rows: logging.warning("Keine Daten für kriterienbasierte Verarbeitung gefunden."); return - - rows_to_process = [] - logging.info("Suche nach Zeilen, die dem Kriterium entsprechen...") - 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 - - try: - if criteria_func(row_data): # Nutze die globale Kriterien-Funktion - rows_to_process.append({'row_num': row_num_in_sheet, 'data': row_data}) - except Exception as e_crit: logging.error(f"FEHLER beim Prüfen des Kriteriums für Zeile {row_num_in_sheet}: {e_crit}"); - - found_count = len(rows_to_process) - logging.info(f"{found_count} Zeilen entsprechen dem Kriterium '{criteria_func.__name__}'.") - if found_count == 0: logging.info("Keine Zeilen gefunden, die dem Kriterium entsprechen."); return - - processed_count = 0 - for task in rows_to_process: - if limit is not None and processed_count >= limit: - logging.info(f"Limit ({limit}) für kriterienbasierte Verarbeitung erreicht. Breche weitere Verarbeitung ab.") - break - - row_num = task['row_num'] - row_data = task['data'] - try: - self._process_single_row( - row_num_in_sheet = row_num, - row_data = row_data, - process_wiki = process_wiki, # <<< ÜBERGIBT DIE STEUERUNG - process_chatgpt = process_chatgpt, # <<< ÜBERGIBT DIE STEUERUNG - process_website = process_website, # <<< ÜBERGIBT DIE STEUERUNG - force_reeval = force_step_reeval # <<< Bestimmt, ob Timestamps ignoriert werden - ) - processed_count += 1 - - except Exception as e_proc: logging.exception(f"FEHLER bei Verarbeitung einer Kriterium-Zeile ({row_num}): {e_proc}") - - logging.info(f"Kriterienbasierte Verarbeitung abgeschlossen. {processed_count} von {found_count} gefundenen Zeilen bearbeitet (Limit war: {limit}).") - - - # --- Batch-Verarbeitung Methoden (Werden von run_batch_dispatcher aufgerufen) --- - # Diese Methoden führen eine spezifische Aufgabe für einen Batch aus, basierend auf einem Timestamp. - # Sie rufen NICHT _process_single_row auf. - - def process_verification_batch(self, limit=None): - """ - Batch-Prozess NUR für Wikipedia-Verifizierung (Spalten S-U, AX). - Findet Startzeile ab erster Zelle mit leerem AX. - """ - logging.info(f"Starte Wikipedia-Verifizierungs-Batch. Limit: {limit if limit is not None else 'Unbegrenzt'}") - if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.") - all_data = self.sheet_handler.get_all_data_with_headers() - header_rows = 5 - if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten gefunden.") - - timestamp_col_key = "Wiki Verif. Timestamp"; timestamp_col_index = COLUMN_MAP.get(timestamp_col_key) - if timestamp_col_index is None: return logging.critical(f"FEHLER: Schlüssel '{timestamp_col_key}' nicht in COLUMN_MAP gefunden.") - ts_col_letter = self.sheet_handler._get_col_letter(timestamp_col_index + 1) - - start_data_index = self.sheet_handler.get_start_row_index(check_column_key=timestamp_col_key, min_sheet_row=header_rows + 1) - if start_data_index == -1: return logging.error(f"FEHLER bei Startzeilensuche auf Spalte '{timestamp_col_key}'.") - if start_data_index >= len(self.sheet_handler.get_data()): return logging.info("Alle Zeilen mit Timestamp gefüllt. Nichts zu tun.") - - start_sheet_row = start_data_index + header_rows + 1 - total_sheet_rows = len(all_data) - end_sheet_row = total_sheet_rows # Default bis Ende - - if limit is not None and limit >= 0: - end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows) - if limit == 0: return logging.info("Limit 0.") - - if start_sheet_row > end_sheet_row: logging.warning("Start nach Ende (Limit)."); return - - logging.info(f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} für Wiki Verifizierung (Batch).") - - batch_size = Config.BATCH_SIZE - current_batch = [] - current_row_numbers = [] - processed_count = 0 - - for i in range(start_sheet_row, end_sheet_row + 1): - row_index_in_list = i - 1 - row = all_data[row_index_in_list] - - # Vorbereitung für den Prompt (Daten holen) - company_name = self._get_cell_value(row, "CRM Name") - crm_desc = self._get_cell_value(row, "CRM Beschreibung") - wiki_url = self._get_cell_value(row, "Wiki URL") - wiki_paragraph = self._get_cell_value(row, "Wiki Absatz") - wiki_categories = self._get_cell_value(row, "Wiki Kategorien") - - - # Füge nur hinzu, wenn relevante Wiki-Daten da sind ODER URL existiert - if wiki_url != 'k.A.' or wiki_paragraph != 'k.A.' or wiki_categories != 'k.A.': - entry_text = ( - f"Eintrag {i}:\n" - f" Firmenname: {company_name}\n" - f" CRM-Beschreibung: {crm_desc[:200]}...\n" - f" Wikipedia-URL: {wiki_url}\n" - f" Wiki-Absatz: {wiki_paragraph[:200]}...\n" - f" Wiki-Kategorien: {wiki_categories[:200]}...\n" - f"----\n" - ) - current_batch.append(entry_text) - current_row_numbers.append(i) - processed_count += 1 - - if len(current_batch) >= batch_size or i == end_sheet_row: - if current_batch: - # Rufe _process_batch auf (globale Helferfunktion oder private Methode) - # Angenommen, _process_batch ist global definiert - try: - _process_batch(self.sheet_handler.sheet, current_batch, current_row_numbers) - - # Setze den AX Timestamp für die bearbeiteten Zeilen, NUR wenn _process_batch nicht exception geworfen hat - wiki_ts_updates = [] - current_wiki_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - for row_num in current_row_numbers: - wiki_ts_updates.append({'range': f'{ts_col_letter}{row_num}', 'values': [[current_wiki_timestamp]]}) - - if wiki_ts_updates: - success_ts = self.sheet_handler.batch_update_cells(wiki_ts_updates) - if success_ts: logging.debug(f"Wiki Verif. Timestamp {ts_col_letter} für Batch {current_row_numbers[0]}-{current_row_numbers[-1]} gesetzt.") - else: logging.error(f"FEHLER beim Setzen des Wiki Verif. Timestamps {ts_col_letter} für Batch.") - except Exception as e_batch: - logging.error(f"FEHLER bei Verarbeitung von Batch {current_row_numbers[0]}-{current_row_numbers[-1]} in _process_batch: {e_batch}") - # Hier könnten Sie die Zeilen im Sheet markieren, die Fehler hatten - pass # Fahren Sie mit dem nächsten Batch fort - - time.sleep(Config.RETRY_DELAY) - - current_batch = [] - current_row_numbers = [] - - logging.info(f"Wikipedia-Verifizierungs-Batch abgeschlossen. {processed_count} Zeilen in Batches verarbeitet (aus Bereich {start_sheet_row}-{end_sheet_row}).") - - # process_website_batch Methode - def process_website_batch(self, limit=None): - """ - Batch-Prozess NUR für Website-Scraping (Rohtext AR, Timestamp AT). - Findet Startzeile ab erster Zelle mit leerem AT. - """ - logging.info(f"Starte Website-Scraping Batch. Limit: {limit if limit is not None else 'Unbegrenzt'}") - if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.") - all_data = self.sheet_handler.get_all_data_with_headers() - header_rows = 5 - if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten gefunden.") - - rohtext_col_key = "Website Rohtext"; rohtext_col_index = COLUMN_MAP.get(rohtext_col_key) - website_col_idx = COLUMN_MAP.get("CRM Website") - version_col_idx = COLUMN_MAP.get("Version") - timestamp_col_key = "Website Scrape Timestamp"; timestamp_col_index = COLUMN_MAP.get(timestamp_col_key) - - if None in [rohtext_col_index, website_col_idx, version_col_idx, timestamp_col_index]: return logging.critical(f"FEHLER: Benötigte Indizes für process_website_batch fehlen.") - rohtext_col_letter = self.sheet_handler._get_col_letter(rohtext_col_index + 1) - version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1) - ts_col_letter = self.sheet_handler._get_col_letter(timestamp_col_index + 1) - - start_data_index = self.sheet_handler.get_start_row_index(check_column_key=timestamp_col_key, min_sheet_row=header_rows + 1) - if start_data_index == -1: return logging.error(f"FEHLER bei Startzeilensuche auf Spalte '{timestamp_col_key}'.") - if start_data_index >= len(self.sheet_handler.get_data()): return logging.info("Alle Zeilen mit Timestamp gefüllt. Nichts zu tun.") - - start_sheet_row = start_data_index + header_rows + 1 - total_sheet_rows = len(all_data) - end_sheet_row = total_sheet_rows # Default bis Ende - - if limit is not None and limit >= 0: - end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows) - if limit == 0: return logging.info("Limit 0.") - - if start_sheet_row > end_sheet_row: logging.warning("Start nach Ende (Limit)."); return - - logging.info(f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} für Website Scraping (Batch).") - - # Worker-Funktion für Scraping (Kann global bleiben oder private statische Methode) - # Bleibt global, da sie keine self benötigt. - def scrape_raw_text_task(task_info): - row_num = task_info['row_num']; url = task_info['url']; raw_text = "k.A."; error = None - try: raw_text = get_website_raw(url) # Annahme: get_website_raw ist global mit Retry - except Exception as e: error = f"Scraping Fehler Zeile {row_num}: {e}"; logging.error(error) # Logge Fehler im Worker - return {"row_num": row_num, "raw_text": raw_text, "error": error} - - - tasks_for_processing_batch = [] - all_sheet_updates = [] - processed_count = 0 # Zählt Zeilen, für die Task erstellt wird - skipped_url_count = 0 - - processing_batch_size = Config.PROCESSING_BATCH_SIZE - max_scraping_workers = Config.MAX_SCRAPING_WORKERS - - for i in range(start_sheet_row, end_sheet_row + 1): - row_index_in_list = i - 1 - row = all_data[row_index_in_list] - - # URL Prüfung (immer nötig, auch wenn AT fehlt) - website_url = row[website_col_idx] if len(row) > website_col_idx else "" - if not website_url or website_url.strip().lower() == "k.A.": - skipped_url_count += 1 - continue - - # Kein AT Timestamp -> Task erstellen - tasks_for_processing_batch.append({"row_num": i, "url": website_url}) - processed_count += 1 - - # Verarbeitungs-Batch ausführen - if len(tasks_for_processing_batch) >= processing_batch_size or i == end_sheet_row: - 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'] - batch_task_count = len(tasks_for_processing_batch) - logging.info(f"\n--- Starte Scraping-Batch ({batch_task_count} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") - - scraping_results = {} # {'row_num': raw_text} - batch_error_count = 0 - logging.info(f" Scrape {batch_task_count} 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}: {exc}" - logging.error(err_msg) - scraping_results[row_num] = "k.A. (Fehler)" - batch_error_count += 1 - - logging.info(f" Scraping für Batch beendet. {len(scraping_results)} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).") - - # Sheet Updates vorbereiten (AR und AT) - if scraping_results: - current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - batch_sheet_updates = [] - for row_num, raw_text_res in scraping_results.items(): - batch_sheet_updates.extend([ - {'range': f'{rohtext_col_letter}{row_num}', 'values': [[raw_text_res]]}, - {'range': f'{ts_col_letter}{row_num}', 'values': [[current_timestamp]]} # Setze AT Timestamp - ]) - all_sheet_updates.extend(batch_sheet_updates) - - # Sheet Updates senden für diesen Batch - if all_sheet_updates: - logging.info(f" Sende Sheet-Update für {len(all_sheet_updates)} Zellen für Batch {batch_start_row}-{batch_end_row}...") - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: logging.info(f" Sheet-Update erfolgreich.") - else: logging.error(f" FEHLER beim Sheet-Update.") - all_sheet_updates = [] # Zurücksetzen nach Senden - - # Pause nach jedem Batch - logging.debug(" Warte nach Batch...") - time.sleep(Config.RETRY_DELAY) - - # Finaler Sheet Update Batch senden (falls Reste übrig) - if all_sheet_updates: - logging.info(f"Sende finalen Sheet-Update ({len(all_sheet_updates)} Zellen)...") - self.sheet_handler.batch_update_cells(all_sheet_updates) - - logging.info(f"Website-Scraping Batch abgeschlossen. {processed_count} Tasks erstellt, {skipped_url_count} Zeilen ohne URL übersprungen.") - - - # process_summarization_batch Methode - def process_summarization_batch(self, limit=None): - """ - Batch-Prozess NUR für Website-Zusammenfassung (AS). - Findet Startzeile ab erster Zelle mit leerem AS, wo AR gefüllt ist. - """ - logging.info(f"Starte Website-Zusammenfassung Batch. Limit: {limit if limit is not None else 'Unbegrenzt'}") - if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.") - all_data = self.sheet_handler.get_all_data_with_headers() - header_rows = 5 - if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten gefunden.") - - rohtext_col_idx = COLUMN_MAP.get("Website Rohtext") - summary_col_idx = COLUMN_MAP.get("Website Zusammenfassung") - version_col_idx = COLUMN_MAP.get("Version") - if None in [rohtext_col_idx, summary_col_idx, version_col_idx]: return logging.critical(f"FEHLER: Benötigte Indizes fehlen.") - 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) - - # Finde die Startzeile: Erste Zelle mit leerem AS UND gefülltem AR - # Dies erfordert ein manuelles Scannen, da get_start_row_index nur eine Spalte prüft - start_sheet_row = header_rows + 1 # Starte nach Headern - logging.info(f"Suche Startzeile für Zusammenfassungs-Batch (leeres AS, gefülltes AR)...") - found_start_row = None - for i in range(header_rows, len(all_data)): - row = all_data[i] - row_num_in_sheet = i + 1 - # Sicherstellen, dass Zeile lang genug ist - if len(row) <= max(rohtext_col_idx, summary_col_idx): continue - - ar_value = str(row[rohtext_col_idx]).strip() - as_value = str(row[summary_col_idx]).strip() - - # Kriterium: AS ist leer UND AR ist gefüllt (nicht k.A. Varianten) - ar_is_filled = bool(ar_value) and ar_value.lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"] - as_is_empty = not bool(as_value) - - if ar_is_filled and as_is_empty: - found_start_row = row_num_in_sheet - logging.info(f"Startzeile für Zusammenfassungs-Batch gefunden: {found_start_row} (Index {i}).") - break - - if found_start_row is None: - logging.info("Keine Zeilen gefunden, die eine Zusammenfassung benötigen (leeres AS, gefülltes AR).") - return # Nichts zu tun - - start_sheet_row = found_start_row - total_sheet_rows = len(all_data) - end_sheet_row = total_sheet_rows # Default bis Ende - - if limit is not None and limit >= 0: - end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows) - if limit == 0: return logging.info("Limit 0.") - - if start_sheet_row > end_sheet_row: logging.warning("Start nach Ende (Limit)."); return - - - logging.info(f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} für Website Zusammenfassung (Batch).") - - tasks_for_openai_batch = [] - all_sheet_updates = [] - processed_count = 0 # Zählt Zeilen, für die Task erstellt wird - - openai_batch_size = Config.OPENAI_BATCH_SIZE_LIMIT - - for i in range(start_sheet_row, end_sheet_row + 1): - row_index_in_list = i - 1 - row = all_data[row_index_in_list] - - # Erneute Prüfung (nur zur Sicherheit): Ist AS noch leer und AR gefüllt? (Daten könnten sich geändert haben) - if len(row) <= max(rohtext_col_idx, summary_col_idx): continue # Zeile zu kurz - - ar_value = str(row[rohtext_col_idx]).strip() - as_value = str(row[summary_col_idx]).strip() - ar_is_filled = bool(ar_value) and ar_value.lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"] - as_is_empty = not bool(as_value) - - if not (ar_is_filled and as_is_empty): - # Diese Zeile wurde von get_start_row_index gefunden, aber das Kriterium passt nicht mehr (z.B. manuell bearbeitet) - logging.debug(f"Zeile {i}: Kriterium (leeres AS, gefülltes AR) passt nicht mehr, übersprungen.") - continue - - - # Task hinzufügen - tasks_for_openai_batch.append({'row_num': i, 'raw_text': ar_value}) # Füge den Rohtext hinzu - processed_count += 1 - - # OpenAI Batch verarbeiten, wenn voll oder letzte Zeile - if tasks_for_openai_batch and (len(tasks_for_openai_batch) >= openai_batch_size or i == end_sheet_row): - debug_print(f" Verarbeite OpenAI Batch für {len(tasks_for_openai_batch)} Aufgaben (Start: {tasks_for_openai_batch[0]['row_num']})...") - # summarize_batch_openai ist global (oder private helper) - try: - summaries_result = summarize_batch_openai(tasks_for_openai_batch) - - # Sheet Updates für diesen OpenAI Batch vorbereiten - current_version = Config.VERSION - current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Timestamp für AS? Oder AT nutzen? - # AT ist für Scraping. AS könnte eigenen Timestamp bekommen, oder AO/AP nutzen. - # Belassen wir es bei AS + AP Update. - - for task in tasks_for_openai_batch: - row_num = task['row_num'] - summary = summaries_result.get(row_num, "k.A. (Fehler Batch Zuordnung)") - batch_sheet_updates = [ - {'range': f'{summary_col_letter}{row_num}', 'values': [[summary]]}, - # Version AP wird in _process_single_row gesetzt. Batch Modi setzen AP nicht. - # Das ist eine Inkonsistenz. AP sollte von jedem Batch Modus gesetzt werden. - # {'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]} # Setze AP hier - ] - all_sheet_updates.extend(batch_sheet_updates) - - # Sheet Updates senden für diesen OpenAI Batch - if all_sheet_updates: - logging.info(f" Sende Sheet-Update für {len(tasks_for_openai_batch)} Zusammenfassungen ({len(all_sheet_updates)} Zellen)...") - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: logging.info(f" Sheet-Update erfolgreich.") - else: logging.error(f" FEHLER beim Sheet-Update.") - all_sheet_updates = [] # Zurücksetzen nach Senden - - except Exception as e_batch: - logging.error(f"FEHLER bei Verarbeitung von OpenAI Batch {tasks_for_openai_batch[0]['row_num']}-{tasks_for_openai_batch[-1]['row_num']}: {e_batch}") - # Fehler markieren? Oder einfach weitermachen? Pass. - - tasks_for_openai_batch = [] # OpenAI Batch leeren - # Pause nach jedem OpenAI Batch - time.sleep(Config.RETRY_DELAY) - - - # Finaler Sheet Update Batch senden (falls Reste übrig) - if all_sheet_updates: - logging.info(f"Sende finalen Sheet-Update ({len(all_sheet_updates)} Zellen)...") - self.sheet_handler.batch_update_cells(all_sheet_updates) - - logging.info(f"Website-Zusammenfassung Batch abgeschlossen. {processed_count} Tasks erstellt.") - - - # process_branch_batch Methode - def process_branch_batch(self, limit=None): - """ - Batch-Prozess NUR für Brancheneinschätzung (W-Y, AO). - Findet Startzeile ab erster Zelle mit leerem AO. - """ - logging.info(f"Starte Branchen-Einschätzung Batch. Limit: {limit if limit is not None else 'Unbegrenzt'}") - if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.") - all_data = self.sheet_handler.get_all_data_with_headers() - header_rows = 5 - if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten gefunden.") - - timestamp_col_key = "Timestamp letzte Prüfung"; timestamp_col_index = COLUMN_MAP.get(timestamp_col_key) - branche_crm_idx = COLUMN_MAP.get("CRM Branche"); beschreibung_idx = COLUMN_MAP.get("CRM Beschreibung") - branche_wiki_idx = COLUMN_MAP.get("Wiki Branche"); kategorien_wiki_idx = COLUMN_MAP.get("Wiki Kategorien") - summary_web_idx = COLUMN_MAP.get("Website Zusammenfassung"); version_col_idx = COLUMN_MAP.get("Version") - branch_w_idx = COLUMN_MAP.get("Chat Vorschlag Branche"); branch_x_idx = COLUMN_MAP.get("Chat Konsistenz Branche") - branch_y_idx = COLUMN_MAP.get("Chat Begründung Abweichung Branche") - required_indices = [timestamp_col_index, branche_crm_idx, beschreibung_idx, branche_wiki_idx, kategorien_wiki_idx, summary_web_idx, version_col_idx, branch_w_idx, branch_x_idx, branch_y_idx] - if None in required_indices: return logging.critical(f"FEHLER: Benötigte Indizes fehlen.") - - ts_col_letter = self.sheet_handler._get_col_letter(timestamp_col_index + 1) - version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1) - branch_w_letter = self.sheet_handler._get_col_letter(branch_w_idx + 1) - branch_x_letter = self.sheet_handler._get_col_letter(branch_x_idx + 1) - branch_y_letter = self.sheet_handler._get_col_letter(branch_y_idx + 1) - - - # Finde die Startzeile - start_data_index = self.sheet_handler.get_start_row_index(check_column_key=timestamp_col_key, min_sheet_row=header_rows + 1) - if start_data_index == -1: return logging.error(f"FEHLER bei Startzeilensuche auf Spalte '{timestamp_col_key}'.") - if start_data_index >= len(self.sheet_handler.get_data()): return logging.info("Alle Zeilen mit Timestamp gefüllt. Nichts zu tun.") - - start_sheet_row = start_data_index + header_rows + 1 - total_sheet_rows = len(all_data) - end_sheet_row = total_sheet_rows # Default bis Ende - - if limit is not None and limit >= 0: - end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows) - if limit == 0: return logging.info("Limit 0.") - - if start_sheet_row > end_sheet_row: logging.warning("Start nach Ende (Limit)."); return - - - logging.info(f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} für Branchen-Einschätzung (Batch).") - - MAX_BRANCH_WORKERS = Config.MAX_BRANCH_WORKERS - OPENAI_CONCURRENCY_LIMIT = Config.OPENAI_CONCURRENCY_LIMIT - openai_semaphore_branch = threading.Semaphore(OPENAI_CONCURRENCY_LIMIT) # Annahme: threading ist importiert - - tasks_for_processing_batch = [] # Liste von Task-Daten für parallele Verarbeitung - processed_count = 0 # Zählt Zeilen, für die Task erstellt wird - - if not ALLOWED_TARGET_BRANCHES: load_target_schema(); - if not ALLOWED_TARGET_BRANCHES: return logging.critical("FEHLER: Ziel-Schema nicht geladen. Branch Batch nicht möglich.") - - - for i in range(start_sheet_row, end_sheet_row + 1): - row_index_in_list = i - 1 - row = all_data[row_index_in_list] - - # Erneute Prüfung (nur zur Sicherheit): Ist AO noch leer? - if len(row) > timestamp_col_index and str(row[timestamp_col_index]).strip(): - logging.debug(f"Zeile {i}: Timestamp {ts_col_letter} ist nicht mehr leer, übersprungen.") - continue - - # Task sammeln (Nutze self._get_cell_value) - task_data = { - "row_num": i, - "crm_branche": self._get_cell_value(row, "CRM Branche"), - "beschreibung": self._get_cell_value(row, "CRM Beschreibung"), - "wiki_branche": self._get_cell_value(row, "Wiki Branche"), - "wiki_kategorien": self._get_cell_value(row, "Wiki Kategorien"), - "website_summary": self._get_cell_value(row, "Website Zusammenfassung") - } - tasks_for_processing_batch.append(task_data) - processed_count += 1 - - # Batch verarbeiten, wenn voll oder letzte Zeile - if len(tasks_for_processing_batch) >= Config.PROCESSING_BRANCH_BATCH_SIZE or i == end_sheet_row: - 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'] - batch_task_count = len(tasks_for_processing_batch) - logging.info(f"\n--- Starte Branch-Evaluation Batch ({batch_task_count} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") - - results_list = []; batch_error_count = 0 - logging.info(f" Evaluiere {batch_task_count} Zeilen parallel (max {MAX_BRANCH_WORKERS} worker, {OPENAI_CONCURRENCY_LIMIT} OpenAI gleichzeitig)...") - # Worker Funktion für Branch Evaluation (muss hier oder global sein) - # Kann private Methode werden, die semaphore nutzt. - # Machen wir sie zu einer privaten Methode. - # Definiere _evaluate_branch_task_worker(self, task_data, semaphore) - - # *** BEGINN PARALLELE VERARBEITUNG *** - with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_BRANCH_WORKERS) as executor: - # Submit Aufgaben an den Executor - # Passing semaphore to each worker task - future_to_task = {executor.submit(self._evaluate_branch_task_worker, task, openai_semaphore_branch): task for task in tasks_for_processing_batch} - - # Warte auf Ergebnisse und sammle sie - for future in concurrent.futures.as_completed(future_to_task): - task = future_to_task[future] - try: - result_data = future.result() # Ergebnis enthält {'row_num': ..., 'result': ..., 'error': ...} - results_list.append(result_data) - if result_data['error']: batch_error_count += 1 - except Exception as exc: - # Dies fängt Fehler auf Executor-Ebene ab (sollte selten sein, da Worker Fehler loggt) - row_num = task['row_num'] - err_msg = f"Generischer Fehler Branch Task Zeile {row_num}: {exc}" - logging.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 - - - # *** ENDE PARALLELE VERARBEITUNG *** - logging.info(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 = Config.VERSION - batch_sheet_updates = [] - # Sortiere Ergebnisse nach Zeilennummer für geordnetes Schreiben (optional) - 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'] - logging.debug(f" Zeile {row_num}: Ergebnis -> Branch='{result.get('branch')}', Consistency='{result.get('consistency')}', Justification='{result.get('justification', '')[:50]}...'") - batch_sheet_updates.extend([ - {'range': f'{branch_w_letter}{row_num}', 'values': [[result.get("branch", "Fehler")]]}, - {'range': f'{branch_x_letter}{row_num}', 'values': [[result.get("consistency", "Fehler")]]}, - {'range': f'{branch_y_letter}{row_num}', 'values': [[result.get("justification", "Fehler")]]}, - {'range': f'{ts_col_letter}{row_num}', 'values': [[current_timestamp]]}, # AO Timestamp setzen - # Version AP sollte auch von Batch Modi gesetzt werden. - {'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]} # Setze AP - ]) - - # Sende Updates für DIESEN Batch SOFORT - if batch_sheet_updates: - logging.info(f" Sende Sheet-Update für {len(results_list)} Zeilen ({len(batch_sheet_updates)} Zellen) für Batch {batch_start_row}-{batch_end_row}...") - success = self.sheet_handler.batch_update_cells(batch_sheet_updates) - if success: logging.info(f" Sheet-Update erfolgreich.") - else: logging.error(f" FEHLER beim Sheet-Update.") - else: logging.debug(f" Keine Sheet-Updates für Batch {batch_start_row}-{batch_end_row} vorbereitet.") - - tasks_for_processing_batch = [] # Batch leeren - logging.debug(f"--- Verarbeitungs-Batch {batch_start_row}-{batch_end_row} abgeschlossen ---") - # Kurze Pause NACHDEM ein Batch komplett verarbeitet und geschrieben wurde - logging.debug(" Warte nach Batch...") - time.sleep(Config.RETRY_DELAY) - - - logging.info(f"Branchen-Einschätzung Batch abgeschlossen. {processed_count} Tasks erstellt.") - - # --- Private Worker Methode für Branch Batch Parallelisierung --- - # Diese Methode gehört in die Klasse DataProcessor und wird vom Branch Batch Executor aufgerufen - def _evaluate_branch_task_worker(self, task_data, semaphore): - """Worker-Funktion für die parallele Branchenevaluation.""" - row_num = task_data['row_num'] - # evaluate_branche_chatgpt ist global und macht den OpenAI Call mit Retry - # Semaphor steuert die Anzahl gleichzeitiger OpenAI Calls - result = {"branch": "k.A. (Task Fehler)", "consistency": "error_task", "justification": "Fehler im Worker"}; error = None - try: - with semaphore: # Acquire the semaphore - # logging.debug(f" Task {row_num}: Semaphore erhalten.") # Zu laut - result = evaluate_branche_chatgpt( - task_data['crm_branche'], task_data['beschreibung'], - task_data['wiki_branche'], task_data['wiki_kategorien'], - task_data['website_summary'] - ) - # logging.debug(f" Task {row_num}: evaluate_branche_chatgpt beendet.") # Zu laut - except Exception as e: - error = f"Fehler bei Branchenevaluation Zeile {row_num}: {e}"; logging.error(error) - # Update result dictionary with error info if needed - result['justification'] = (result.get('justification', '') + f" Fehler: {error}")[:500] - result['consistency'] = 'error_task' - - return {"row_num": row_num, "result": result, "error": error} - - - # --- Dienstprogramm Methoden (Werden von run_user_interface aufgerufen) --- - # Diese Methoden führen eine spezifische Aufgabe aus und arbeiten oft über das gesamte Sheet - # oder eine gefilterte Menge. - - # process_serp_website_lookup Methode (früher process_serp_website_lookup_for_empty) - def process_serp_website_lookup(self, limit=None): # <<< Methode in DataProcessor - """ - Sucht fehlende Websites (Spalte D ist leer oder "k.A.") via SERP API - (Google Search) und trägt gefundene URLs in Spalte D ein. - - Args: - limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None. - """ - logging.info(f"Starte Modus: SERP API Website Lookup für leere Zellen in Spalte D. Limit: {limit if limit is not None else 'Unbegrenzt'}") - if not self.sheet_handler.load_data(): return logging.error("Fehler beim Laden der Daten.") - data_rows = self.sheet_handler.get_data() - header_rows = 5 - - rows_processed_count = 0 - updates = [] - - try: # Annahme: COLUMN_MAP ist global - website_col_idx = COLUMN_MAP["CRM Website"]; name_col_idx = COLUMN_MAP["CRM Name"] - website_col_letter = self.sheet_handler._get_col_letter(website_col_idx + 1) - # Optional: Timestamp AY für SerpAPI Wiki Suche, um hier nicht Website Suche immer wieder zu machen, wenn Wiki Suche für die Zeile schon fehlschlug. - # Das wird aber komplex. Belassen wir es simpel. - except KeyError as e: logging.critical(f"FEHLER: Benötigte Spalte '{e}' fehlt."); return - except Exception as e: logging.critical(f"FEHLER beim Holen der Spaltenbuchstaben: {e}"); return - - for i, row in enumerate(data_rows): # <= for-Schleife beginnt hier - row_num_in_sheet = i + header_rows + 1 - - if limit is not None and rows_processed_count >= limit: - logging.info(f"Limit ({limit}) für Website Lookup erreicht.") - break - - # Sicherstellen, dass die Zeile lang genug ist, um auf die benötigten Spalten zuzugreifen - max_needed_idx = max(website_col_idx, name_col_idx) - if len(row) <= max_needed_idx: - logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz).") - continue # continue gehört unter das if - - - current_website = row[website_col_idx] if len(row) > website_col_idx else "" - - if not current_website or str(current_website).strip().lower() == "k.a.": - company_name = row[name_col_idx] if len(row) > name_col_idx else "" - if not company_name or str(company_name).strip() == "": - logging.warning(f"Zeile {row_num_in_sheet}: Übersprungen (kein Firmenname).") - continue # continue gehört unter das if - - - logging.info(f"Zeile {row_num_in_sheet}: Suche Website für '{company_name}'...") - new_website = serp_website_lookup(company_name) # Globale Funktion mit Retry - rows_processed_count += 1 - - if new_website != "k.A.": - updates.append({'range': f'{website_col_letter}{row_num_in_sheet}', 'values': [[new_website]]}) - logging.info(f"Zeile {row_num_in_sheet}: Neue Website '{new_website}' gefunden.") - else: - logging.info(f"Zeile {row_num_in_sheet}: Keine Website gefunden.") - - time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.3) # Pause nach jedem SERP-Aufruf - - # <= Hier endet die for-Schleife. Die folgenden Blöcke müssen auf dieser Ebene (derselben wie for) eingerückt sein. - - if updates: # <= DIESER BLOCK muss auf derselben Ebene wie die for-Schleife beginnen (z.B. 8 Leerzeichen) - # <= Die folgenden Zeilen müssen EINE EBENE TIEFER als das "if updates:" eingerückt sein (z.B. 12 Leerzeichen). - logging.info(f"Sende Batch-Update für {len(updates)} Zellen ({rows_processed_count} Zeilen geprüft)...") # <<< Diese Zeile EINE EBENE TIEFER - success = self.sheet_handler.batch_update_cells(updates) # <<< Diese Zeile EINE EBENE TIEFER - if success: # <= Dieses if gehört zum if updates: Block. Es muss auf derselben Ebene wie die vorherigen eingerückten Zeilen beginnen (z.B. 12 Leerzeichen). - # <= Der folgende Code gehört zum if success: (z.B. 16 Leerzeichen) - logging.info(f"Batch-Update erfolgreich.") - else: # <= Dieses else gehört zum if success: (z.B. 12 Leerzeichen) - # <= Der folgende Code gehört zum else success: (z.B. 16 Leerzeichen) - logging.error(f"FEHLER beim Batch-Update.") - - else: # <= DIESER BLOCK gehört zum if updates: (Ebene 1). Muss auf derselben Ebene wie if updates: beginnen. (z.B. 8 Leerzeichen) - # <= Die folgende Zeile muss unter dem else eingerückt sein (z.B. 12 Leerzeichen). - logging.info("Keine fehlenden Websites gefunden oder keine Updates nötig.") - - # Diese Zeile gehört zur Methode, auf derselben Ebene wie das if updates: und das else: darüber. - # (z.B. 8 Leerzeichen, dieselbe Ebene wie die for-Schleife) - logging.info(f"Modus 'website_lookup' abgeschlossen. {rows_processed_count} Zeilen geprüft.") - - - - # process_find_wiki_serp Methode - def process_find_wiki_serp(self, limit=None, min_employees=500, min_umsatz=200): # <<< Methode in DataProcessor - """ - Sucht fehlende Wikipedia-URLs (Spalte M = k.A.) für Unternehmen mit - (Umsatz CRM > min_umsatz MIO € ODER Mitarbeiter CRM > min_employees) - über SerpAPI und 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). - Merkt sich in Spalte AY, wann die Suche durchgeführt wurde. - - Args: - limit (int, optional): Maximale Anzahl zu prüfender 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. - """ - logging.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})..."); - if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten."); - - # Korrigierte Zeilen - keine Anweisung nach dem Semikolon - header_rows = 5 # Zuweisung auf eigener Zeile - if not self.sheet_handler.get_all_data_with_headers() or len(self.sheet_handler.get_all_data_with_headers()) <= header_rows: # if-Statement auf neuer Zeile (nutze get_all_data_with_headers) - # Die folgenden Zeilen müssen unter dem if eingerückt sein - logging.warning("Keine Daten gefunden oder nur Header."); # Geändert von "Keine Daten gefunden.", da len <= header_rows - return # continue oder return je nach Logik, hier return - - # Diese Zeilen gehören zum normalen Fluss der Methode, nach dem if-Block - all_data = self.sheet_handler.get_all_data_with_headers(); - data_rows = all_data[header_rows:]; - col_indices = {}; - required_keys = [ "ReEval Flag", "CRM Anzahl Mitarbeiter", "CRM Umsatz", "Wiki URL", "CRM Name", "CRM Website", "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", "Wikipedia Timestamp", "Timestamp letzte Prüfung", "Version", "Wiki Verif. Timestamp", "SerpAPI Wiki Search Timestamp" ]; - - # KORRIGIERTE ZEILEN: Trenne Zuweisung und For-Schleife auf separate Zeilen - all_keys_found = True # <- Zuweisung auf eigener Zeile (Einrückung wie col_indices) - # Die For-Schleife beginnt auf der nächsten Zeile und ist eingerückt - for key in required_keys: # <- For-Schleife beginnt hier, eingerückt unter all_keys_found - # Die folgenden Zeilen gehören zum Körper der For-Schleife und sind weiter eingerückt - idx = COLUMN_MAP.get(key); - col_indices[key] = idx; - if idx is None: # <- If innerhalb der For-Schleife - # <- Code unter dem If, weiter eingerückt - logging.critical(f"FEHLER: Benötigter Spaltenschlüssel '{key}' nicht in COLUMN_MAP gefunden! Modus abgebrochen."); - all_keys_found = False # <- Zuweisung unter dem If - - - # Hier endet die For-Schleife. Die folgenden Zeilen sind auf derselben Ebene wie die For-Schleife - if not all_keys_found: - return; # Abbruch, wenn nicht alle Schlüssel gefunden - - - # Diese Zeilen gehören zum normalen Fluss der Methode, nach dem if not all_keys_found Block - col_letters = {key: self.sheet_handler._get_col_letter(idx + 1) for key, idx in col_indices.items()}; - all_sheet_updates = []; processed_rows_count = 0; found_urls_count = 0; skipped_timestamp_ay_count = 0; skipped_size_count = 0; skipped_m_filled_count = 0; - now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S"); - - - for idx, row in enumerate(data_rows): - row_num_in_sheet = idx + header_rows + 1 - - if limit is not None and processed_rows_count >= limit: - logging.info(f"Limit ({limit}) für Suche erreicht.") - break - - # KORRIGIERTE ZEILEN: Trenne Zuweisung und If-Statement - max_needed_idx = max(col_indices.values()) # Zuweisung auf eigener Zeile (z.B. 12 Leerzeichen eingerückt) - if len(row) <= max_needed_idx: # If-Statement auf neuer Zeile (dieselbe Einrückung wie die Zuweisung darüber, z.B. 12 Leerzeichen) - # Die folgenden Zeilen gehören zum If-Block und müssen weiter eingerückt sein (z.B. 16 Leerzeichen) - logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz).") - continue # continue gehört unter das if - - # Diese Zeilen gehören zum normalen Fluss der For-Schleife (z.B. 12 Leerzeichen) - ts_ay_val = row[col_indices["SerpAPI Wiki Search Timestamp"]]; - if ts_ay_val and ts_ay_val.strip(): - skipped_timestamp_ay_count += 1 - continue # continue gehört zum if darüber - - m_value = row[col_indices["Wiki URL"]]; - if m_value and str(m_value).strip().lower() not in ["k.a.", "kein artikel gefunden"]: - skipped_m_filled_count += 1 - continue # continue gehört zum if darüber - m_value = row[col_indices["Wiki URL"]]; # Zuweisung auf eigener Zeile (z.B. 12 Leerzeichen) - if m_value and str(m_value).strip().lower() not in ["k.a.", "kein artikel gefunden"]: # If-Statement auf neuer Zeile (dieselbe Einrückung wie die Zuweisung darüber, z.B. 12 Leerzeichen) - # Die folgenden Zeilen gehören zum If-Block und müssen weiter eingerückt sein (z.B. 16 Leerzeichen) - skipped_m_filled_count += 1; - continue; # continue gehört unter das if - - - # Diese Zeilen gehören zum normalen Fluss der For-Schleife (z.B. 12 Leerzeichen) - # ts_ay_val wurde schon oben verarbeitet. - # m_value wurde gerade verarbeitet, aber wir müssen sicherstellen, - # dass die Variablen für die nächste Bedingung (Umsatz/MA) korrekt sind, - # unabhängig davon, ob die Zeile gerade übersprungen wurde. - # Die Logik für Umsatz/MA Prüfung kommt hier, NACH der M_value Prüfung. - - # ... (Rest der Methode folgt, beginnend mit Umsatz/MA Prüfung) ... - umsatz_val_str = row[col_indices["CRM Umsatz"]]; - ma_val_str = row[col_indices["CRM Anzahl Mitarbeiter"]]; - - umsatz_val_mio = get_numeric_filter_value(umsatz_val_str, is_umsatz=True); # Globale Funktion - ma_val_num = get_numeric_filter_value(ma_val_str, is_umsatz=False); # Globale Funktion - - if not (umsatz_val_mio > min_umsatz or ma_val_num > min_employees): - logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Größe nicht ausreichend. Umsatz (Mio): {umsatz_val_mio:.2f}, MA: {ma_val_num}). Schwellen: Umsatz > {min_umsatz} Mio, MA > {min_employees}."); - skipped_size_count += 1; - continue; - - - company_name = row[col_indices["CRM Name"]]; # Zuweisung auf eigener Zeile (z.B. 12 Leerzeichen) - # Das if-Statement beginnt auf einer neuen Zeile und muss eingerückt sein - if not company_name or str(company_name).strip() == "": # If-Statement auf neuer Zeile (z.B. 12 Leerzeichen) - # Die folgenden Zeilen gehören zum If-Block und müssen weiter eingerückt sein (z.B. 16 Leerzeichen) - logging.warning(f"Zeile {row_num_in_sheet}: Übersprungen, kein Firmenname."); - # Die nächsten beiden Anweisungen gehören auch ZUM IF BLOCK (wenn kein Firmenname) - ay_col_letter = col_letters["SerpAPI Wiki Search Timestamp"]; - all_sheet_updates.append({'range': f'{ay_col_letter}{row_num_in_sheet}', 'values': [[now_timestamp_str]]}); - # Und dann soll die Schleife übersprungen werden - continue # continue gehört zum if darüber - - # Diese Zeilen gehören zum normalen Fluss der For-Schleife (z.B. 12 Leerzeichen) - # Sie werden nur ausgeführt, wenn das If (kein Firmenname) NICHT True war. - logging.info(f"Zeile {row_num_in_sheet}: Suche Wiki-URL für '{company_name}' (Umsatz (Mio): {umsatz_val_mio:.2f}, MA: {ma_val_num})..."); - processed_rows_count += 1; - - website_url = row[col_indices["CRM Website"]] if col_indices["CRM Website"] is not None and len(row) > col_indices["CRM Website"] else None - wiki_url_found = serp_wikipedia_lookup(company_name, website=website_url) # Globale Funktion mit Retry - - ay_col_letter = col_letters["SerpAPI Wiki Search Timestamp"]; all_sheet_updates.append({'range': f'{ay_col_letter}{row_num_in_sheet}', 'values': [[now_timestamp_str]]}) - - if wiki_url_found and wiki_url_found.strip() and wiki_url_found != "k.A.": - logging.info(f" -> URL gefunden: {wiki_url_found}. Bereite Update vor.") - found_urls_count += 1; m_l = col_letters["Wiki URL"]; a_l = col_letters["ReEval Flag"]; n_idx = col_indices["Wiki Absatz"]; v_idx = col_indices["Begründung bei Abweichung"]; n_l=self.sheet_handler._get_col_letter(n_idx+1); v_l=self.sheet_handler._get_col_letter(v_idx+1); an_l = col_letters["Wikipedia Timestamp"]; ao_l = col_indices["Timestamp letzte Prüfung"]; ap_l = col_letters["Version"]; ax_l = col_letters["Wiki Verif. Timestamp"] - # Korrektur AO_l war Index, muss Buchstabe sein - ao_idx = COLUMN_MAP.get("Timestamp letzte Prüfung"); ao_l=self.sheet_handler._get_col_letter(ao_idx+1); - - all_sheet_updates.extend([ - {'range': f'{m_l}{row_num_in_sheet}', 'values': [[wiki_url_found]]}, {'range': f'{a_l}{row_num_in_sheet}', 'values': [['x']]}, - {'range': f'{n_l}{row_num_in_sheet}:{v_l}{row_num_in_sheet}', 'values': [[''] * (v_idx - n_idx + 1)]}, - {'range': f'{an_l}{row_num_in_sheet}', 'values': [['']]}, {'range': f'{ao_l}{row_num_in_sheet}', 'values': [['']]}, - {'range': f'{ap_l}{row_num_in_sheet}', 'values': [['']]}, {'range': f'{ax_l}{row_num_in_sheet}', 'values': [['']]} - ]) - else: logging.info(f" -> Keine Wiki-URL via SerpAPI gefunden.") - time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.3) - - if all_sheet_updates: - logging.info(f"Sende Batch-Update für {len(all_sheet_updates)} Zellen ({processed_rows_count} Zeilen geprüft)...") - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: logging.info(f"Sheet-Update erfolgreich.") - else: logging.error(f"FEHLER beim Batch-Update.") - else: logging.info("Keine Updates nötig.") - logging.info(f"Modus 'find_wiki_serp' abgeschlossen. {processed_rows_count} Tasks erstellt, {found_urls_count} URLs gefunden, {skipped_timestamp_ay_count} AY gesetzt, {skipped_size_count} Größe, {skipped_m_filled_count} M gefüllt.") - - # process_wiki_updates_from_chatgpt Methode - def process_wiki_updates_from_chatgpt(self, row_limit=None): # <<< Methode in DataProcessor - """ - Identifiziert Zeilen (S nicht OK/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 TS/Version, setzt ReEval-Flag A. - - 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 row_limit Zeilen. - """ - logging.info( - f"Starte Modus: Wiki-Updates (URL-Validierung & Löschen ungültiger Vorschläge). " - f"Limit: {row_limit if row_limit is not None else 'Unbegrenzt'}" - ) - if not self.sheet_handler.load_data(): - return logging.error("FEHLER beim Laden der Daten.") - - # Header-Zeilen überspringen - header_rows = 5 - if (not self.sheet_handler.get_all_data_with_headers() - or len(self.sheet_handler.get_all_data_with_headers()) <= header_rows): - logging.warning("Keine Daten gefunden oder nur Header.") - return - - # Daten und Spalten-Indizes vorbereiten - all_data = self.sheet_handler.get_all_data_with_headers() - data_rows = all_data[header_rows:] - required_keys = [ - "Chat Wiki Konsistenzprüfung", "Chat Vorschlag Wiki Artikel", "Wiki URL", - "Wikipedia Timestamp", "Wiki Verif. Timestamp", "Timestamp letzte Prüfung", - "Version", "ReEval Flag", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", - "Wiki Mitarbeiter", "Wiki Kategorien", "Begründung bei Abweichung" - ] - col_indices = {} - - all_keys_found = True - for key in required_keys: - idx = COLUMN_MAP.get(key) - col_indices[key] = idx - if idx is None: - logging.critical(f"FEHLER: Schlüssel '{key}' nicht in COLUMN_MAP gefunden! Modus abgebrochen.") - all_keys_found = False - - if not all_keys_found: - return - - all_sheet_updates = [] - processed_rows_count = 0 - updated_url_count = 0 - cleared_suggestion_count = 0 - - # Durch alle Datenzeilen iterieren - for idx, row in enumerate(data_rows): - row_num_in_sheet = idx + header_rows + 1 - if row_limit is not None and processed_rows_count >= row_limit: - logging.info(f"Limit ({row_limit}) erreicht.") - break - - # Zeile übersprungen, wenn zu kurz - max_needed_idx = max(col_indices.values()) - if len(row) <= max_needed_idx: - logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz).") - continue - - # Zellenwerte holen - konsistenz_s = self._get_cell_value(row, "Chat Wiki Konsistenzprüfung").strip() - vorschlag_u = self._get_cell_value(row, "Chat Vorschlag Wiki Artikel").strip() - url_m = self._get_cell_value(row, "Wiki URL").strip() - - konsistenz_s_upper = konsistenz_s.upper() - is_candidate_for_check = ( - bool(konsistenz_s_upper) - and konsistenz_s_upper not in ["OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)", "?"] - ) - if is_candidate_for_check or (konsistenz_s_upper == "?" and not vorschlag_u): - logging.debug( - f"Zeile {row_num_in_sheet}: Kandidat für Wiki-Update-Prüfung " - f"(Status S = '{konsistenz_s}'). Vorschlag U = '{vorschlag_u}'" - ) - processed_rows_count += 1 - - # Update-Kandidat prüfen - is_update_candidate = False - new_url = "" - condition2_u_is_wiki_url = ( - vorschlag_u.lower().startswith(("http://", "https://")) - and "wikipedia.org/wiki/" in vorschlag_u.lower() - ) - - if condition2_u_is_wiki_url: - new_url = vorschlag_u - condition3_u_differs_m = ( - simple_normalize_url(new_url) != simple_normalize_url(url_m) - ) - - if condition3_u_differs_m: - logging.debug(f" -> Prüfe Validität der neuen URL: {new_url}...") - try: - condition4_u_is_valid = is_valid_wikipedia_article_url(new_url) - except Exception as e_valid: - logging.error( - f" -> Fehler bei Validierung der URL '{new_url}': {e_valid}. " - "Behandle als ungültig." - ) - condition4_u_is_valid = False - - if condition4_u_is_valid: - is_update_candidate = True - logging.debug(f" -> URL '{new_url}' ist ein valider Artikel.") - else: - logging.debug(f" -> URL '{new_url}' ist KEIN valider Artikel laut API Check.") - else: - logging.debug(f" -> Vorschlag U ist identisch mit URL M.") - else: - logging.debug(f" -> Vorschlag U ist keine Wikipedia URL ('{vorschlag_u}').") - - # Je nach Ergebnis Update oder Cleanup anlegen - if is_update_candidate: - logging.info( - f"Zeile {row_num_in_sheet}: Update-Kandidat VALIDIERUNG ERFOLGREICH. " - f"Setze ReEval-Flag 'x' und bereite Updates vor für URL: {new_url}" - ) - updated_url_count += 1 - - # Spaltenbuchstaben bestimmen - m_l = self.sheet_handler._get_col_letter(col_indices["Wiki URL"] + 1) - s_l = self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"] + 1) - u_l = self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"] + 1) - a_l = self.sheet_handler._get_col_letter(col_indices["ReEval Flag"] + 1) - - n_idx = col_indices["Wiki Absatz"] - v_idx = col_indices["Begründung bei Abweichung"] - n_l = self.sheet_handler._get_col_letter(n_idx + 1) - v_l = self.sheet_handler._get_col_letter(v_idx + 1) - - an_l = self.sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"] + 1) - ax_l = self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"]+ 1) - ao_l = self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Prüfung"]+1) - ap_l = self.sheet_handler._get_col_letter(col_indices["Version"] + 1) - - all_sheet_updates.extend([ - {'range': f'{m_l}{row_num_in_sheet}', 'values': [[ new_url ]]}, - {'range': f'{s_l}{row_num_in_sheet}', 'values': [[ "X (URL Copied)" ]]}, - {'range': f'{u_l}{row_num_in_sheet}', 'values': [[ "URL übernommen" ]]}, - { - 'range': f'{n_l}{row_num_in_sheet}:{v_l}{row_num_in_sheet}', - 'values': [[''] * (v_idx - n_idx + 1)] - }, - {'range': f'{an_l}{row_num_in_sheet}', 'values': [['']]}, - {'range': f'{ax_l}{row_num_in_sheet}', 'values': [['']]}, - {'range': f'{ao_l}{row_num_in_sheet}', 'values': [['']]}, - {'range': f'{ap_l}{row_num_in_sheet}', 'values': [['']]}, - {'range': f'{a_l}{row_num_in_sheet}', 'values': [[ "x" ]]}, - ]) - else: - logging.info( - f"Zeile {row_num_in_sheet}: Vorschlag U ('{vorschlag_u}') ist ungültig/identisch. " - "Lösche U und setze Status S." - ) - cleared_suggestion_count += 1 - - s_l = self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"] + 1) - u_l = self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"] + 1) - - all_sheet_updates.extend([ - {'range': f'{s_l}{row_num_in_sheet}', 'values': [[ "X (Invalid Suggestion)" ]]}, - {'range': f'{u_l}{row_num_in_sheet}', 'values': [[ "" ]]}, - ]) - - # Nach der Schleife: Batch-Update senden, falls nötig - if all_sheet_updates: - logging.info( - f"Sende Batch-Update für {processed_rows_count} geprüfte Zeilen " - f"({len(all_sheet_updates)} Zellen)..." - ) - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: - logging.info("Sheet-Update für Wiki-Updates erfolgreich.") else: - logging.error("FEHLER beim Sheet-Update für Wiki-Updates.") + 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 + + + # --- 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: - logging.info("Keine Zeilen gefunden, die eine Wiki-URL-Korrektur oder Vorschlagsbereinigung benötigen.") - - logging.info( - f"Wiki-Updates abgeschlossen. {processed_rows_count} Zeilen geprüft. " - f"{updated_url_count} URLs kopiert & für ReEval markiert, " - f"{cleared_suggestion_count} ungültige Vorschläge gelöscht/markiert." - ) + # 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.") - # --- Private Helfer für Timestamp/Status Checks --- - # Diese werden von _process_single_row aufgerufen. - # Stellen Sie sicher, dass diese Methoden IN der Klasse DataProcessor sind. + # 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) - def _get_cell_value(self, row_data, key): - """Lokale Hilfsfunktion zum sicheren Zugriff auf Zellwerte innerhalb von Methoden, die row_data als Parameter erhalten.""" - idx = COLUMN_MAP.get(key) # Annahme: COLUMN_MAP ist global - if idx is not None and len(row_data) > idx: - return row_data[idx] if row_data[idx] is not None else '' - return "" + self.logger.info(f"--- Verarbeitung für Zeile {row_num_in_sheet} abgeschlossen ---") - def _is_step_processing_needed(self, row_data, step_key, force_reeval, related_inputs_updated=False): - """ - Prüft, ob ein spezifischer Verarbeitungsschritt für diese Zeile ausgeführt werden soll, - basierend auf Timestamp, force_reeval und ob Eingangsdaten aktualisiert wurden. - """ - if force_reeval: return True - if step_key is None: return related_inputs_updated # Ohne Timestamp, nur wenn Inputs neu - - timestamp_col_index = COLUMN_MAP.get(step_key) - if timestamp_col_index is None: logging.error(f" -> Step Check Fehler: Timestamp Schlüssel '{step_key}' nicht in COLUMN_MAP gefunden."); return False - - ts_value = row_data[timestamp_col_index] if len(row_data) > timestamp_col_index else "" - ts_is_set = bool(str(ts_value).strip()) - - needs_processing = not ts_is_set or related_inputs_updated - return needs_processing - - # _is_wiki_search_extract_needed Helfer (spezifisch für AN & S='X (URL Copied)') - def _is_wiki_search_extract_needed(self, row_data, force_reeval): # related_inputs_updated hier nicht relevant - """Prüft, ob Wikipedia Search/Extraction nötig ist (AN Timestamp oder S='X (URL Copied)' oder force_reeval).""" - # Wiki Search/Extraction ist nötig, wenn: force_reeval ODER AN fehlt ODER S='X (URL Copied)' - # Nutze private Helfermethode _get_cell_value - wiki_ts_an_missing = not self._get_cell_value(row_data, "Wikipedia Timestamp").strip() - status_s_indicates_reparse = self._get_cell_value(row_data, "Chat Wiki Konsistenzprüfung").strip().upper() == "X (URL COPIED)" - - return force_reeval or wiki_ts_an_missing or status_s_indicates_reparse - - # _is_wiki_verification_needed Helfer (spezifisch für AX) - def _is_wiki_verification_needed(self, row_data, force_reeval, wiki_data_updated_in_this_run): # Abhängig von wiki_data_updated_in_this_run - """Prüft, ob Wikipedia Verifizierung nötig ist (AX Timestamp oder Wiki Daten aktualisiert).""" - # Wiki Verifizierung (S-U, AX) ist nötig, wenn: force_reeval ODER AX fehlt ODER Wiki Daten gerade aktualisiert wurden - return self._is_step_processing_needed(row_data, "Wiki Verif. Timestamp", force_reeval, wiki_data_updated_in_this_run) - - # _is_branch_evaluation_needed Helfer (spezifisch für AO) - def _is_branch_evaluation_needed(self, row_data, force_reeval, inputs_updated_in_this_run): # Abhängig von wiki_data_updated_in_this_run ODER website_data_updated_in_this_run - """Prüft, ob Branch Evaluation nötig ist (AO Timestamp oder Inputs (Wiki/Web) aktualisiert).""" - # Branch Evaluation ist nötig, wenn: force_reeval ODER AO fehlt ODER Inputs (Wiki/Web) wurden aktualisiert - return self._is_step_processing_needed(row_data, "Timestamp letzte Prüfung", force_reeval, inputs_updated_in_this_run) - - # Fügen Sie hier weitere _is_xxx_needed Methoden für andere Schritte hinzu (FSM, MA, Umsatz Schätzung) - # Diese prüfen jeweils ihren spezifischen Trigger (eigenen Timestamp ODER Inputs). - # Z.B. FSM hat keinen eigenen TS, wird getriggert wenn Branch Eval inputs (Wiki/Web) aktualisiert ODER Branch Eval selbst neu gemacht wurde. - # Implementierung könnte so aussehen: - # def _is_fsm_evaluation_needed(self, row_data, force_reeval, inputs_updated_in_this_run): - # # FSM hat keinen eigenen Timestamp, AO wird für Branch Eval verwendet - # # Wir triggern FSM, wenn Branch Eval triggern würde ODER Inputs aktualisiert wurden - # branch_eval_trigger = self._is_step_processing_needed(row_data, "Timestamp letzte Prüfung", force_reeval, inputs_updated_in_this_run) - # return branch_eval_trigger # FSM wird getriggert, wenn der Branch Step getriggert wird. (Vereinfachung) + # --- Ende der _process_single_row Methode --- - # --- Methode für den Re-Eval Modus --- - # Diese Methode gehört in die Klasse DataProcessor + # --- 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, @@ -3744,1777 +2582,2373 @@ class DataProcessor: 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 die Gruppe Wiki-Schritte ausgeführt werden?. Defaults to True. - process_chatgpt_steps (bool, optional): Soll die Gruppe ChatGPT-Schritte ausgeführt werden?. Defaults to True. - process_website_steps (bool, optional): Soll die Gruppe Website-Schritte ausgeführt werden?. 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. """ - logging.info(f"Starte Re-Evaluierungsmodus (Spalte A = 'x'). Max. Zeilen: {row_limit if row_limit is not None else 'Unbegrenzt'}") + 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") - if process_chatgpt_steps: selected_steps_log.append("ChatGPT") - if process_website_steps: selected_steps_log.append("Website") - logging.info(f"Ausgewählte Schritte für Re-Eval: {', '.join(selected_steps_log) if selected_steps_log else 'Keine ausgewählt!'} (force_reeval=True)") + 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 - if not self.sheet_handler.load_data(): return logging.error("Fehler beim Laden der Daten für Re-Evaluation.") all_data = self.sheet_handler.get_all_data_with_headers() - header_rows = 5 - if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten für Re-Evaluation gefunden.") + 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 - reeval_col_idx = COLUMN_MAP.get("ReEval Flag") # Annahme: COLUMN_MAP ist global - if reeval_col_idx is None: return logging.critical("FEHLER: 'ReEval Flag' Spaltenindex nicht in COLUMN_MAP gefunden.") + # 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 - # Sicherstellen, dass die Zeile lang genug ist für Spalte A - if len(row_data) > reeval_col_idx and str(row_data[reeval_col_idx]).strip().lower() == "x": + 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) - logging.info(f"{found_count} Zeilen mit ReEval-Flag 'x' gefunden.") - if found_count == 0: return logging.info("Keine Zeilen zur Re-Evaluation markiert.") - + 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 = [] - rows_actually_processed = [] + 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: - if limit is not None and processed_count >= limit: - logging.info(f"Zeilenlimit ({limit}) für Re-Evaluation erreicht. Breche weitere Verarbeitung ab.") + # Ü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'] + 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 mit force_reeval=True und den ausgewählten Flags auf + # 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, - process_wiki = process_wiki_steps, # <<< ÜBERGIBT DIE STEUERUNG - process_chatgpt = process_chatgpt_steps, # <<< ÜBERGIBT DIE STEUERUNG - process_website = process_website_steps, # <<< ÜBERGIBT DIE STEUERUNG - force_reeval = True # <<< BLEIBT HIER TRUE FÜR RE-EVAL MODUS + steps_to_run = steps_to_run_set, # <-- Übergibt die ausgewählten Schritte + force_reeval = True # <-- Erzwingt Re-Evaluation unabhängig von Timestamps ) - processed_count += 1 - rows_actually_processed.append(row_num) + 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: logging.error(f"Fehler: Konnte Spaltenbuchstaben für 'ReEval Flag' ({reeval_col_idx+1}) nicht ermitteln.") + 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: logging.exception(f"FEHLER bei Re-Evaluation von Zeile {row_num}: {e_proc}") + 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: - logging.info(f"Lösche ReEval-Flags für {len(updates_clear_flag)} erfolgreich verarbeitete Zeilen ({rows_actually_processed})...") + 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: logging.info("ReEval-Flags erfolgreich gelöscht.") - else: logging.error("FEHLER beim Löschen der ReEval-Flags.") - logging.info(f"Re-Evaluierung abgeschlossen. {processed_count} Zeilen verarbeitet (Limit war: {limit}, Gefunden: {found_count}).") + 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}).") - # --- Methode für sequenzielle Verarbeitung (full_run) --- - # Diese Methode gehört in die Klasse DataProcessor - def process_sequential(self, start_sheet_row, num_to_process, - process_wiki=True, process_chatgpt=True, process_website=True): + # --- 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-Zeile - sequenziell, eine nach der anderen, unter Verwendung von _process_single_row. - _process_single_row prüft dabei die Timestamps/Status (force_reeval=False). + 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 Startzeilennummer im Sheet. + 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 (bool, optional): Soll die Gruppe Wiki-Schritte ausgeführt werden?. Defaults to True. - process_chatgpt (bool, optional): Soll die Gruppe ChatGPT-Schritte ausgeführt werden?. Defaults to True. - process_website (bool, optional): Soll die Gruppe Website-Schritte ausgeführt werden?. 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 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 - logging.info(f"Starte sequenzielle Verarbeitung von {num_to_process} Zeilen ab Sheet-Zeile {start_sheet_row}...") - groups_to_attempt_log = [] - if process_website: groups_to_attempt_log.append("Website") - if process_wiki: groups_to_attempt_log.append("Wiki") - if process_chatgpt: groups_to_attempt_log.append("ChatGPT") - logging.info(f"Ausgewählte Schritte: {', '.join(groups_to_attempt_log) if groups_to_attempt_log else 'Keine ausgewählt!'} (Standard-Timestamp/Status-Logik)") + # 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 !!!") - if not self.sheet_handler.load_data(): logging.error("Fehler beim Laden der Daten für sequenzielle Verarbeitung."); return + # 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) - if start_sheet_row > total_sheet_rows or start_sheet_row <= header_rows: - logging.warning(f"Start-Sheet-Zeile {start_sheet_row} liegt außerhalb des gültigen Datenbereichs ({header_rows+1} bis {total_sheet_rows}). Keine Verarbeitung.") + # 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 - end_sheet_row_inclusive = min(start_sheet_row + num_to_process - 1, total_sheet_rows) - logging.info(f"Sequenzielle Verarbeitung geplant für Sheet-Zeilen {start_sheet_row} bis {end_sheet_row_inclusive}.") - if start_sheet_row > end_sheet_row_inclusive: logging.warning("Start nach Ende (berechnet nach Limit). Keine Verarbeitung."); 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 - for i in range(start_sheet_row, end_sheet_row_inclusive + 1): - row_num_in_sheet = i - row_data = all_data[i - 1] + # 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, - process_wiki = process_wiki, # <<< ÜBERGIBT DIE STEUERUNG - process_chatgpt = process_chatgpt, # <<< ÜBERGIBT DIE STEUERUNG - process_website = process_website, # <<< ÜBERGIBT DIE STEUERUNG - force_reeval = False # <<< WICHTIG: Standard-Timestamp/Status-Logik + 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 - except Exception as e_proc: logging.exception(f"FEHLER bei sequenzieller Verarbeitung von Zeile {row_num_in_sheet}: {e_proc}") + processed_count += 1 # Zählen, wenn _process_single_row erfolgreich aufgerufen wurde (unabhängig von internen Überspringungen) - logging.info(f"Sequenzielle Verarbeitung abgeschlossen. {processed_count} Zeilen bearbeitet im Bereich [{start_sheet_row}, {end_sheet_row_inclusive}].") + 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.") - # --- Methode zum Prozessieren von Zeilen nach Kriterien (NEU) --- - # Diese Methode gehört in die Klasse DataProcessor - def process_rows_matching_criteria(self, criteria_func, limit=None, - process_wiki=True, process_chatgpt=True, process_website=True, - force_step_reeval=False): + # --- 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): """ - Sucht Zeilen im Sheet, die ein gegebenes Kriterium erfüllen (definiert durch criteria_func). - Verarbeitet eine begrenzte Anzahl dieser passenden Zeilen unter Verwendung von _process_single_row. + Verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen über OpenAI. + Sammelt die Ergebnisse und gibt sie zurück. Aktualisiert NICHT das Sheet direkt. Args: - criteria_func (callable): Eine Funktion, die eine Zeile (list) nimmt und True zurückgibt, wenn das Kriterium erfüllt ist. - limit (int, optional): Maximale Anzahl passender Zeilen, die verarbeitet werden sollen. Defaults to None (alle passenden). - process_wiki (bool, optional): Soll die Gruppe Wiki-Schritte ausgeführt werden?. Defaults to True. - process_chatgpt (bool, optional): Soll die Gruppe ChatGPT-Schritte ausgeführt werden?. Defaults to True. - process_website (bool, optional): Soll die Gruppe Website-Schritte ausgeführt werden?. Defaults to True. - force_step_reeval (bool, optional): Bestimmt, ob _process_single_row mit force_reeval=True aufgerufen wird (ignoriert Timestamps für ausgewählte Schritte). Defaults to False. + 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. """ - logging.info(f"Starte Verarbeitung von Zeilen nach Kriterien. Limit: {limit if limit is not None else 'Unbegrenzt'}") - logging.info(f"Verwendetes Kriterium: {criteria_func.__name__}") - groups_to_attempt_log = [] - if process_website: groups_to_attempt_log.append("Website") - if process_wiki: groups_to_attempt_log.append("Wiki") - if process_chatgpt: groups_to_attempt_log.append("ChatGPT") - logging.info(f"Ausgewählte Schritte: {', '.join(groups_to_attempt_log) if groups_to_attempt_log else 'Keine ausgewählt!'}") - logging.info(f"force_reeval für Schritte: {force_step_reeval}") + 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']})...") - if not self.sheet_handler.load_data(): logging.error("Fehler beim Laden der Daten für kriterienbasierte Verarbeitung."); return - all_data = self.sheet_handler.get_all_data_with_headers(); header_rows = 5 - if not all_data or len(all_data) <= header_rows: logging.warning("Keine Daten für kriterienbasierte Verarbeitung gefunden."); return - - rows_to_process = [] - logging.info("Suche nach Zeilen, die dem Kriterium entsprechen...") - 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 - - try: - if criteria_func(row_data): # Nutze die globale Kriterien-Funktion - rows_to_process.append({'row_num': row_num_in_sheet, 'data': row_data}) - except Exception as e_crit: logging.error(f"FEHLER beim Prüfen des Kriteriums für Zeile {row_num_in_sheet}: {e_crit}"); - - found_count = len(rows_to_process) - logging.info(f"{found_count} Zeilen entsprechen dem Kriterium '{criteria_func.__name__}'.") - if found_count == 0: logging.info("Keine Zeilen gefunden, die dem Kriterium entsprechen."); return - - processed_count = 0 - for task in rows_to_process: - if limit is not None and processed_count >= limit: - logging.info(f"Limit ({limit}) für kriterienbasierte Verarbeitung erreicht. Breche weitere Verarbeitung ab.") - break - - row_num = task['row_num']; row_data = task['data'] - try: - self._process_single_row( - row_num_in_sheet = row_num, - row_data = row_data, - process_wiki = process_wiki, - process_chatgpt = process_chatgpt, - process_website = process_website, - force_reeval = force_step_reeval - ) - processed_count += 1 - - except Exception as e_proc: logging.exception(f"FEHLER bei Verarbeitung einer Kriterium-Zeile ({row_num}): {e_proc}") - - logging.info(f"Kriterienbasierte Verarbeitung abgeschlossen. {processed_count} von {found_count} gefundenen Zeilen bearbeitet (Limit war: {limit}).") - - - # --- Batch-Verarbeitung Methoden (Werden von run_batch_dispatcher aufgerufen) --- - # Diese Methoden führen eine spezifische Aufgabe für einen Batch aus, basierend auf einem Timestamp. - # Sie rufen NICHT _process_single_row auf. - - def process_verification_batch(self, limit=None): - """ - Batch-Prozess NUR für Wikipedia-Verifizierung (Spalten S-U, AX). - Findet Startzeile ab erster Zelle mit leerem AX. - """ - logging.info( - f"Starte Wikipedia-Verifizierungs-Batch. Limit: {limit if limit is not None else 'Unbegrenzt'}" + # --- 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 : \n\n" + "Mögliche Antworten:\n" + "- 'OK' (wenn der Artikel gut passt)\n" + "- 'X | Alternativer Artikel: | Begründung: ' (wenn der Artikel nicht passt, aber ein besserer gefunden wurde)\n" + "- 'X | Kein passender Artikel gefunden | 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" ) - if not self.sheet_handler.load_data(): - return logging.error("FEHLER beim Laden der Daten.") - all_data = self.sheet_handler.get_all_data_with_headers() - header_rows = 5 - if not all_data or len(all_data) <= header_rows: - return logging.warning("Keine Daten gefunden.") + # 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.') - # Schlüssel holen und prüfen - timestamp_col_key = "Wiki Verif. Timestamp" - timestamp_col_index = COLUMN_MAP.get(timestamp_col_key) - if timestamp_col_index is None: - return logging.critical(f"FEHLER: Schlüssel '{timestamp_col_key}' fehlt.") - ts_col_letter = self.sheet_handler._get_col_letter(timestamp_col_index + 1) + 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 - # Erste Zeile finden, in der AX leer ist - start_data_index = self.sheet_handler.get_start_row_index( - check_column_key=timestamp_col_key, - min_sheet_row=header_rows + 1 - ) - if start_data_index == -1: - return logging.error(f"FEHLER bei Startzeilensuche auf Spalte '{timestamp_col_key}'.") - if start_data_index >= len(self.sheet_handler.get_data()): - logging.info("Alle Zeilen mit Timestamp gefüllt. Nichts zu tun.") - return + aggregated_prompt += "--------------------\nBitte nur die 'Eintrag X: Antwort'-Zeilen ausgeben." - # Bereich festlegen - start_sheet_row = start_data_index + header_rows + 1 + # 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 :" 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) - end_sheet_row = total_sheet_rows - if limit is not None and limit >= 0: - end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows) - if limit == 0: - logging.info("Limit 0.") - return - if start_sheet_row > end_sheet_row: - logging.warning("Start nach Ende (Limit).") - return + # 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 - logging.info( - f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} " - "für Wiki-Verifizierung (Batch)." - ) + self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") - batch_size = Config.BATCH_SIZE - current_batch = [] - current_row_numbers = [] - processed_count = 0 + 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 - row = all_data[row_index_in_list] + 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 - company_name = self._get_cell_value(row, "CRM Name") - crm_desc = self._get_cell_value(row, "CRM Beschreibung") - wiki_url = self._get_cell_value(row, "Wiki URL") - wiki_paragraph = self._get_cell_value(row, "Wiki Absatz") - wiki_categories = self._get_cell_value(row, "Wiki Kategorien") + row = all_data[row_index_in_list] - if wiki_url != 'k.A.' or wiki_paragraph != 'k.A.' or wiki_categories != 'k.A.': - entry_text = ( - f"Eintrag {i}:\n" - f" Firmenname: {company_name}\n" - f" CRM-Beschreibung: {crm_desc[:200]}...\n" - f" Wikipedia-URL: {wiki_url}\n" - f" Wiki-Absatz: {wiki_paragraph[:200]}...\n" - f" Wiki-Kategorien: {wiki_categories[:200]}...\n" - "----\n" - ) - current_batch.append(entry_text) - current_row_numbers.append(i) - processed_count += 1 - - if len(current_batch) >= batch_size or i == end_sheet_row: - if current_batch: - try: - _process_batch(self.sheet_handler.sheet, - current_batch, - current_row_numbers) - wiki_ts_updates = [] - current_wiki_ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - for row_num in current_row_numbers: - wiki_ts_updates.append({ - 'range': f'{ts_col_letter}{row_num}', - 'values': [[current_wiki_ts]] - }) - if wiki_ts_updates: - success_ts = self.sheet_handler.batch_update_cells(wiki_ts_updates) - if success_ts: - logging.debug( - f"Wiki Verif. Timestamp {ts_col_letter} " - f"für Batch {current_row_numbers[0]}–" - f"{current_row_numbers[-1]} gesetzt." - ) - else: - logging.error( - "FEHLER beim Setzen des Wiki Verif. Timestamps." - ) - except Exception as e_batch: - logging.error( - f"FEHLER bei Verarbeitung von Batch " - f"{current_row_numbers[0]}–" - f"{current_row_numbers[-1]} in _process_batch: {e_batch}" - ) - finally: - time.sleep(Config.RETRY_DELAY) - current_batch = [] - current_row_numbers = [] - - logging.info( - f"Wikipedia-Verifizierungs-Batch abgeschlossen. " - f"{processed_count} Zeilen in Batches verarbeitet." - ) - - def process_website_batch(self, limit=None): - """ - Batch-Prozess NUR für Website-Scraping (Rohtext AR, Timestamp AT). - Findet Startzeile ab erster Zelle mit leerem AT. - """ - logging.info( - f"Starte Website-Scraping Batch. " - f"Limit: {limit if limit is not None else 'Unbegrenzt'}" - ) - if not self.sheet_handler.load_data(): - return logging.error("FEHLER beim Laden der Daten.") - - all_data = self.sheet_handler.get_all_data_with_headers() - header_rows = 5 - if not all_data or len(all_data) <= header_rows: - return logging.warning("Keine Daten gefunden.") - - # Spalten-Indizes holen - rohtext_col_key = "Website Rohtext" - rohtext_col_index = COLUMN_MAP.get(rohtext_col_key) - website_col_idx = COLUMN_MAP.get("CRM Website") - version_col_idx = COLUMN_MAP.get("Version") - timestamp_col_key = "Website Scrape Timestamp" - timestamp_col_index = COLUMN_MAP.get(timestamp_col_key) - - # Prüfen, dass alle Indizes da sind - if None in [rohtext_col_index, website_col_idx, version_col_idx, timestamp_col_index]: - return logging.critical("FEHLER: Benötigte Indizes fehlen.") - - # Spaltenbuchstaben ermitteln - rohtext_col_letter = self.sheet_handler._get_col_letter(rohtext_col_index + 1) - version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1) - ts_col_letter = self.sheet_handler._get_col_letter(timestamp_col_index + 1) - - # Erste zu bearbeitende Zeile finden (erstes leeres Timestamp-Feld) - start_data_index = self.sheet_handler.get_start_row_index( - check_column_key=timestamp_col_key, - min_sheet_row=header_rows + 1 - ) - if start_data_index == -1: - return logging.error("FEHLER bei Startzeilensuche.") - if start_data_index >= len(self.sheet_handler.get_data()): - logging.info("Alle Zeilen mit Timestamp gefüllt. Nichts zu tun.") - return - - start_sheet_row = start_data_index + header_rows + 1 - total_sheet_rows = len(all_data) - end_sheet_row = total_sheet_rows - - # Limit auswerten - if limit is not None and limit >= 0: - end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows) - if limit == 0: - logging.info("Limit 0.") - return - if start_sheet_row > end_sheet_row: - logging.warning("Start nach Ende (Limit).") - return - - logging.info( - f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} " - "für Website-Scraping (Batch)." - ) - - # Helfer-Funktion fürs parallele Scraping - def scrape_raw_text_task(task_info): - row_num = task_info["row_num"] - url = task_info["url"] - raw_text = "k.A." - error = None - try: - raw_text = get_website_raw(url) - except Exception as e: - error = f"Scraping Fehler Zeile {row_num}: {e}" - logging.error(error) - return { - "row_num": row_num, - "raw_text": raw_text, - "error": error - } - - tasks_for_processing_batch = [] - all_sheet_updates = [] - processed_count = 0 - skipped_url_count = 0 - processing_batch_size = Config.PROCESSING_BATCH_SIZE - max_scraping_workers = Config.MAX_SCRAPING_WORKERS - - # Durch alle Zeilen iterieren - for i in range(start_sheet_row, end_sheet_row + 1): - row_index_in_list = i - 1 - row = all_data[row_index_in_list] - - # URL auslesen und überspringen, falls k.A. - website_url = "" - if len(row) > website_col_idx: - website_url = row[website_col_idx] - - if not website_url or website_url.strip().lower() == "k.a.": - skipped_url_count += 1 + # 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 - # Job anlegen - tasks_for_processing_batch.append({ - "row_num": i, - "url": website_url + # --- 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 }) - processed_count += 1 + rows_in_current_batch.append(i) # Sammle Zeilennummer - # Batch abarbeiten, wenn voll oder am Ende - if (len(tasks_for_processing_batch) >= processing_batch_size - or i == end_sheet_row): + # --- 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) - 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"] - batch_task_count = len(tasks_for_processing_batch) + # 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 = [] - logging.info( - f"--- Starte Scraping-Batch ({batch_task_count} Tasks, " - f"Zeilen {batch_start_row}-{batch_end_row}) ---" - ) - logging.info( - f"Scrape {batch_task_count} Websites parallel " - f"(max {max_scraping_workers} workers)..." - ) + 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 - scraping_results = {} - batch_error_count = 0 + # 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 [] - 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}: {exc}" - ) - logging.error(err_msg) - scraping_results[row_num] = "k.A. (Fehler)" + + 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 + + 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} + + # 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 - logging.info( - f"Scraping für Batch beendet: " - f"{len(scraping_results)} Ergebnisse, " - f"{batch_error_count} Fehler." - ) + self.logger.debug(f" Scraping für Batch beendet. {len(scraping_results)} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).") - # Updates vorbereiten - if scraping_results: - current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - batch_sheet_updates = [] - for row_num, raw_text_res in scraping_results.items(): - batch_sheet_updates.extend([ - { - "range": f"{rohtext_col_letter}{row_num}", - "values": [[raw_text_res]] - }, - { - "range": f"{ts_col_letter}{row_num}", - "values": [[current_timestamp]] - } - ]) - all_sheet_updates.extend(batch_sheet_updates) + # 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 = [] - # Batch-Update senden - if all_sheet_updates: - logging.info( - f"Sende Sheet-Update für " - f"{len(all_sheet_updates)} Zellen " - f"(Batch {batch_start_row}-{batch_end_row})..." - ) - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: - logging.info("Sheet-Update erfolgreich.") - else: - logging.error("FEHLER beim Sheet-Update.") - all_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 - logging.debug("Warte nach Batch…") - time.sleep(Config.RETRY_DELAY) + # Sammle diese Batch-Updates für das größere Batch-Update am Ende oder bei Limit + all_sheet_updates.extend(batch_sheet_updates) - # nächste Runde vorbereiten + # Leere den Scraping-Batch tasks_for_processing_batch = [] + rows_in_current_scraping_batch = [] - # finaler Update-Push, falls noch Reste da sind + # 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: - logging.info( - f"Sende finalen Sheet-Update " - f"({len(all_sheet_updates)} Zellen)…" - ) - self.sheet_handler.batch_update_cells(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 - logging.info( - f"Website-Scraping Batch abgeschlossen. " - f"{processed_count} Tasks erstellt, " - f"{skipped_url_count} Zeilen ohne URL übersprungen." - ) + 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. - def process_summarization_batch(self, limit=None): + + # --- 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). - Findet Startzeile ab erster Zelle mit leerem AS, wo AR gefüllt ist. + 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. """ - logging.info( - f"Starte Website-Zusammenfassung Batch. " - f"Limit: {limit if limit is not None else 'Unbegrenzt'}" - ) - if not self.sheet_handler.load_data(): - return logging.error("FEHLER beim Laden der Daten.") + self.logger.info(f"Starte Website-Zusammenfassung (Batch). Bereich: {start_sheet_row}-{end_sheet_row}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") - all_data = self.sheet_handler.get_all_data_with_headers() - header_rows = 5 - if not all_data or len(all_data) <= header_rows: - return logging.warning("Keine Daten gefunden.") + # --- 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"] - # Spalten-Indizes holen - rohtext_col_idx = COLUMN_MAP.get("Website Rohtext") - summary_col_idx = COLUMN_MAP.get("Website Zusammenfassung") - version_col_idx = COLUMN_MAP.get("Version") - if None in [rohtext_col_idx, summary_col_idx, version_col_idx]: - return logging.critical("FEHLER: Benötigte Indizes fehlen.") 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) - # Startzeile suchen: erstes gefülltes AR, leeres AS - found_start_row = None - for i in range(header_rows, len(all_data)): - row = all_data[i] - sheet_row_num = i + 1 - # Sicherstellen, dass beide Spalten in dieser Zeile existieren - if len(row) <= max(rohtext_col_idx, summary_col_idx): - continue + # --- 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 - ar_value = str(row[rohtext_col_idx]).strip() - as_value = str(row[summary_col_idx]).strip() + 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) - ar_filled = ( - bool(ar_value) - and ar_value.lower() - not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"] - ) - as_empty = not bool(as_value) - - if ar_filled and as_empty: - found_start_row = sheet_row_num - logging.info(f"Startzeile gefunden: {found_start_row}.") - break - - if found_start_row is None: - logging.info("Keine Zeilen gefunden, die Zusammenfassung benötigen.") - return - - # Bereich definieren - start_sheet_row = found_start_row - total_sheet_rows = len(all_data) - end_sheet_row = total_sheet_rows - - # Limit auswerten - if limit is not None and limit >= 0: - end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows) - if limit == 0: - logging.info("Limit 0.") - return - if start_sheet_row > end_sheet_row: - logging.warning("Start nach Ende (Limit).") - return - - logging.info( - f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} " - "für Website-Zusammenfassung (Batch)." - ) - - tasks_for_openai_batch = [] - all_sheet_updates = [] - processed_count = 0 - openai_batch_size = Config.OPENAI_BATCH_SIZE_LIMIT - - # Durch die Zeilen iterieren + # Iteriere über die Sheet-Zeilen im definierten Bereich for i in range(start_sheet_row, end_sheet_row + 1): - row_index = i - 1 - row = all_data[row_index] + 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 - # Sicherstellen, dass die Spalten existieren - if len(row) <= max(rohtext_col_idx, summary_col_idx): + 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 - ar_value = str(row[rohtext_col_idx]).strip() - as_value = str(row[summary_col_idx]).strip() + # --- Wenn Verarbeitung nötig: Füge zur Batch-Liste hinzu --- + processed_count += 1 # Zähle die Zeile, die verarbeitet wird (zum Limit zählen) - ar_filled = ( - bool(ar_value) - and ar_value.lower() - not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"] - ) - as_empty = not bool(as_value) + # 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 - if not (ar_filled and as_empty): - logging.debug(f"Zeile {i}: Kriterium passt nicht mehr, übersprungen.") - continue + # 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 - # Task für OpenAI-Batch anlegen - tasks_for_openai_batch.append({ - 'row_num': i, - 'raw_text': ar_value - }) - processed_count += 1 - # Batch an OpenAI senden, wenn voll oder am Ende - if (len(tasks_for_openai_batch) >= openai_batch_size - or i == end_sheet_row): + # --- 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}) ---") - logging.debug( - f"Verarbeite OpenAI Batch mit " - f"{len(tasks_for_openai_batch)} Aufgaben " - f"(Startzeile: {tasks_for_openai_batch[0]['row_num']})..." - ) - try: - summaries_result = summarize_batch_openai(tasks_for_openai_batch) - current_version = Config.VERSION + # 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 - # Ergebnisse in Sheet-Updates umwandeln - for task in tasks_for_openai_batch: - row_num = task['row_num'] - summary = summaries_result.get( - row_num, - "k.A. (Fehler Batch Zuordnung)" - ) - batch_updates = [ - { - 'range': f'{summary_col_letter}{row_num}', - 'values': [[ summary ]] - }, - # Optional Version setzen: - # { - # 'range': f'{version_col_letter}{row_num}', - # 'values': [[ current_version ]] - # } - ] - all_sheet_updates.extend(batch_updates) + # Sammle Sheet Updates (AS, AP) für diesen Batch + current_version = getattr(Config, 'VERSION', 'unknown') + batch_sheet_updates = [] - # Updates senden - if all_sheet_updates: - logging.info( - f"Sende Sheet-Update für " - f"{len(tasks_for_openai_batch)} Zusammenfassungen " - f"({len(all_sheet_updates)} Zellen)..." - ) - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: - logging.info("Sheet-Update erfolgreich.") - else: - logging.error("FEHLER beim Sheet-Update.") - all_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)" - except Exception as e_batch: - logging.error( - f"FEHLER bei OpenAI Batch " - f"{tasks_for_openai_batch[0]['row_num']}–" - f"{tasks_for_openai_batch[-1]['row_num']}: {e_batch}" - ) + 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 - # Für den nächsten Batch zurücksetzen - tasks_for_openai_batch = [] - time.sleep(Config.RETRY_DELAY) + # Sammle diese Batch-Updates für das größere Batch-Update + all_sheet_updates.extend(batch_sheet_updates) - # Abschließender Push, falls Reste da sind + 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: - logging.info( - f"Sende finalen Sheet-Update " - f"({len(all_sheet_updates)} Zellen)..." - ) - self.sheet_handler.batch_update_cells(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 - logging.info( - f"Website-Zusammenfassung Batch abgeschlossen. " - f"{processed_count} Tasks erstellt." - ) + 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. - def process_branch_batch(self, limit=None): + + # --- 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): """ - Batch-Prozess NUR für Branchen-Einschätzung (Spalten W-Y, AO). - Findet Startzeile ab erster Zelle mit leerem AO. + 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. """ - logging.info( - f"Starte Branchen-Einschätzung Batch. " - f"Limit: {limit if limit is not None else 'Unbegrenzt'}" - ) - if not self.sheet_handler.load_data(): - return logging.error("FEHLER beim Laden der Daten.") + row_num = task_data['row_num'] + result = {"branch": "k.A. (Fehler Task)", "consistency": "error", "justification": "Fehler in Worker-Task"} + error = None - all_data = self.sheet_handler.get_all_data_with_headers() - header_rows = 5 - if not all_data or len(all_data) <= header_rows: - return logging.warning("Keine Daten gefunden.") + 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 - # Timestamp-Spalte prüfen - timestamp_col_key = "Timestamp letzte Prüfung" - timestamp_col_index = COLUMN_MAP.get(timestamp_col_key) - if timestamp_col_index is None: - return logging.critical( - f"FEHLER: Schlüssel '{timestamp_col_key}' fehlt." - ) - - # Weitere benötigte Spalten indizieren - branche_crm_idx = COLUMN_MAP.get("CRM Branche") - beschreibung_idx = COLUMN_MAP.get("CRM Beschreibung") - branche_wiki_idx = COLUMN_MAP.get("Wiki Branche") - kategorien_wiki_idx = COLUMN_MAP.get("Wiki Kategorien") - summary_web_idx = COLUMN_MAP.get("Website Zusammenfassung") - version_col_idx = COLUMN_MAP.get("Version") - branch_w_idx = COLUMN_MAP.get("Chat Vorschlag Branche") - branch_x_idx = COLUMN_MAP.get("Chat Konsistenz Branche") - branch_y_idx = COLUMN_MAP.get("Chat Begründung Abweichung Branche") - - required_indices = [ - branche_crm_idx, beschreibung_idx, branche_wiki_idx, - kategorien_wiki_idx, summary_web_idx, version_col_idx, - branch_w_idx, branch_x_idx, branch_y_idx - ] - if None in required_indices: - return logging.critical("FEHLER: Benötigte Indizes fehlen.") - - # Spaltenbuchstaben ermitteln - ts_col_letter = self.sheet_handler._get_col_letter(timestamp_col_index + 1) - version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1) - branch_w_letter = self.sheet_handler._get_col_letter(branch_w_idx + 1) - branch_x_letter = self.sheet_handler._get_col_letter(branch_x_idx + 1) - branch_y_letter = self.sheet_handler._get_col_letter(branch_y_idx + 1) - - # Erste Zeile finden, in der AO leer ist - start_data_index = self.sheet_handler.get_start_row_index( - check_column_key=timestamp_col_key, - min_sheet_row=header_rows + 1 - ) - if start_data_index == -1: - return logging.error("FEHLER bei Startzeilensuche.") - if start_data_index >= len(self.sheet_handler.get_data()): - logging.info("Alle Zeilen mit Timestamp gefüllt. Nichts zu tun.") - return - - # Bereichsgrenzen festlegen - start_sheet_row = start_data_index + header_rows + 1 - total_sheet_rows = len(all_data) - end_sheet_row = total_sheet_rows - - if limit is not None and limit >= 0: - end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows) - if limit == 0: - logging.info("Limit 0.") - return - if start_sheet_row > end_sheet_row: - logging.warning("Start nach Ende (Limit).") - return - - logging.info( - f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} " - f"für Branchen-Einschätzung (Batch)." - ) - - # Vorbereitung für parallele Verarbeitung - MAX_BRANCH_WORKERS = Config.MAX_BRANCH_WORKERS - OPENAI_CONCURRENCY_LIMIT = Config.OPENAI_CONCURRENCY_LIMIT - tasks_for_processing_batch = [] - all_sheet_updates = [] - processed_count = 0 - - # Zielschema sicherstellen - if not ALLOWED_TARGET_BRANCHES: - load_target_schema() - if not ALLOWED_TARGET_BRANCHES: - return logging.critical( - "FEHLER: Ziel-Schema nicht geladen. Branch Batch nicht möglich." - ) - - # Tasks sammeln - for i in range(start_sheet_row, end_sheet_row + 1): - row_index = i - 1 - row = all_data[row_index] - - cell_val = row[timestamp_col_index] if len(row) > timestamp_col_index else None - if cell_val and str(cell_val).strip(): - logging.debug( - f"Zeile {i}: Timestamp '{ts_col_letter}' nicht leer, übersprungen." + # 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 - task_data = { - "row_num": i, - "crm_branche": self._get_cell_value(row, "CRM Branche"), - "beschreibung": self._get_cell_value(row, "CRM Beschreibung"), - "wiki_branche": self._get_cell_value(row, "Wiki Branche"), - "wiki_kategorien":self._get_cell_value(row, "Wiki Kategorien"), - "website_summary":self._get_cell_value(row, "Website Zusammenfassung") - } - tasks_for_processing_batch.append(task_data) - processed_count += 1 + # 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() - # Batch abarbeiten - if (len(tasks_for_processing_batch) - >= Config.PROCESSING_BRANCH_BATCH_SIZE - or i == end_sheet_row): + info_sources_count = sum(1 for val in [crm_branche, crm_beschreibung, wiki_branche, wiki_kategorien, website_summary] if val and val != "k.A.") - batch_start_row = tasks_for_processing_batch[0]["row_num"] - batch_end_row = tasks_for_processing_batch[-1]["row_num"] - batch_task_count = len(tasks_for_processing_batch) + 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 - logging.info( - f"--- Starte Branch-Evaluation Batch " - f"({batch_task_count} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---" - ) - logging.info( - f"Evaluiere parallel ({MAX_BRANCH_WORKERS} Worker, " - f"{OPENAI_CONCURRENCY_LIMIT} OpenAI gleichzeitig)..." - ) + # --- Wenn Verarbeitung nötig: Füge zur Batch-Liste hinzu --- + processed_count += 1 # Zähle die Zeile, die verarbeitet wird (zum Limit zählen) - results_list = [] - batch_error_cnt = 0 + # 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 - # Worker ausführen - with concurrent.futures.ThreadPoolExecutor( - max_workers=MAX_BRANCH_WORKERS - ) as executor: + # 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 - future_to_task = { - executor.submit( - _evaluate_branch_task_worker, - task, - openai_semaphore_branch - ): task for task in tasks_for_processing_batch - } + # --- 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] + task = future_to_task[future] # Get the original task data try: - result_data = future.result() - results_list.append(result_data) - if result_data.get("error"): - batch_error_cnt += 1 + 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: - row_num = task["row_num"] - err_msg = ( - f"Generischer Fehler Branch Task Zeile {row_num}: {exc}" - ) - logging.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_cnt += 1 + # 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 - logging.info( - f"Branch-Evaluation für Batch beendet: " - f"{len(results_list)} Ergebnisse, " - f"{batch_error_cnt} Fehler." - ) + # *** 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).") - # Ergebnisse sortieren und Sheet-Updates erzeugen + + # Sheet Updates vorbereiten FÜR DIESEN BATCH if results_list: - current_ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - current_version = Config.VERSION - results_list.sort(key=lambda x: x["row_num"]) - + current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + current_version = getattr(Config, 'VERSION', 'unknown') batch_sheet_updates = [] - for res in results_list: - rn = res["row_num"] - result = res["result"] - logging.debug( - f"Zeile {rn}: Branch='{result.get('branch')}', " - f"Consistency='{result.get('consistency')}', " - f"Justification='{result.get('justification','')[:50]}...'" - ) - batch_sheet_updates.extend([ - { - "range": f"{branch_w_letter}{rn}", - "values": [[ result.get("branch", "Fehler") ]] - }, - { - "range": f"{branch_x_letter}{rn}", - "values": [[ result.get("consistency", "Fehler") ]] - }, - { - "range": f"{branch_y_letter}{rn}", - "values": [[ result.get("justification", "Fehler") ]] - }, - { - "range": f"{ts_col_letter}{rn}", - "values": [[ current_ts ]] - }, - { - "range": f"{version_col_letter}{rn}", - "values": [[ current_version ]] - } - ]) - # Sheet-Update senden + # 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: - logging.info( - f"Sende Sheet-Update für {len(results_list)} Zeilen " - f"({len(batch_sheet_updates)} Zellen)..." - ) + 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: - logging.info("Sheet-Update erfolgreich.") - else: - logging.error("FEHLER beim Sheet-Update.") + 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 - # Vorbereitung für nächsten Batch + + # Leere den Batch für die nächste Iteration tasks_for_processing_batch = [] - time.sleep(Config.RETRY_DELAY) + rows_in_current_batch = [] - # Finalen Push, falls noch Updates da sind - if all_sheet_updates: - logging.info( - f"Sende finalen Sheet-Update ({len(all_sheet_updates)} Zellen)..." - ) - self.sheet_handler.batch_update_cells(all_sheet_updates) + # 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) - logging.info( - f"Branchen-Einschätzung Batch abgeschlossen. " - f"{processed_count} Tasks erstellt." - ) - # --- Dienstprogramm Methoden (Werden von run_user_interface aufgerufen) --- - # Diese Methoden führen eine spezifische Aufgabe aus und arbeiten oft über das gesamte Sheet - # oder eine gefilterte Menge. + # --- 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}) ---") - def process_serp_website_lookup(self, limit=None): + 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 Websites (Spalte D ist leer oder "k.A.") via SERP API - und trägt gefundene URLs in Spalte D ein. - - Args: - limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. - """ - logging.info( - f"Starte Modus: SERP API Website Lookup für leere Zellen in Spalte D. " - f"Limit: {limit if limit is not None else 'Unbegrenzt'}" - ) - if not self.sheet_handler.load_data(): - return logging.error("FEHLER beim Laden der Daten.") - - data_rows = self.sheet_handler.get_data() - header_rows = 5 - rows_processed_count = 0 - updates = [] - - # Spaltenindizes ermitteln - try: - website_col_idx = COLUMN_MAP["CRM Website"] - name_col_idx = COLUMN_MAP["CRM Name"] - except KeyError as e: - logging.critical(f"FEHLER: Benötigte Spalte '{e.args[0]}' fehlt.") - return - except Exception as e: - logging.critical(f"FEHLER beim Holen der Spaltenbuchstaben: {e}") - return - - website_col_letter = self.sheet_handler._get_col_letter(website_col_idx + 1) - - # Durch alle Zeilen iterieren - for i, row in enumerate(data_rows): - row_num_in_sheet = i + header_rows + 1 - - # Limit prüfen - if limit is not None and rows_processed_count >= limit: - logging.info(f"Limit ({limit}) erreicht.") - break - - # Zeile überspringen, wenn sie zu kurz ist - max_needed_idx = max(website_col_idx, name_col_idx) - if len(row) <= max_needed_idx: - logging.debug( - f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz)." - ) - continue - - # Bestehende Website auslesen - current_website = row[website_col_idx] if len(row) > website_col_idx else "" - if not current_website or str(current_website).strip().lower() == "k.a.": - # Firmenname prüfen - company_name = row[name_col_idx] if len(row) > name_col_idx else "" - if not company_name or not str(company_name).strip(): - logging.warning( - f"Zeile {row_num_in_sheet}: Übersprungen (kein Firmenname)." - ) - continue - - # SERP-Abfrage - logging.info( - f"Zeile {row_num_in_sheet}: Suche Website für '{company_name}'..." - ) - new_website = serp_website_lookup(company_name) - rows_processed_count += 1 - - if new_website != "k.A.": - updates.append({ - 'range': f'{website_col_letter}{row_num_in_sheet}', - 'values': [[new_website]] - }) - logging.info( - f"Zeile {row_num_in_sheet}: Neue Website '{new_website}' gefunden." - ) - else: - logging.info( - f"Zeile {row_num_in_sheet}: Keine Website gefunden." - ) - - # Kurze Pause nach jedem Lookup - delay = getattr(Config, "RETRY_DELAY", 5) * 0.3 - time.sleep(delay) - - # Batch-Update abschicken, falls Änderungen vorliegen - if updates: - logging.info( - f"Sende Batch-Update für {len(updates)} Zellen " - f"({rows_processed_count} Zeilen geprüft)..." - ) - success = self.sheet_handler.batch_update_cells(updates) - if success: - logging.info("Batch-Update erfolgreich.") - else: - logging.error("FEHLER beim Batch-Update.") - else: - logging.info( - "Keine fehlenden Websites gefunden oder keine Updates nötig." - ) - - logging.info( - f"Modus 'website_lookup' abgeschlossen. {rows_processed_count} Zeilen geprüft." - ) - - def process_find_wiki_serp(self, limit=None, min_employees=500, min_umsatz=200): - """ - Sucht fehlende Wikipedia-URLs (Spalte M = k.A.) für Unternehmen mit + Sucht fehlende Wikipedia-URLs (Spalte M = k.A.) über SerpAPI für Unternehmen mit (Umsatz CRM > min_umsatz MIO € ODER Mitarbeiter CRM > min_employees) - über SerpAPI und trägt gefundene URLs in Spalte M ein. Setzt ReEval-Flag (A) + 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). - Merkt sich in Spalte AY, wann die Suche durchgeführt wurde. - """ - logging.info( - f"Starte Modus 'find_wiki_serp': Suche fehlende Wiki-URLs für Firmen " - f"mit (Umsatz CRM > {min_umsatz} MIO € ODER Mitarbeiter CRM > {min_employees})..." - ) - if not self.sheet_handler.load_data(): - return logging.error("FEHLER beim Laden der Daten.") - - all_data = self.sheet_handler.get_all_data_with_headers() - header_rows = 5 - if not all_data or len(all_data) <= header_rows: - logging.warning("Keine Daten gefunden.") - return - - data_rows = all_data[header_rows:] - - # Spalten-Indices sammeln - col_indices = {} - required_keys = [ - "ReEval Flag", "CRM Anzahl Mitarbeiter", "CRM Umsatz", "Wiki URL", - "CRM Name", "CRM Website", "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", "Wikipedia Timestamp", - "Timestamp letzte Prüfung", "Version", "Wiki Verif. Timestamp", - "SerpAPI Wiki Search Timestamp" - ] - all_keys_found = True - for key in required_keys: - idx = COLUMN_MAP.get(key) - col_indices[key] = idx - if idx is None: - logging.critical(f"FEHLER: Schlüssel '{key}' fehlt! Modus abgebrochen.") - all_keys_found = False - - if not all_keys_found: - return - - # Spaltenbuchstaben ermitteln - col_letters = { - key: self.sheet_handler._get_col_letter(idx + 1) - for key, idx in col_indices.items() - } - - all_sheet_updates = [] - processed_rows_count = 0 - found_urls_count = 0 - skipped_timestamp_ay_count = 0 - skipped_size_count = 0 - skipped_m_filled_count = 0 - now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - for idx, row in enumerate(data_rows): - row_num_in_sheet = idx + header_rows + 1 - - # Limit prüfen - if limit is not None and processed_rows_count >= limit: - logging.info(f"Limit ({limit}) erreicht.") - break - - # Zeile überspringen, wenn zu kurz - max_needed_idx = max(col_indices.values()) - if len(row) <= max_needed_idx: - logging.debug( - f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz)." - ) - continue - - # AY-Timestamp prüfen - ts_ay_val = row[col_indices["SerpAPI Wiki Search Timestamp"]] - if ts_ay_val and str(ts_ay_val).strip(): - skipped_timestamp_ay_count += 1 - continue - - # M (Wiki URL) prüfen - m_value = row[col_indices["Wiki URL"]] - if m_value and str(m_value).strip().lower() not in ["k.a.", "kein artikel gefunden"]: - skipped_m_filled_count += 1 - continue - - # Größenfilter: Umsatz und Mitarbeiter - umsatz_val_str = row[col_indices["CRM Umsatz"]] - ma_val_str = row[col_indices["CRM Anzahl Mitarbeiter"]] - 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) - - if not (umsatz_val_mio > min_umsatz or ma_val_num > min_employees): - logging.debug( - f"Zeile {row_num_in_sheet}: Übersprungen (Größe nicht ausreichend. " - f"Umsatz (Mio): {umsatz_val_mio:.2f}, MA: {ma_val_num}). " - f"Schwellen: Umsatz > {min_umsatz} Mio, MA > {min_employees}." - ) - skipped_size_count += 1 - continue - - # Firmenname prüfen - company_name = row[col_indices["CRM Name"]] - if not company_name or not str(company_name).strip(): - logging.warning( - f"Zeile {row_num_in_sheet}: Übersprungen, kein Firmenname." - ) - ay_col = col_letters["SerpAPI Wiki Search Timestamp"] - all_sheet_updates.append({ - 'range': f'{ay_col}{row_num_in_sheet}', - 'values': [[now_timestamp_str]] - }) - continue - - # SerpAPI-Aufruf - logging.info( - f"Zeile {row_num_in_sheet}: Suche Wiki-URL für '{company_name}' " - f"(Umsatz (Mio): {umsatz_val_mio:.2f}, MA: {ma_val_num})..." - ) - processed_rows_count += 1 - - # Optionale Website als Kontext - website_url = None - idx_crm_web = col_indices["CRM Website"] - if idx_crm_web is not None and len(row) > idx_crm_web: - website_url = row[idx_crm_web] - - wiki_url_found = serp_wikipedia_lookup(company_name, website=website_url) - - # AY-Timestamp setzen - ay_col = col_letters["SerpAPI Wiki Search Timestamp"] - all_sheet_updates.append({ - 'range': f'{ay_col}{row_num_in_sheet}', - 'values': [[now_timestamp_str]] - }) - - # Ergebnis auswerten - if wiki_url_found and wiki_url_found.strip() and wiki_url_found != "k.A.": - logging.info(f" -> URL gefunden: {wiki_url_found}. Bereite Update vor.") - found_urls_count += 1 - - m_l = col_letters["Wiki URL"] - a_l = col_letters["ReEval Flag"] - n_idx = col_indices["Wiki Absatz"] - v_idx = col_indices["Begründung bei Abweichung"] - n_l = self.sheet_handler._get_col_letter(n_idx + 1) - v_l = self.sheet_handler._get_col_letter(v_idx + 1) - an_idx = col_indices["Wikipedia Timestamp"] - an_l = self.sheet_handler._get_col_letter(an_idx + 1) - ao_idx = col_indices["Timestamp letzte Prüfung"] - ao_l = self.sheet_handler._get_col_letter(ao_idx + 1) - ap_l = col_letters["Version"] - ax_l = col_letters["Wiki Verif. Timestamp"] - - all_sheet_updates.extend([ - {'range': f'{m_l}{row_num_in_sheet}', 'values': [[wiki_url_found]]}, - {'range': f'{a_l}{row_num_in_sheet}', 'values': [['x']]}, - { - 'range': f'{n_l}{row_num_in_sheet}:{v_l}{row_num_in_sheet}', - 'values': [[''] * (v_idx - n_idx + 1)] - }, - {'range': f'{an_l}{row_num_in_sheet}', 'values': [['']]}, - {'range': f'{ao_l}{row_num_in_sheet}', 'values': [['']]}, - {'range': f'{ap_l}{row_num_in_sheet}', 'values': [['']]}, - {'range': f'{ax_l}{row_num_in_sheet}', 'values': [['']]} - ]) - else: - logging.info(" -> Keine Wiki-URL via SerpAPI gefunden.") - - time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.3) - - # Batch-Update senden - if all_sheet_updates: - logging.info( - f"Sende Batch-Update für {len(all_sheet_updates)} Zellen " - f"({processed_rows_count} Zeilen geprüft)..." - ) - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: - logging.info("Sheet-Update erfolgreich.") - else: - logging.error("FEHLER beim Batch-Update.") - else: - logging.info("Keine Updates nötig.") - - logging.info( - f"Modus 'find_wiki_serp' abgeschlossen. " - f"{processed_rows_count} Tasks erstellt, " - f"{found_urls_count} URLs gefunden, " - f"{skipped_timestamp_ay_count} AY gesetzt, " - f"{skipped_size_count} Größe, " - f"{skipped_m_filled_count} M gefüllt." - ) - - def process_wiki_updates_from_chatgpt(self, row_limit=None): - """ - Identifiziert Zeilen (S nicht OK/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 TS/Version, setzt ReEval-Flag A. - - 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 row_limit Zeilen. - """ - logging.info( - f"Starte Modus: Wiki-Updates (URL-Validierung & Löschen ungültiger Vorschläge). " - f"Limit: {row_limit if row_limit is not None else 'Unbegrenzt'}" - ) - if not self.sheet_handler.load_data(): - return logging.error("FEHLER beim Laden der Daten.") - - all_data = self.sheet_handler.get_all_data_with_headers() - header_rows = 5 - if not all_data or len(all_data) <= header_rows: - logging.warning("Keine Daten gefunden oder nur Header.") - return - - data_rows = all_data[header_rows:] - - # Spalten-Indizes prüfen - required_keys = [ - "Chat Wiki Konsistenzprüfung", "Chat Vorschlag Wiki Artikel", "Wiki URL", - "Wikipedia Timestamp", "Wiki Verif. Timestamp", "Timestamp letzte Prüfung", - "Version", "ReEval Flag", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", - "Wiki Mitarbeiter", "Wiki Kategorien", "Begründung bei Abweichung" - ] - col_indices = {} - all_keys_found = True - for key in required_keys: - idx = COLUMN_MAP.get(key) - col_indices[key] = idx - if idx is None: - logging.critical(f"FEHLER: Schlüssel '{key}' fehlt! Modus abgebrochen.") - all_keys_found = False - if not all_keys_found: - return - - all_sheet_updates = [] - processed_rows_count = 0 - updated_url_count = 0 - cleared_suggestion_count = 0 - - # Durch die Zeilen iterieren - for idx, row in enumerate(data_rows): - row_num_in_sheet = idx + header_rows + 1 - - # Limit prüfen - if row_limit is not None and processed_rows_count >= row_limit: - logging.info(f"Limit ({row_limit}) erreicht.") - break - - # Zeile überspringen, wenn zu kurz - max_needed_idx = max(col_indices.values()) - if len(row) <= max_needed_idx: - logging.debug( - f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz)." - ) - continue - - # Zellenwerte holen - konsistenz_s = self._get_cell_value(row, "Chat Wiki Konsistenzprüfung").strip() - vorschlag_u = self._get_cell_value(row, "Chat Vorschlag Wiki Artikel").strip() - url_m = self._get_cell_value(row, "Wiki URL").strip() - - # Kandidatenstatus prüfen - konsistenz_s_upper = konsistenz_s.upper() - is_candidate_for_check = ( - bool(konsistenz_s_upper) - and konsistenz_s_upper not in [ - "OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)", "?" - ] - ) - if is_candidate_for_check or (konsistenz_s_upper == "?" and not vorschlag_u): - logging.debug( - f"Zeile {row_num_in_sheet}: Kandidat für Wiki-Update-Prüfung " - f"(Status S = '{konsistenz_s}'). Vorschlag U = '{vorschlag_u}'" - ) - processed_rows_count += 1 - - # Update-Kandidat validieren - is_update_candidate = False - new_url = "" - condition2 = ( - vorschlag_u.lower().startswith(("http://", "https://")) - and "wikipedia.org/wiki/" in vorschlag_u.lower() - ) - - if condition2: - new_url = vorschlag_u - condition3 = ( - simple_normalize_url(new_url) - != simple_normalize_url(url_m) - ) - if condition3: - logging.debug(f" -> Prüfe Validität der neuen URL: {new_url}...") - try: - condition4 = is_valid_wikipedia_article_url(new_url) - except Exception as e_valid: - logging.error( - f" -> Fehler bei Validierung der URL '{new_url}': {e_valid}. " - "Behandle als ungültig." - ) - condition4 = False - - if condition4: - is_update_candidate = True - logging.debug(f" -> URL '{new_url}' ist ein valider Artikel.") - else: - logging.debug( - f" -> URL '{new_url}' ist KEIN valider Artikel laut API Check." - ) - else: - logging.debug(f" -> Vorschlag U ist identisch mit URL M.") - else: - logging.debug( - f" -> Vorschlag U ist keine Wikipedia URL ('{vorschlag_u}')." - ) - - # Update oder Cleanup vorbereiten - if is_update_candidate: - logging.info( - f"Zeile {row_num_in_sheet}: Update-Kandidat VALIDIERUNG ERFOLGREICH. " - f"Setze ReEval-Flag 'x' und bereite Updates vor für URL: {new_url}" - ) - updated_url_count += 1 - - # Spaltenbuchstaben ermitteln - m_l = self.sheet_handler._get_col_letter(col_indices["Wiki URL"] + 1) - s_l = self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"] + 1) - u_l = self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"] + 1) - a_l = self.sheet_handler._get_col_letter(col_indices["ReEval Flag"] + 1) - - n_idx = col_indices["Wiki Absatz"] - v_idx = col_indices["Begründung bei Abweichung"] - n_l = self.sheet_handler._get_col_letter(n_idx + 1) - v_l = self.sheet_handler._get_col_letter(v_idx + 1) - - an_l = self.sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"] + 1) - ax_l = self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"]+ 1) - ao_l = self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Prüfung"]+1) - ap_l = self.sheet_handler._get_col_letter(col_indices["Version"] + 1) - - all_sheet_updates.extend([ - {'range': f'{m_l}{row_num_in_sheet}', 'values': [[ new_url ]]}, - {'range': f'{s_l}{row_num_in_sheet}', 'values': [[ "X (URL Copied)" ]]}, - {'range': f'{u_l}{row_num_in_sheet}', 'values': [[ "URL übernommen" ]]}, - { - 'range': f'{n_l}{row_num_in_sheet}:{v_l}{row_num_in_sheet}', - 'values': [[''] * (v_idx - n_idx + 1)] - }, - {'range': f'{an_l}{row_num_in_sheet}', 'values': [['']]}, - {'range': f'{ax_l}{row_num_in_sheet}', 'values': [['']]}, - {'range': f'{ao_l}{row_num_in_sheet}', 'values': [['']]}, - {'range': f'{ap_l}{row_num_in_sheet}', 'values': [['']]}, - {'range': f'{a_l}{row_num_in_sheet}', 'values': [[ "x" ]]}, - ]) - else: - logging.info( - f"Zeile {row_num_in_sheet}: Vorschlag U ('{vorschlag_u}') " - "ist ungültig/identisch. Lösche U und setze Status S." - ) - cleared_suggestion_count += 1 - - s_l = self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"] + 1) - u_l = self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"] + 1) - - all_sheet_updates.extend([ - {'range': f'{s_l}{row_num_in_sheet}', 'values': [[ "X (Invalid Suggestion)" ]]}, - {'range': f'{u_l}{row_num_in_sheet}', 'values': [[ "" ]]}, - ]) - - # Nach der Schleife: Batch-Update senden - if all_sheet_updates: - logging.info( - f"Sende Batch-Update für {processed_rows_count} geprüfte Zeilen " - f"({len(all_sheet_updates)} Zellen)..." - ) - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: - logging.info("Sheet-Update für Wiki-Updates erfolgreich.") - else: - logging.error("FEHLER beim Sheet-Update für Wiki-Updates.") - else: - logging.info( - "Keine Zeilen gefunden, die eine Wiki-URL-Korrektur oder " - "Vorschlagsbereinigung benötigen." - ) - - logging.info( - f"Wiki-Updates abgeschlossen. {processed_rows_count} Zeilen geprüft. " - f"{updated_url_count} URLs kopiert & für ReEval markiert, " - f"{cleared_suggestion_count} ungültige Vorschläge gelöscht/markiert." - ) - - def process_website_details(self, 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. + Setzt Timestamp in Spalte AY, wann die Suche durchgeführt wurde (unabhängig vom Ergebnis). Args: - limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. + 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. """ - logging.info( - f"Starte Modus (EXPERIMENTELL): Website Detail Extraction " - f"für Zeilen mit 'x' in Spalte A. " - f"Limit: {limit if limit is not None else 'Unbegrenzt'}" - ) + 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'}...") - if not self.sheet_handler.load_data(): - return logging.error("FEHLER beim Laden der Daten.") - - data_rows = self.sheet_handler.get_data() - header_rows = 5 - rows_processed_count = 0 - updates = [] - - # Spalten-Indizes ermitteln - try: - reeval_col_idx = COLUMN_MAP["ReEval Flag"] - website_col_idx = COLUMN_MAP["CRM Website"] - - # Versuche zuerst die dedizierte Spalte 'Website Details' - details_col_idx = COLUMN_MAP.get("Website Details") - if details_col_idx is None: - # Fallback auf 'Website Rohtext' (AR), falls 'Website Details' nicht vorhanden - details_col_idx = COLUMN_MAP.get("Website Rohtext") - if details_col_idx is None: - logging.critical( - "FEHLER: Weder 'Website Details' noch 'Website Rohtext' " - "als Spalte vorhanden." - ) - return - logging.warning( - "Keine Spalte 'Website Details' in COLUMN_MAP gefunden, " - "nutze 'Website Rohtext' als Fallback." - ) - - details_col_letter = self.sheet_handler._get_col_letter(details_col_idx + 1) - - except KeyError as e: - logging.critical(f"FEHLER: Benötigte Spalte '{e.args[0]}' fehlt.") - return - except Exception as e: - logging.critical(f"FEHLER beim Holen der Spaltenbuchstaben: {e}") - return - - # Über alle Zeilen iterieren - for i, row in enumerate(data_rows): - row_num_in_sheet = i + header_rows + 1 - - # Limit prüfen - if limit is not None and rows_processed_count >= limit: - logging.info(f"Limit ({limit}) erreicht.") - break - - # Nur Zeilen mit 'x' im ReEval-Flag betrachten - if len(row) <= reeval_col_idx or str(row[reeval_col_idx]).strip().lower() != "x": - continue - - # Website-URL holen und validieren - website_url = "" - if len(row) > website_col_idx: - website_url = str(row[website_col_idx]).strip() - - if not website_url or website_url.lower() == "k.a.": - logging.warning( - f"Zeile {row_num_in_sheet}: Keine gültige Website-URL, überspringe." - ) - continue - - logging.info( - f"Zeile {row_num_in_sheet}: Extrahiere Website Details von '{website_url}'..." - ) - rows_processed_count += 1 - - # Details extrahieren - try: - details = scrape_website_details(website_url) - except NameError: - logging.critical( - "FEHLER: Funktion 'scrape_website_details' nicht definiert!" - ) - details = "FEHLER: Funktion nicht definiert" - except Exception as e_detail: - logging.exception( - f"Fehler bei scrape_website_details für {website_url}: {e_detail}" - ) - details = f"FEHLER: {e_detail}" - - # Update vorbereiten - updates.append({ - 'range': f'{details_col_letter}{row_num_in_sheet}', - 'values': [[ str(details) ]] - }) - - # Kurze Pause, um Rate-Limits zu schonen - time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.2) - - # Batch-Update senden, falls Änderungen vorhanden - if updates: - logging.info( - f"Sende Batch-Update für {len(updates)} Zellen " - f"({rows_processed_count} Zeilen geprüft)..." - ) - success = self.sheet_handler.batch_update_cells(updates) - if success: - logging.info("Batch-Update erfolgreich.") - else: - logging.error("FEHLER beim Batch-Update.") + # --- 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: - logging.info("Keine 'x'-Zeilen gefunden für Detail-Extraktion.") + # 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 - logging.info( - f"Modus 'website_details' abgeschlossen. " - f"{rows_processed_count} Zeilen geprüft." - ) + all_data = self.sheet_handler.get_all_data_with_headers() + header_rows = self.sheet_handler._header_rows + total_sheet_rows = len(all_data) - # process_contact_research Methode - def process_contact_research(self, limit=None): # <<< Methode in DataProcessor - """Sucht LinkedIn Kontakte und trägt sie in 'Contacts' Sheet ein.""" - logging.info(f"Starte Contact Research (LinkedIn). Limit: {limit if limit is not None else 'Unbegrenzt'}"); - # DataProcessor benötigt sheet_handler.sheet.spreadsheet Zugriff - if not self.sheet_handler or not hasattr(self.sheet_handler, 'sheet') or not hasattr(self.sheet_handler.sheet, 'spreadsheet'): - logging.critical("FEHLER: Sheet Handler oder Spreadsheet nicht verfügbar für Contact Research."); - return; + # Berechne Endzeile, wenn nicht gesetzt + if end_sheet_row is None: + end_sheet_row = total_sheet_rows # Bis zur letzten Zeile - main_sheet = self.sheet_handler.sheet; - if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten."); - all_data = self.sheet_handler.get_all_data_with_headers(); header_rows = 5; + self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") - # Finde Startzeile basierend auf Timestamp in Spalte AM (Index 38) - timestamp_col_index = COLUMN_MAP.get("Contact Search Timestamp"); - if timestamp_col_index is None: logging.critical("FEHLER: 'Contact Search Timestamp' Spaltenindex fehlt."); return; + 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 - start_sheet_row = -1; - # Starte Suche nach leerem Timestamp nach Headern (Zeile 6, Index 5) - for i in range(header_rows, len(all_data)): # Iterate 0-based - row_index_in_list = i; row = all_data[row_index_in_list]; row_num_in_sheet = i + 1; # 1-based - if len(row) <= timestamp_col_index or not row[timestamp_col_index].strip(): - start_sheet_row = row_num_in_sheet; break; - if start_sheet_row == -1: logging.info("Keine Zeile ohne Contact Search Timestamp gefunden."); return; - logging.info(f"Contact Research startet ab Zeile {start_sheet_row}."); - # Kontakte-Blatt öffnen oder erstellen - try: contacts_sheet = self.sheet_handler.sheet.spreadsheet.worksheet("Contacts"); logging.info("Blatt 'Contacts' gefunden."); + # --- 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: - logging.info("Blatt 'Contacts' nicht gefunden, erstelle neu..."); - contacts_sheet = self.sheet_handler.sheet.spreadsheet.add_worksheet(title="Contacts", rows="1000", cols="12"); - header = ["Firmenname", "CRM Kurzform", "Website", "Geschlecht", "Vorname", "Nachname", "Position", "Suchbegriffskategorie", "E-Mail-Adresse", "LinkedIn-Link", "Timestamp"]; - try: contacts_sheet.update(values=[header], range_name="A1:K1"); logging.info("Neues Blatt 'Contacts' erstellt und Header eingetragen."); - except Exception as e: logging.error(f"FEHLER beim Schreiben des Headers ins 'Contacts' Blatt: {e}"); # Kann hier weitergehen? - - # Positionen, nach denen gesucht wird - positions_to_search = ["Serviceleiter", "Leiter Kundendienst", "IT-Leiter", "Leiter IT", "Geschäftsführer", "Vorstand", "Disponent", "Einsatzleiter"]; # Annahme - - processed_count = 0; - # Gehe Zeilen im Hauptblatt durch (ab Startzeile) - for i in range(start_sheet_row, len(all_data) + 1): - row_index_in_list = i - 1; row = all_data[row_index_in_list]; row_num_in_sheet = i; # 1-based - if limit is not None and processed_count >= limit: logging.info(f"Limit ({limit}) erreicht."); break; - - # Benötigte Spaltenindizes für Lesezugriff (CRM Name, Kurzform, Website) - name_idx = COLUMN_MAP.get("CRM Name"); kurzform_idx = COLUMN_MAP.get("CRM Kurzform"); website_idx = COLUMN_MAP.get("CRM Website"); - if None in [name_idx, kurzform_idx, website_idx]: logging.error("FEHLER: Benötigte CRM-Spalten für Contact Research fehlen."); break; - - # Sicherstellen, dass Zeile lang genug ist - max_crm_idx = max(name_idx, kurzform_idx, website_idx); - if len(row) <= max_crm_idx: logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz)."); continue; + # 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 - company_name = row[name_idx]; crm_kurzform = row[kurzform_idx]; website = row[website_idx]; - if not all([company_name, crm_kurzform, website]) or any(str(v).strip().lower() == "k.a." for v in [company_name, crm_kurzform, website]): - logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (fehlende CRM Daten: Name, Kurzform oder Website)."); - # Optional: Setze AM Timestamp zu "k.A. (Missing Data)"? Oder leer lassen? - # Lassen wir leer, website_lookup/find_wiki_serp könnten Daten ergänzen. - continue; + # --- 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. - logging.info(f"Zeile {row_num_in_sheet}: Suche Kontakte für '{crm_kurzform}'..."); - processed_count += 1; # Zähle als verarbeitet, wenn die Suche für diese Firma gestartet wird - all_found_contacts = []; contact_counts = {pos: 0 for pos in ["Serviceleiter", "IT-Leiter", "Geschäftsführer", "Disponent"]}; + 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) - for position in positions_to_search: - # search_linkedin_contacts ist global mit Retry - found_contacts = search_linkedin_contacts(company_name, website, position, crm_kurzform, num_results=5); # Suche max. 5 Kontakte pro Position - - # Zählung für das Hauptblatt (vereinfachte Kategorien) - if "serviceleiter" in position.lower() or "kundendienst" in position.lower() or "einsatzleiter" in position.lower(): contact_counts["Serviceleiter"] += len(found_contacts); - elif "it-leiter" in position.lower() or "leiter it" in position.lower(): contact_counts["IT-Leiter"] += len(found_contacts); - elif "geschäftsführer" in position.lower() or "vorstand" in position.lower(): contact_counts["Geschäftsführer"] += len(found_contacts); - elif "disponent" in position.lower(): contact_counts["Disponent"] += len(found_contacts); - - # Füge gefundene Kontakte zur Liste hinzu (mit Suchkategorie) - for contact in found_contacts: contact["Suchbegriffskategorie"] = position; all_found_contacts.append(contact); - - time.sleep(1.5); # Kleine Pause zwischen SerpAPI-Aufrufen - - # Verarbeite gefundene Kontakte und schreibe ins Contacts-Sheet - rows_to_append = []; timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S"); - unique_contacts = {c.get('LinkedInURL'): c for c in all_found_contacts if c.get('LinkedInURL')}.values(); # Deduplizieren & nur mit URL - - for contact in unique_contacts: - firstname = contact.get("Vorname", ""); lastname = contact.get("Nachname", ""); - gender_value = get_gender(firstname); # Global Function with Retry - email = get_email_address(firstname, lastname, website); # Global Function - contact_row = [ contact.get("Firmenname", ""), contact.get("CRM Kurzform", ""), contact.get("Website", ""), gender_value, firstname, lastname, contact.get("Position", ""), contact.get("Suchbegriffskategorie", ""), email, contact.get("LinkedInURL", ""), timestamp ]; - rows_to_append.append(contact_row); - - if rows_to_append: - try: contacts_sheet.append_rows(rows_to_append, value_input_option='USER_ENTERED'); logging.info(f"Zeile {row_num_in_sheet}: {len(rows_to_append)} neue Kontakte zum 'Contacts'-Blatt hinzugefügt."); - except Exception as e: logging.error(f"Zeile {row_num_in_sheet}: Fehler beim Hinzufügen von Kontakten zum Sheet: {e}"); pass; # Fehler loggen, aber weitermachen - - # Aktualisiere Trefferzahlen und Timestamp im Hauptblatt (Batch Update) - # Benötigte Spaltenindizes für Schreibzugriff (AI-AM) - ai_idx = COLUMN_MAP.get("Linked Serviceleiter gefunden"); aj_idx = COLUMN_MAP.get("Linked It-Leiter gefunden"); ak_idx = COLUMN_MAP.get("Linked Management gefunden"); al_idx = COLUMN_MAP.get("Linked Disponent gefunden"); am_idx = COLUMN_MAP.get("Contact Search Timestamp"); - if None in [ai_idx, aj_idx, ak_idx, al_idx, am_idx]: logging.error("FEHLER: Benötigte Linked/Contact TS Spalten fehlen."); continue; # Kann nicht updaten - - main_sheet_updates = []; - main_sheet_updates.append({'range': f'AI{row_num_in_sheet}', 'values': [[str(contact_counts.get("Serviceleiter", ""))]]}); - main_sheet_updates.append({'range': f'AJ{row_num_in_sheet}', 'values': [[str(contact_counts.get("IT-Leiter", ""))]]}); - main_sheet_updates.append({'range': f'AK{row_num_in_sheet}', 'values': [[str(contact_counts.get("Geschäftsführer", ""))]]}); - main_sheet_updates.append({'range': f'AL{row_num_in_sheet}', 'values': [[str(contact_counts.get("Disponent", ""))]]}); - main_sheet_updates.append({'range': f'AM{row_num_in_sheet}', 'values': [[timestamp]]}); - - if main_sheet_updates: - success = self.sheet_handler.batch_update_cells(main_sheet_updates); # Nutze self.sheet_handler - if success: logging.debug(f"Zeile {row_num_in_sheet}: Kontaktzahlen/AM im Hauptblatt aktualisiert."); - else: logging.error(f"Zeile {row_num_in_sheet}: FEHLER beim Batch-Update Kontaktzahlen/AM."); - - # Pause nach Verarbeitung einer Firma - time.sleep(Config.RETRY_DELAY); + now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - logging.info(f"Contact Research abgeschlossen. {processed_count} Firmen geprüft.") - # --- Methoden zur Datenvorbereitung und Modelltraining für ML --- - # Diese Methoden gehören in die Klasse DataProcessor + # 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 - # prepare_data_for_modeling Methode + 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. + - Wählt relevante Spalten aus und benennt sie um. - Konsolidiert Umsatz/Mitarbeiter (Wiki > CRM Priorität). - - Filert nach gültiger Technikerzahl (> 0). + - 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. """ - logging.info("Starte Datenvorbereitung für Modellierung...") + 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: - logging.error("Fehler: Sheet Handler nicht initialisiert oder keine Daten geladen für prepare_data_for_modeling.") + 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(): - logging.critical("Konnte Daten auch nach erneutem Versuch nicht laden. Abbruch der Datenvorbereitung.") + 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 = 6 # 5 Header + 1 Datenzeile + min_required_rows = header_rows + 1 if not all_data or len(all_data) < min_required_rows: - logging.error(f"Fehler: Nicht genügend Datenzeilen ({len(all_data)}) im Sheet gefunden für Modellierung (mindestens {min_required_rows} benötigt).") + 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, - # um die Spaltenindizes aus COLUMN_MAP zu finden - if len(headers) <= max(COLUMN_MAP.values()): - logging.critical(f"FEHLER: Header-Zeile ({len(headers)} Spalten) ist kürzer als der höchste Index in COLUMN_MAP ({max(COLUMN_MAP.values())}). COLUMN_MAP passt nicht zum Sheet.") + # 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: - logging.critical("FEHLER: Sheet scheint leer zu sein oder hat keine erste Zeile, keine Header gefunden.") + self.logger.critical("FEHLER: Sheet scheint leer zu sein oder hat keine erste Zeile, keine Header gefunden.") return None - except ValueError as e: - logging.critical(f"FEHLER: Ungültiger Wert in COLUMN_MAP. Kann max Index nicht ermitteln: {e}") - return None except Exception as e: - logging.critical(f"FEHLER beim Zugriff auf Header oder Prüfen der Spaltenlänge: {e}") + self.logger.critical(f"FEHLER beim Zugriff auf Header: {e}") return None - data_rows = all_data[5:] # Annahme: Die ersten 5 Zeilen sind Header + data_rows = all_data[header_rows:] # Annahme: Die ersten X Zeilen sind Header # Erstelle DataFrame df = pd.DataFrame(data_rows, columns=headers) - logging.info(f"Initialen DataFrame für Modellierung erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.") + 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 und ihre gewünschten Namen im DataFrame - col_keys = { + # 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 Branche", # Für One-Hot Encoding + "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 @@ -5523,836 +4957,1478 @@ class DataProcessor: } # Überprüfe, ob alle benötigten Spalten in der COLUMN_MAP vorhanden sind - missing_keys = [key for key in col_keys.values() if key not in COLUMN_MAP] - if missing_keys: - logging.critical(f"FEHLER: Folgende benötigte Spalten-Schlüssel fehlen in COLUMN_MAP für prepare_data_for_modeling: {missing_keys}.") + 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 Header-Namen zu internen Schlüsseln - header_to_internal_key = {headers[COLUMN_MAP[key]]: internal_key for internal_key, key in col_keys.items()} - - # Wähle nur die benötigten Spalten im DataFrame aus - # Verwende die tatsächlichen Header-Namen aus dem Sheet für die Auswahl - cols_to_select_by_header = [headers[COLUMN_MAP[key]] for key in col_keys.values()] + # 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, - # aber zur Sicherheit ein weiterer Check - logging.critical(f"FEHLER beim Auswählen/Umbenennen der Spalten (KeyError: {e}). Verfügbare Header im DF: {list(df.columns)}") + # 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: - logging.critical(f"Unerwarteter FEHLER beim Auswählen/Umbenennen der Spalten: {e}") + self.logger.critical(f"Unerwarteter FEHLER beim Auswählen/Umbenennen der Spalten: {e}") + self.logger.debug(traceback.format_exc()) return None - logging.info(f"Benötigte Spalten für Modellierung ausgewählt und umbenannt: {list(df_subset.columns)}") + self.logger.info(f"Benötigte Spalten für Modellierung ausgewählt und umbenannt: {list(df_subset.columns)}") # --- Features konsolidieren (Umsatz, Mitarbeiter) --- - # Annahme: extract_numeric_value existiert (global) - # Wir brauchen hier eine Funktion, die NaN zurückgibt für ungültige Werte, nicht "k.A." - # Passen Sie extract_numeric_value an oder erstellen Sie eine neue. - # Die get_valid_numeric Funktion aus Ihrer alten prepare_data_for_modeling Version macht genau das. - - def get_valid_numeric(value_str): - """Hilfsfunktion zur sicheren Konvertierung mit Fehlerbehandlung.""" - if value_str is None or pd.isna(value_str) or str(value_str).strip() == '': - return np.nan - raw_value_str = str(value_str) - try: - # Kopieren Sie hier die Logik von extract_numeric_value, die NaN zurückgibt - # anstatt "k.A." bei Fehlern oder 0/negativen Werten. - processed_value = clean_text(raw_value_str) # Annahme: clean_text existiert - 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 zählen - - except (ValueError, TypeError) as e: - logging.debug(f"Konntze Wert '{str(value_str)[:50]}...' nicht als gültige Zahl parsen: {e}") - return np.nan - except Exception as e: - logging.warning(f"Unerwarteter Fehler in get_valid_numeric für Wert '{str(value_str)[:50]}...': {e}") - return np.nan - - + # 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(): - logging.info(f"Verarbeite und konsolidiere '{base_name}' (Priorität: Wiki > CRM)...") - # Sicherstellen, dass Spalten existieren (get_valid_numeric behandelt None) + 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) - # np.where wählt den ersten Wert, wenn er nicht NaN ist, sonst den zweiten + df_subset[final_col] = np.where( - wiki_series.notna(), - wiki_series, - crm_series + 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) ) - logging.info(f" -> {df_subset[final_col].notna().sum()} gültige '{final_col}' Werte erstellt (von {len(df_subset)} Zeilen).") + # 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 = "techniker" # Interne Spaltenname nach Umbenennung - logging.info(f"Verarbeite Zielvariable '{techniker_col}'...") - if techniker_col not in df_subset.columns: - logging.critical(f"FEHLER: Zielvariable '{techniker_col}' fehlt.") - return None - df_subset['Anzahl_Servicetechniker_Numeric'] = pd.to_numeric(df_subset[techniker_col], errors='coerce') + techniker_col_internal = "techniker" # Interne Spaltenname nach Umbenennung + self.logger.info(f"Verarbeite Zielvariable '{techniker_col_internal}'...") - # Filtere Zeilen: Behalte nur die mit gültiger, positiver Technikerzahl (> 0) + # 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() + ].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: - logging.info(f"{removed_rows} Zeilen entfernt (fehlende/ungültige Technikerzahl).") - logging.info(f"Verbleibende Zeilen für Modellierung (mit gültiger Technikerzahl > 0): {filtered_rows}") + 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: - logging.error("FEHLER: Keine Zeilen mit gültiger Technikerzahl (>0) übrig!") + 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)' - ] - df_filtered['Techniker_Bucket'] = pd.cut( - df_filtered['Anzahl_Servicetechniker_Numeric'], - bins=bins, - labels=labels, - right=True, - include_lowest=True - ) - logging.info("Techniker-Buckets erstellt.") - logging.info(f"Verteilung der Techniker-Buckets im Trainingsdatensatz:\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True).round(3)}") + 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 - # NaN-Buckets entfernen - if df_filtered['Techniker_Bucket'].isna().any(): - logging.warning("WARNUNG: NaNs in Techniker-Buckets erstellt. Entferne diese Zeilen.") - df_filtered.dropna(subset=['Techniker_Bucket'], inplace=True) - logging.info(f"Nach Entfernung von NaN Buckets: {len(df_filtered)} Zeilen verbleiben.") - if len(df_filtered) == 0: - logging.error("FEHLER: Keine Zeilen übrig nach Entfernung von NaN Buckets.") - return None # --- Kategoriale Features vorbereiten (Branche) --- - branche_col = "branche" # Interne Spaltenname - logging.info(f"Verarbeite kategoriales Feature '{branche_col}' für One-Hot Encoding...") - if branche_col not in df_filtered.columns: - logging.critical(f"FEHLER: Spalte '{branche_col}' fehlt für One-Hot Encoding.") - return None - df_filtered[branche_col] = df_filtered[branche_col].astype(str).fillna('Unbekannt').str.strip() - df_encoded = pd.get_dummies(df_filtered, columns=[branche_col], prefix='Branche', dummy_na=False) - logging.info(f"One-Hot Encoding für '{branche_col}' durchgeführt. Neue Spaltenanzahl: {len(df_encoded.columns)}") + 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 --- - feature_columns = [col for col in df_encoded.columns if col.startswith('Branche_')] + # 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']) - if not all(col in df_encoded.columns for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter']): - logging.critical("FEHLER: Konsolidierte numerische Spalten fehlen.") - return None - target_column = 'Techniker_Bucket' - original_data_cols = ['name', 'Anzahl_Servicetechniker_Numeric'] - if not all(col in df_encoded.columns for col in original_data_cols): - logging.critical(f"FEHLER: Originaldaten-Spalten {original_data_cols} fehlen.") - return None - df_model_ready = df_encoded[original_data_cols + feature_columns + [target_column]].copy() + # 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: - df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce') + 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) - logging.info("Datenvorbereitung für Modellierung abgeschlossen.") - logging.info(f"Finaler DataFrame hat {len(df_model_ready)} Zeilen und {len(df_model_ready.columns)} Spalten.") - logging.info(f"Anzahl Feature-Spalten: {len(feature_columns)}") - logging.info(f"Ziel-Spalte: {target_column}") - nan_counts = df_model_ready[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].isna().sum() - logging.info(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}") - rows_with_nan = df_model_ready[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].isna().any(axis=1).sum() - logging.info(f"Anzahl Zeilen mit mindestens einem fehlenden numerischen Feature: {rows_with_nan}") + 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 - # train_technician_model Methode - def train_technician_model(self, model_out, imputer_out, patterns_out): - """ - Trainiert Decision Tree Modell zur Schätzung der Servicetechnikerzahl. - """ - logging.info("Starte Modus: train_technician_model") - prepared_df = self.prepare_data_for_modeling() # Nutze self - if prepared_df is not None and not prepared_df.empty: - logging.info("Aufteilen der Daten für das Modelltraining...") - try: - X = prepared_df.drop(columns=['Techniker_Bucket', 'name', 'Anzahl_Servicetechniker_Numeric']) - y = prepared_df['Techniker_Bucket'] - X_train, X_test, y_train, y_test = train_test_split( - X, y, test_size=0.25, random_state=42, stratify=y - ) - logging.info(f"Train/Test Split: {len(X_train)} Train, {len(X_test)} Test samples.") - except KeyError as e: - logging.error(f"FEHLER beim Train/Test Split: Spalte nicht gefunden - {e}.") - return - except Exception as e: - logging.error(f"FEHLER beim Train/Test Split: {e}") - return - - logging.info("Imputation fehlender numerischer Werte (Median)...") - numeric_features = ['Finaler_Umsatz', 'Finaler_Mitarbeiter'] - try: - imputer = SimpleImputer(strategy='median') - features_to_impute = [nf for nf in numeric_features if nf in X_train.columns] - if features_to_impute: - X_train[features_to_impute] = imputer.fit_transform( - X_train[features_to_impute] - ) - X_test[features_to_impute] = imputer.transform( - X_test[features_to_impute] - ) - imputer_filename = imputer_out - with open(imputer_filename, 'wb') as f_imp: - pickle.dump(imputer, f_imp) - logging.info(f"Imputer erfolgreich trainiert und gespeichert: '{imputer_filename}'.") - else: - logging.warning("Keine numerischen Features gefunden, die imputiert werden müssen.") - except Exception as e: - logging.error(f"FEHLER bei der Imputation: {e}") - return - - logging.info("Starte Decision Tree Training mit GridSearchCV...") - param_grid = { - 'criterion': ['gini', 'entropy'], - 'max_depth': [6, 8, 10, 12, 15], - 'min_samples_split': [20, 40, 60], - 'min_samples_leaf': [10, 20, 30], - 'ccp_alpha': [0.0, 0.001, 0.005] - } - dtree = DecisionTreeClassifier(random_state=42, class_weight='balanced') - grid_search = GridSearchCV( - estimator=dtree, param_grid=param_grid, - cv=5, scoring='f1_weighted', n_jobs=-1, verbose=1 - ) - if X_train.isna().sum().sum() > 0: - logging.error( - f"FEHLER: NaNs nach Imputation in X_train gefunden. " - f"{X_train.columns[X_train.isna().any()].tolist()}. Training abgebrochen." - ) - return - try: - grid_search.fit(X_train, y_train) - best_estimator = grid_search.best_estimator_ - logging.info("GridSearchCV abgeschlossen.") - logging.info(f"Beste Parameter: {grid_search.best_params_}") - logging.info(f"Bester F1-Score (gewichtet, CV): {grid_search.best_score_:.4f}") - model_filename = model_out - with open(model_filename, 'wb') as f_mod: - pickle.dump(best_estimator, f_mod) - logging.info(f"Bestes Modell gespeichert: '{model_filename}'.") - except Exception as e_train: - logging.exception(f"FEHLER während des Trainings: {e_train}") - return - - logging.info("Evaluiere Modell auf dem Test-Set...") - try: - X_test_processed = X_test.reindex( - columns=X_train.columns, fill_value=0 - ) - y_pred = best_estimator.predict(X_test_processed) - test_accuracy = accuracy_score(y_test, y_pred) - class_labels = [str(cls) for cls in best_estimator.classes_] - report = classification_report( - y_test, y_pred, zero_division=0, - labels=best_estimator.classes_, - target_names=class_labels - ) - conf_matrix = confusion_matrix( - y_test, y_pred, labels=best_estimator.classes_ - ) - conf_matrix_df = pd.DataFrame(conf_matrix, index=class_labels, columns=class_labels) - logging.info( - f"\n--- Evaluation Test-Set ---\n" - f"Genauigkeit: {test_accuracy:.4f}\n" - f"Classification Report:\n{report}\n" - f"Confusion Matrix:\n{conf_matrix_df}" - ) - print(f"\nModell Genauigkeit (Test): {test_accuracy:.4f}") - except Exception as e_eval: - logging.exception(f"FEHLER bei der Evaluation des Test-Sets: {e_eval}") - - logging.info("Extrahiere Baumregeln...") - try: - feature_names = list(X_train.columns) - rules_text = export_text( - best_estimator, feature_names=feature_names, - show_weights=True, spacing=3 - ) - patterns_filename = patterns_out - with open(patterns_filename, 'w', encoding='utf-8') as f_rules: - f_rules.write(rules_text) - logging.info(f"Regeln als Text gespeichert: '{patterns_filename}'.") - except Exception as e_export: - logging.error(f"Fehler beim Exportieren der Regeln: {e_export}") - - else: - logging.warning("Datenvorbereitung für Modelltraining fehlgeschlagen oder ergab keine Daten.") - - # train_technician_model_rag_light Methode (NEU - Platzhalter) - # Diese Methode würde die Schätzung mit dem trainierten Modell und Regeln durchführen. - # Sie gehört hierher, wird aber erst später implementiert. - # def train_technician_model_rag_light(self, ...): - # pass # Implementierung später - # --- Batch Dispatcher Methode (Werden von run_user_interface aufgerufen) --- - # Diese Methode wählt den passenden Batch-Prozess aus und ruft die entsprechende Batch-Methode auf. - # Sie findet die Startzeile für die Batch-Methoden. - def run_batch_dispatcher(self, mode, limit=None): + # 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): """ - Wählt den passenden Batch-Prozess basierend auf dem Modus und ruft die entsprechende Methode auf. - Ermittelt die Startzeile dynamisch. + Trainiert ein Decision Tree Modell zur Schätzung der Servicetechniker-Buckets. + Speichert das Modell, den Imputer und die Feature-Spalten. Args: - mode (str): Der Name des Batch-Modus (z.B. 'wiki_batch', 'website_scrape_batch'). - limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None. + 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). """ - logging.info(f"Starte DataProcessor Batch Dispatcher im Modus '{mode}' mit limit={limit if limit is not None else 'Unbegrenzt'}.") - header_rows = 5 # Annahme, könnte auch dynamisch vom handler kommen + self.logger.info("Starte Training des Servicetechniker Decision Tree Modells...") - # Startspalte für jeden Batch-Modus (basierend auf Timestamp/Status für Neuverarbeitung) - start_col_key = None - if mode == "wiki_batch": start_col_key = "Wiki Verif. Timestamp" # AX - elif mode == "website_scrape_batch": start_col_key = "Website Scrape Timestamp" # AT - elif mode == "summarize_batch": # Summarize Batch braucht leeres AS und gefülltes AR - # Die Startzeilensuche für Summarize Batch ist komplexer und in process_summarization_batch implementiert. - # Dieser Dispatcher kann sie hier nicht generisch finden. process_summarization_batch muss das selbst tun. - pass # Keine generische Startspalte hier - elif mode == "branch_batch": start_col_key = "Timestamp letzte Prüfung" # AO - elif mode == "combined": - # Combined mode ruft die einzelnen Batch-Methoden nacheinander auf - logging.info("Combined mode: Calling batches sequentially.") - self.run_batch_dispatcher(mode="wiki_batch", limit=limit) # Prüft AX - self.run_batch_dispatcher(mode="website_scrape_batch", limit=limit) # Prüft AT - self.run_batch_dispatcher(mode="summarize_batch", limit=limit) # Sucht Startzeile intern - self.run_batch_dispatcher(mode="branch_batch", limit=limit) # Prüft AO - logging.info("Combined mode completed.") - return # Wichtig: Nach Combined beenden + # 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 - # Logik für einzelne Batch-Modi (wiki, website, summarize, branch) - # Für Summarize Batch (mode == 'summarize_batch'), ruft die Methode intern die Startzeilensuche auf. - # Für die anderen (wiki, website, branch), nutzen wir get_start_row_index hier. - if mode in ["wiki_batch", "website_scrape_batch", "branch_batch"]: - if start_col_key is None: - logging.critical(f"FEHLER: Keine Startspalte für Batch-Modus '{mode}' definiert.") - 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 - logging.info(f"Dispatcher: Ermittle Startzeile basierend auf Spalte '{start_col_key}'...") - start_data_index = self.sheet_handler.get_start_row_index(check_column_key=start_col_key, min_sheet_row=header_rows + 1) + # 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 - if start_data_index == -1: return logging.error(f"FEHLER: Startspalte '{start_col_key}' prüfen!") - # get_start_row_index gibt den Index in den Daten (ohne Header) zurück. - # Wenn alle Zeilen gefüllt sind, gibt es die Anzahl der Datenzeilen zurück. - # Wenn dieser Index >= Anzahl der Datenzeilen ist, gibt es nichts zu tun. - if start_data_index >= len(self.sheet_handler.get_data()): - logging.info(f"Alle Zeilen in Spalte '{start_col_key}' sind gefüllt. Nichts zu tun für Modus '{mode}'.") - return + X = df_model_ready[feature_columns] + y = df_model_ready[target_column] - # Diese Startzeile (0-basiert in Daten) wird nicht direkt an die Batch-Methoden übergeben, - # da diese die Startzeile (1-basiert im Sheet) benötigen, um über die GESAMTE Liste zu iterieren. - # Wir berechnen hier nur zur Info die Start-Sheet-Zeile. - start_sheet_row_info = start_data_index + header_rows + 1 - logging.info(f"Erste Zeile mit leerem Timestamp in Spalte '{start_col_key}' ist Sheet-Zeile {start_sheet_row_info}.") - # Die Batch-Methoden (process_verification_batch etc.) müssen ihre eigene Startzeilensuche durchführen, - # oder wir übergeben die Daten ab dieser Zeile. - # Aktuell machen die Batch-Methoden ihre eigene get_start_row_index Suche. - # Das ist redundant, aber funktioniert. Behalten wir das vorerst bei. - # Im Refactoring kann man die Batch-Methoden so ändern, dass sie ab einem übergebenen Index iterieren. + 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 - # Aufruf der spezifischen Batch-Methoden + 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: - # Diese Methoden müssen in der DataProcessor Klasse implementiert sein - if mode == "wiki_batch": self.process_verification_batch(limit=limit) - elif mode == "website_scrape_batch": self.process_website_batch(limit=limit) - elif mode == "summarize_batch": self.process_summarization_batch(limit=limit) # Sucht Startzeile intern - elif mode == "branch_batch": self.process_branch_batch(limit=limit) - # Combined wird oben separat behandelt + 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: - logging.exception(f"FEHLER in DataProcessor Batch Dispatcher im Modus '{mode}': {e}") + self.logger.error(f"FEHLER beim Speichern der Feature-Spalten in '{patterns_out_json}': {e}") + # Fahren Sie fort - logging.info(f"DataProcessor Batch Dispatcher für Modus '{mode}' abgeschlossen.") + # 5. Evaluation (Optional, aber empfohlen) + self.logger.info("Starte Modellevaluation...") + # Vorhersagen auf dem Testset + y_pred = dt_classifier.predict(X_test_imputed) -# --- Neue Funktion: Benutzerinterface & Modus Dispatcher --- -# Diese Funktion ist die neue Steuerzentrale, die das Menü anzeigt und die Aufrufe delegiert. -# Sie ersetzt den grossen if/elif Block in der alten main Funktion. -# Annahme: DataProcessor Klasse ist definiert und instanziert -# Annahme: Globale Kriterien-Funktionen (criteria_xxx) sind definiert -# Annahme: Globale Dienstprogramm-Funktionen (alignment_demo) sind definiert -# Annahme: logging ist konfiguriert + # Metriken berechnen und loggen + accuracy = accuracy_score(y_test, y_pred) + self.logger.info(f"Modell Genauigkeit auf dem Testset: {accuracy:.4f}") -def run_user_interface(data_processor, cli_mode=None, cli_limit=None, cli_start_row=None, cli_steps=None, cli_min_umsatz=None, cli_min_employees=None): - """ - Implementiert das interaktive Menü zur Modusauswahl oder verarbeitet CLI-Argumente. - Ruft die entsprechenden Methoden der DataProcessor-Instanz auf. - """ - mode_info = None - row_limit = cli_limit - start_row = cli_start_row # Optional für sequenzielle Verarbeitung - # CLI Steps für Re-Eval werden hier verarbeitet - steps_list = [step.strip().lower() for step in (cli_steps.split(',') if cli_steps else [])] + # 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}") - # Definition der Hauptmodi (Numerisch -> Name -> Beschreibung) - # Definieren Sie dieses Dictionary hier oder global - MAIN_MODES = { - 1: {"name": "sequential", "description": "Sequenzielle Zeilenverarbeitung", "requires_limit": True, "requires_start_row": True, "is_single_row_processing_mode": True}, - 2: {"name": "reeval", "description": "Re-evaluate markierte Zeilen (Spalte A='x')", "requires_limit": True, "is_single_row_processing_mode": True}, - 3: {"name": "criteria", "description": "Prozessiere Zeilen, die Kriterien erfüllen", "requires_limit": True, "is_single_row_processing_mode": True}, - 4: {"name": "batch", "description": "Batch-Verarbeitung (Schritt-optimiert)", "requires_limit": True, "is_single_row_processing_mode": False}, - 5: {"name": "dienstprogramme", "description": "Einzelne Dienstprogramme / Suchen", "requires_limit": False, "is_single_row_processing_mode": False}, - } + # 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}") - # --- Modus Auswahl (CLI hat Priorität über Interaktiv) --- - if cli_mode: - # Finde Modus Info basierend auf CLI Name - found_mode = [info for num, info in MAIN_MODES.items() if info["name"] == cli_mode] - if found_mode: - mode_info = found_mode[0] - logging.info(f"Hauptmodus (aus Kommandozeile): {mode_info['name']}") + # 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: - logging.error(f"Ungültiger Hauptmodus '{cli_mode}' via Kommandozeile. Gültige Modi: {', '.join([info['name'] for info in MAIN_MODES.values()])}") - return # Skript beenden bei ungültigem CLI Modus - else: - # Interaktiver Modus - Stufe 1 Menü - print("\n--- Hauptmodus wählen ---") - for num, info in MAIN_MODES.items(): - print(f" {num}: {info['description']}") + 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 - while mode_info is None: try: - choice = input("Geben Sie die Zahl des Hauptmodus ein: ").strip() - mode_num = int(choice) - if mode_num in MAIN_MODES: - mode_info = MAIN_MODES[mode_num] - logging.info(f"Hauptmodus (interaktiv gewählt): {mode_info['name']}") + # 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: - print("Ungültige Zahl. Bitte versuchen Sie es erneut.") - except ValueError: print("Ungültige Eingabe. Bitte geben Sie eine Zahl ein.") - except Exception as e: logging.error(f"Fehler bei Hauptmodus-Eingabe: {e}"); return # Skript beenden + 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.") - # --- Abfrage weiterer Parameter basierend auf Hauptmodus --- + # --- Verarbeitung des Kandidaten ODER Löschen des ungültigen Vorschlags --- + updates_for_row = [] # Updates nur für diese Zeile sammeln - flags_for_steps = None # Wird für Zeilenverarbeitungsmodi gesetzt - selected_batch_mode = None # Wird für Batch-Modus gesetzt - selected_dienstprogramm = None # Wird für Dienstprogramme gesetzt - criteria_func = None # Wird für Kriterien-Modus gesetzt - force_step_reeval = False # Bestimmt force_reeval in _process_single_row für Criteria Mode + 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': [['']]}) - # --- Ebene 2/3: Spezifische Aktion / Schritte wählen / Bereich & Kriterien --- - - # Fall: Zeilenverarbeitung (Sequentiell, Re-Eval, Kriterien) - if mode_info["is_single_row_processing_mode"]: - # --- Schrittauswahl für Zeilenverarbeitung --- - # Wenn Schritte nicht über CLI (--steps) gesetzt wurden, frage interaktiv. - if not cli_steps: - logging.info("\n--- Schritte für Zeilenverarbeitung auswählen ---") - print("\nWelche Verarbeitungsschritte sollen für die Zeilen ausgeführt werden?") - STEP_OPTIONS = { - 1: {"name": "initial_search", "description": "Initial-Suchen (SerpAPI Website/Wiki, LinkedIn Kontakte)", "steps": ['initial_search']}, # Schrittnamen müssen mit denen in _process_single_row übereinstimmen (zukünftig) - 2: {"name": "core_extraction", "description": "Kern-Extraktion (Wiki Daten, Web Scraping, Web Summary)", "steps": ['website', 'wiki']}, # Namen der Gruppen-Flags - 3: {"name": "ki_enrichment", "description": "KI/Logik Anreicherung (Wiki Verify, Branch, FSM, MA Schätzung, Umsatz Schätzung, Konsistenz)", "steps": ['wiki_verify', 'chatgpt']}, # Namen der Gruppen-Flags - 4: {"name": "all_enrichment", "description": "Alle oben genannten Anreicherungs-Schritte (1+2+3)", "steps": ['initial_search', 'website', 'wiki', 'wiki_verify', 'chatgpt']}, # Alle relevanten Gruppen-Flags - # Optional detailliertere Schritte hier einfügen ('wiki_extract', 'wiki_verify', 'branch_eval', 'fsm_eval', 'ma_est', 'umsatz_est', 'ma_cons', 'umsatz_cons', 'website_scrape', 'website_summary', 'website_lookup', 'linkedin_contacts') - } - selected_step_option = None - while selected_step_option is None: - try: - for num, info in STEP_OPTIONS.items(): print(f" {num}: {info['description']}") - step_choice = input("Geben Sie die Zahl der Schrittgruppe ein: ").strip(); step_num = int(step_choice); - if step_num in STEP_OPTIONS: selected_step_option = STEP_OPTIONS[step_num]; steps_list = selected_step_option["steps"]; logging.info(f"Schrittgruppe gewählt: {selected_step_option['name']}"); - else: print("Ungültige Zahl für Schritte."); - except ValueError: print("Ungültige Eingabe."); - except Exception as e: logging.error(f"Fehler bei Schritt-Eingabe: {e}"); return; - - # Mappen der Schritt-String-Liste auf die boolschen Flags für _process_single_row - # Diese Flags müssen mit den Parameternamen von _process_single_row übereinstimmen (process_wiki, process_chatgpt, process_website) - # Im Refactoring werden dies detailliertere Flags in einem Dictionary sein. - # Vorerst: Mappe auf die 3 groben Flags - process_wiki_flag = 'wiki' in steps_list or 'wiki_verify' in steps_list or 'wiki_extraction' in steps_list # Wenn irgendein Wiki-Schritt gewählt - process_chatgpt_flag = 'chatgpt' in steps_list or 'branch_eval' in steps_list or 'fsm_eval' in steps_list or 'ma_est' in steps_list or 'umsatz_est' in steps_list or 'ma_cons' in steps_list or 'umsatz_cons' in steps_list # Wenn irgendein ChatGPT/KI Schritt gewählt - process_website_flag = 'web' in steps_list or 'website_scrape' in steps_list or 'website_summary' in steps_list # Wenn irgendein Website-Schritt gewählt - # Setzen der flags_for_steps für den Aufruf von _process_single_row (mit den 3 groben Flags) - flags_for_steps = { - 'process_wiki': process_wiki_flag, - 'process_chatgpt': process_chatgpt_flag, - 'process_website': process_website_flag - } - # Logge die gemappten Flags - logging.info(f"Gemappte _process_single_row Flags: wiki={process_wiki_flag}, chatgpt={process_chatgpt_flag}, website={process_website_flag}") + 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 - # --- Kriterien Auswahl für 'criteria' Modus --- - if mode_info["name"] == "criteria": - logging.info("\n--- Kriterium für Zeilenauswahl wählen ---") - print("\nWelches Kriterium soll für die Zeilenauswahl angewendet werden?") - CRITERIA_OPTIONS = { - 1: {"name": "m_filled_an_empty", "description": "Wiki URL (M) gefüllt UND Wiki Timestamp (AN) leer", "func": criteria_m_filled_an_empty}, # Globale Funktion - 2: {"name": "ao_empty", "description": "Timestamp letzte Prüfung (AO) leer", "func": criteria_ao_empty}, # Globale Funktion - 3: {"name": "ar_empty", "description": "Website Rohtext (AR) leer", "func": criteria_ar_empty}, # Globale Funktion - 4: {"name": "ax_empty", "description": "Wiki Verif. Timestamp (AX) leer", "func": criteria_ax_empty}, # Globale Funktion - # Fügen Sie hier weitere Kriterien hinzu - 5: {"name": "size_meets_threshold", "description": f"Umsatz CRM > {cli_min_umsatz} MIO € ODER Mitarbeiter CRM > {cli_min_employees}", "func": lambda row_data: criteria_size_meets_threshold(row_data, cli_min_employees, cli_min_umsatz)}, # Kriterium mit Parametern - } - selected_criteria_option = None - while selected_criteria_option is None: - try: - for num, info in CRITERIA_OPTIONS.items(): print(f" {num}: {info['description']}") - criteria_choice = input("Geben Sie die Zahl des Kriteriums ein: ").strip(); criteria_num = int(criteria_choice); - if criteria_num in CRITERIA_OPTIONS: selected_criteria_option = CRITERIA_OPTIONS[criteria_num]; criteria_func = selected_criteria_option["func"]; logging.info(f"Kriterium gewählt: {selected_criteria_option['name']}"); - else: print("Ungültige Zahl für Kriterium."); - except ValueError: print("Ungültige Eingabe."); - except Exception as e: logging.error(f"Fehler bei Kriterien-Eingabe: {e}"); return; - - # Für Criteria Modus: Abfragen, ob force_reeval für Schritte angewendet werden soll - reeval_criteria_input = input("Force re-evaluate (Timestamp/Status ignorieren) für diese Schritte bei passenden Zeilen? (j/N): ").strip().lower() - force_step_reeval = (reeval_criteria_input == 'j') - logging.info(f"Force re-evaluate für Kriterien-Modus Schritte: {force_step_reeval}") + # Sammle die Updates für diese Zeile + all_sheet_updates.extend(updates_for_row) - # Fall: Batch-Verarbeitung (Ebene 1 = 4) - elif mode_info["name"] == "batch": - # Hier das Menü für die Auswahl des Batch-Modus anzeigen - logging.info("\n--- Batch-Modus auswählen ---") - print("\nWelchen Batch-Modus möchten Sie ausführen?") - BATCH_MODES = { - 1: {"name": "wiki_batch", "description": "Wikipedia-Verifizierung (AX)"}, - 2: {"name": "website_scrape_batch", "description": "Website-Scraping Rohtext (AT)"}, - 3: {"name": "summarize_batch", "description": "Website-Zusammenfassung (AS)"}, - 4: {"name": "branch_batch", "description": "Branchen-Einstufung (AO)"}, - # 5: {"name": "combined", "description": "Alle Batch-Modi nacheinander"}, # Optional - } - selected_batch_mode_info = None - while selected_batch_mode_info is None: - try: - for num, info in BATCH_MODES.items(): print(f" {num}: {info['description']}"); - batch_choice = input("Geben Sie die Zahl des Batch-Modus ein: ").strip(); batch_num = int(batch_choice); - if batch_num in BATCH_MODES: selected_batch_mode_info = BATCH_MODES[batch_num]; selected_batch_mode = selected_batch_mode_info["name"]; logging.info(f"Batch-Modus gewählt: {selected_batch_mode}"); - else: print("Ungültige Zahl für Batch-Modus."); - except ValueError: print("Ungültige Eingabe."); - except Exception as e: logging.error(f"Fehler bei Batch-Modus-Eingabe: {e}"); return; + # 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) - # Fall: Dienstprogramme (Ebene 1 = 5) - elif mode_info["name"] == "dienstprogramme": - # Hier das Menü für die Auswahl des Dienstprogramms anzeigen - logging.info("\n--- Dienstprogramm auswählen ---") - print("\nWelches Dienstprogramm möchten Sie ausführen?") - DIENSTPROGRAMM_MODES = { - 1: {"name": "find_wiki_serp", "description": "Finde fehlende Wiki-URLs via SerpAPI"}, - 2: {"name": "website_lookup", "description": "Finde fehlende Website-URLs via SerpAPI"}, - 3: {"name": "contacts", "description": "Suche LinkedIn Kontakte via SerpAPI"}, - 4: {"name": "update_wiki_suggestions", "description": "Übernehme Wiki-Vorschläge aus U nach M"}, - 5: {"name": "train_technician_model", "description": "Trainiere ML Technikermodell"}, - 6: {"name": "alignment", "description": "Schreibe Header (A1:AY5)"}, - # 7: {"name": "website_details", "description": "EXPERIMENTELL: Extrahiere Website-Details für 'x' Zeilen"}, # Ggf. ausblenden - 8: {"name": "wiki_reextract", "description": "Wiki Re-Extraction (M gefüllt, AN leer) - Übergang"}, # Übergangsmodus - } - selected_dienstprogramm_info = None - while selected_dienstprogramm_info is None: - try: - for num, info in DIENSTPROGRAMM_MODES.items(): print(f" {num}: {info['description']}"); - dp_choice = input("Geben Sie die Zahl des Dienstprogramms ein: ").strip(); dp_num = int(dp_choice); - if dp_num in DIENSTPROGRAMM_MODES: selected_dienstprogramm_info = DIENSTPROGRAMM_MODES[dp_num]; selected_dienstprogramm = selected_dienstprogramm_info["name"]; logging.info(f"Dienstprogramm gewählt: {selected_dienstprogramm}"); - else: print("Ungültige Zahl für Dienstprogramm."); - except ValueError: print("Ungültige Eingabe."); - except Exception as e: logging.error(f"Fehler bei Dienstprogramm-Eingabe: {e}"); return; + # --- 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 - # --- Abfrage Limit & Startzeile (Falls noch nicht gesetzt und benötigt) --- - # Limit ist bereits als CLI arg oder interaktiv abgefragt, wenn mode_info["requires_limit"] True ist. - # Startzeile wird nur für Sequenziell benötigt. - - if mode_info["name"] == "sequential" and start_row is None: - try: - start_row_input = input(f"Startzeile (1-basiert) für sequenzielle Verarbeitung? (Enter=automatisch ermitteln): ").strip(); - if start_row_input: - start_row_val = int(start_row_input); - if start_row_val >= 1: start_row = start_row_val; - else: logging.warning("Ungültige Startzeile ignoriert (<=0)."); start_row = None; - else: logging.info("Startzeile wird automatisch ermittelt."); - logging.info(f"Startzeile für sequenzielle Verarbeitung: {start_row if start_row is not None else 'Automatisch'}"); - except ValueError: logging.warning("Ungültige Startzeilen-Eingabe ignoriert."); start_row = None; - except Exception as e: logging.error(f"Fehler bei Startzeilen-Eingabe: {e}"); start_row = None; + 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. - # --- Modus Ausführung basierend auf Auswahl --- + # --- 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) +# ============================================================================== + +# --- 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). +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. + + # 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. + + 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 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"], + # 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"], + # 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)."] + # 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 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): + 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 + num_cols = max(len(row) for row in new_headers) + + + # Hilfsfunktion zum Konvertieren des 1-basierten Spaltenindex in Buchstaben (A, B, AA, ...) + def colnum_string(n): + string = "" + while n > 0: + n, remainder = divmod(n - 1, 26) + string = chr(65 + remainder) + string + return string + + # Berechnen Sie den Bereich für 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: - # Fall: Zeilenverarbeitung (Sequenziell, Re-Eval, Kriterien) - if mode_info["is_single_row_processing_mode"]: - if flags_for_steps is None or not any(flags_for_steps.values()): - logging.warning("Keine Verarbeitungsschritte für Zeilenverarbeitung ausgewählt oder Fehler bei Auswahl. Nichts zu tun."); return; - - if mode_info["name"] == "sequential": - # Sequenzielle Verarbeitung ruft process_sequential Methode auf - # start_row ist die 1-basierte Sheet-Zeile, wird so an process_sequential übergeben. - # process_sequential kümmert sich um die Konvertierung zu Daten-Index und Iteration. - # Wenn start_row None ist, wird es in process_sequential automatisch ermittelt. - data_processor.process_sequential( - start_sheet_row = start_row, # Kann None sein - num_to_process = row_limit, # Kann None sein - process_wiki = flags_for_steps.get('process_wiki', False), # <<< ÜBERGIBT DIE STEUERUNG - process_chatgpt = flags_for_steps.get('process_chatgpt', False), # <<< ÜBERGIBT DIE STEUERUNG - process_website = flags_for_steps.get('process_website', False) # <<< ÜBERGIBT DIE STEUERUNG - ); + # 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. + 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: + logger.error(f"FEHLER beim Schreiben der Alignment-Demo Header in Bereich {header_range}: {e}") + logger.debug(traceback.format_exc()) - elif mode_info["name"] == "reeval": - # Re-Evaluate ruft process_reevaluation_rows Methode auf - # row_limit und flags_for_steps sind bereits gesetzt - data_processor.process_reevaluation_rows( - row_limit = row_limit, - clear_flag = True, # Standardmäßig Flag 'x' löschen - process_wiki_steps = flags_for_steps.get('process_wiki', False), # <<< ÜBERGIBT DIE STEUERUNG - process_chatgpt_steps = flags_for_steps.get('process_chatgpt', False), # <<< ÜBERGIBT DIE STEUERUNG - process_website_steps = flags_for_steps.get('process_website', False) # <<< ÜBERGIBT DIE STEUERUNG - ); +# ============================================================================== +# 8. MAIN FUNCTION (HAUPTEINSTIEGSPUNKT & UI DISPATCHER) +# ============================================================================== - elif mode_info["name"] == "criteria": - # Kriterienbasierte Verarbeitung ruft process_rows_matching_criteria Methode auf - # limit, flags_for_steps, criteria_func, force_step_reeval sind bereits gesetzt - if criteria_func is None: logging.error("FEHLER: Kriterien-Funktion nicht gesetzt. Abbruch."); return; - data_processor.process_rows_matching_criteria( - criteria_func = criteria_func, - limit = row_limit, - process_wiki = flags_for_steps.get('process_wiki', False), - process_chatgpt = flags_for_steps.get('process_chatgpt', False), - process_website = flags_for_steps.get('process_website', False), - force_step_reeval = force_step_reeval - ); - - - # Fall: Batch-Verarbeitung (Ebene 1 = 4) - elif mode_info["name"] == "batch": - if selected_batch_mode is None: logging.warning("Kein Batch-Modus ausgewählt oder Fehler bei Auswahl. Nichts zu tun."); return; - # Batch Dispatcher Methode aufrufen - data_processor.run_batch_dispatcher( - mode = selected_batch_mode, # Gewählten Batch-Modus Namen übergeben - limit = row_limit # Limit übergeben - ); - - # Fall: Dienstprogramme (Ebene 1 = 5) - elif mode_info["name"] == "dienstprogramme": - if selected_dienstprogramm is None: logging.warning("Kein Dienstprogramm ausgewählt oder Fehler bei Auswahl. Nichts zu tun."); return; - - # Aufruf der spezifischen Dienstprogramm-Methode - if selected_dienstprogramm == "find_wiki_serp": - # Parameter werden über CLI args (args.min_umsatz, args.min_employees) oder Defaults genommen - data_processor.process_find_wiki_serp(row_limit=row_limit, min_employees=cli_min_employees, min_umsatz=cli_min_umsatz); # <<< CLI args hier übergeben - elif selected_dienstprogramm == "website_lookup": - data_processor.process_serp_website_lookup(limit=row_limit); - elif selected_dienstprogramm == "contacts": - data_processor.process_contact_research(limit=row_limit); # limit parameter hinzufügen - elif selected_dienstprogramm == "update_wiki_suggestions": - data_processor.process_wiki_updates_from_chatgpt(row_limit=row_limit); - elif selected_dienstprogramm == "train_technician_model": - # Parameter werden über CLI args genommen - data_processor.train_technician_model(model_out=args.model_out, imputer_out=args.imputer_out, patterns_out=args.patterns_out); # <<< CLI args hier übergeben - elif selected_dienstprogramm == "alignment": - # alignment_demo ist global und braucht sheet_handler.sheet - alignment_demo(data_processor.sheet_handler.sheet); - elif selected_dienstprogramm == "website_details": - data_processor.process_website_details(limit=row_limit); # limit parameter hinzufügen - elif selected_dienstprogramm == "wiki_reextract": - # Dies ist der Übergangsmodus, der die temporäre globale Funktion nutzt - # Annahme: process_wiki_reextract_missing_an ist global - process_wiki_reextract_missing_an(data_processor.sheet_handler, data_processor, limit=row_limit); - - except KeyboardInterrupt: logging.warning("Skript durch Benutzer unterbrochen (KeyboardInterrupt)."); print("\n! Skript wurde manuell beendet."); - except Exception as e: logging.critical(f"FATAL: Unerwarteter Fehler während der Ausführung von Modus '{mode_info.get('name', 'Unbekannt')}': {e}"); logging.exception("Traceback des kritischen Fehlers:"); - - -# ==================== MAIN EXECUTION BLOCK ==================== -# Diese Funktion ist der eigentliche Startpunkt des Skripts, wenn die Datei ausgeführt wird. - -# main Funktion def main(): - # WICHTIG: Global LOG_FILE wird benötigt, aber erst nach Arg-Parsing gesetzt. + """ + Haupteinstiegspunkt des Skripts. + Verarbeitet Kommandozeilen-Argumente, richtet Logging ein, + initialisiert Komponenten und dispatchet zu den passenden Modi. + """ + # WICHTIG: Global LOG_FILE wird benötigt global LOG_FILE # --- Initial Logging Setup (Konfiguration von Level und Format) --- - import logging - log_level = logging.DEBUG - log_format = '%(asctime)s - %(levelname)-8s - %(name)-15s - %(message)s' + # 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 + 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 logging.basicConfig(level=log_level, format=log_format, handlers=[]) - console_handler = logging.StreamHandler(); console_handler.setLevel(log_level); console_handler.setFormatter(logging.Formatter(log_format)); - logging.getLogger('').addHandler(console_handler); - # --- Initialisierung (Argument Parser etc.) --- - # Version hier (sollte mit Config.VERSION übereinstimmen) - current_script_version = "v1.6.7" # <<< ANPASSEN, wenn Config.VERSION geändert wird + # Console Handler explizit hinzufügen + 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 - parser = argparse.ArgumentParser(description=f"Firmen-Datenanreicherungs-Skript {current_script_version}"); - # Liste der gültigen Hauptmodi (Namen) für CLI - valid_main_modes = ["sequential", "reeval", "criteria", "batch", "dienstprogramme"]; + # 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).") - parser.add_argument("--mode", type=str, help=f"Hauptbetriebsmodus ({', '.join(valid_main_modes)})"); - parser.add_argument("--limit", type=int, help="Maximale Anzahl zu verarbeitender Zeilen", default=None); - parser.add_argument("--start_row", type=int, help="Startzeile im Sheet (1-basiert) für sequenzielle Modi", default=None); - # NEUES ARGUMENT für den Re-Eval Modus zur Auswahl der Schritte - # Standard ist "wiki,chat,web", um das bisherige Verhalten zu imitieren - # Mögliche Werte für die Schritte: 'wiki', 'chat', 'web' (entsprechend den Parametern in _process_single_row) - # Im Refactoring werden dies detailliertere Namen sein. - parser.add_argument("--steps", type=str, help="Komma-getrennte Liste der Schritte im 'reeval' Modus (z.B. 'wiki,chat,web'). Mögliche Schritte: wiki, chat, web.", default="wiki,chat,web"); + # --- Initialisierung (Argument Parser) --- + current_script_version = getattr(Config, 'VERSION', 'unknown') + + 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 + ) + + # Liste der gültigen Modi - MUSS mit den elif-Zweigen unten übereinstimmen! + # Kategorisiert für die Menü-Ausgabe + mode_categories = { + "Batch-Verarbeitung (Schritt-Optimiert)": [ + "wiki_verify", "website_scraping", "summarize_website", "branch_eval", + ], + "Sequenzielle Verarbeitung (Zeilenweise)": [ + "full_run", + ], + "Re-Evaluate Markierte Zeilen (Spalte A='x')": [ + "reeval", + ], + "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 + ], + "Kombinierte Läufe (Vordefiniert)": [ + "combined_all", # Neuer kombinierter Modus + ] + } + # Erstellen Sie eine flache Liste aller validen Modi + 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" + 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) + + + # 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 + + 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 # Argumente für find_wiki_serp (falls über CLI gesteuert) - parser.add_argument("--min_umsatz", type=int, help="Mindestumsatz in Mio € für find_wiki_serp", default=200); - parser.add_argument("--min_employees", type=int, help="Mindestmitarbeiterzahl für find_wiki_serp", default=500); + 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 Modell (.pkl)"); - parser.add_argument("--imputer_out", type=str, default=IMPUTER_FILE, help=f"Pfad für Imputer (.pkl)"); - parser.add_argument("--patterns_out", type=str, default=PATTERNS_FILE_TXT, help=f"Pfad für Regeln (.txt)"); + 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 - # TODO: Fügen Sie hier weitere CLI-Argumente hinzu, falls andere Modi Parameter benötigen (z.B. für Kriterien-Modus spezifische Parameter) + # TODO: Fügen Sie hier weitere CLI-Argumente hinzu, falls andere Modi Parameter benötigen - args = parser.parse_args(); + args = parser.parse_args() - # Lade API Keys direkt am Anfang - Config.load_api_keys(); # Nutzt jetzt logging intern + # --- Konfiguration laden --- + Config.load_api_keys() # Nutzt jetzt logging intern (print am Anfang) + # --- Logdatei-Konfiguration abschließen --- - log_mode_name = args.mode if args.mode else "interactive"; # Verwenden Sie den CLI Modus Namen, wenn vorhanden - LOG_FILE = create_log_filename(log_mode_name); # Annahme: create_log_filename ist global + # Bestimmen Sie den Log-Modus Namen basierend auf CLI oder Interaktion + log_mode_name = args.mode if args.mode else "interactive" + LOG_FILE = create_log_filename(log_mode_name) # Nutzt globale Funktion + + if LOG_FILE: + try: + # Erstellen Sie den FileHandler + 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 + file_handler.setFormatter(logging.Formatter(log_format)) + # Füge FileHandler zum Root-Logger hinzu + 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}") + # 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)] - try: - file_handler = logging.FileHandler(LOG_FILE, mode='a', encoding='utf-8'); file_handler.setLevel(log_level); file_handler.setFormatter(logging.Formatter(log_format)); - logging.getLogger('').addHandler(file_handler); logging.info(f"Logging wird jetzt auch in Datei geschrieben: {LOG_FILE}"); - except Exception as e: - print(f"[ERROR] Konnte FileHandler für Logdatei '{LOG_FILE}' nicht erstellen: {e}"); - logging.getLogger('').handlers = [h for h in logging.getLogger('').handlers if not isinstance(h, logging.FileHandler)]; - logging.error(f"Konnte FileHandler für Logdatei '{LOG_FILE}' nicht erstellen: {e}"); # --- JETZT die Startmeldungen loggen (gehen jetzt in Konsole UND Datei) --- - logging.info(f"===== Skript gestartet ====="); logging.info(f"Version: {Config.VERSION}"); logging.info(f"Logdatei: {LOG_FILE}"); - if args.mode: logging.info(f"Betriebsmodus (CLI): {args.mode}"); - if args.limit is not None: logging.info(f"CLI Argument --limit: {args.limit}"); - if args.start_row is not None: logging.info(f"CLI Argument --start_row: {args.start_row}"); - if 'steps' in args and args.steps: logging.info(f"CLI Argument --steps: '{args.steps}' (relevant für 'reeval' Modus)"); - if 'min_umsatz' in args: logging.info(f"CLI Argument --min_umsatz: {args.min_umsatz}"); - if 'min_employees' in args: logging.info(f"CLI Argument --min_employees: {args.min_employees}"); - if 'model_out' in args: logging.info(f"CLI Argument --model_out: '{args.model_out}'"); - if 'imputer_out' in args: logging.info(f"CLI Argument --imputer_out: '{args.imputer_out}'"); - if 'patterns_out' in args: logging.info(f"CLI Argument --patterns_out: '{args.patterns_out}'"); + logger.info(f"===== Skript gestartet =====") + logger.info(f"Version: {current_script_version}") + 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, Sheet Handler etc.) --- - load_target_schema(); # Annahme: load_target_schema ist global definiert + sheet_handler = None # Initialisiere Variable + try: + sheet_handler = GoogleSheetHandler() # Initialisiere den Sheet Handler + except Exception as e: + 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 + return # Beende Skript - try: sheet_handler = GoogleSheetHandler(); # Annahme: GoogleSheetHandler ist global definiert - except Exception as e: logging.critical(f"FATAL: Initialisierung GoogleSheetHandlers fehlgeschlagen: {e}"); logging.critical(f"Bitte Logdatei prüfen: {LOG_FILE}"); return; + wiki_scraper = None # Initialisiere Variable + try: + # Initialisiere WikipediaScraper + wiki_scraper = WikipediaScraper() # Annahme: WikipediaScraper ist global definiert + except Exception as e: + logger.critical(f"FATAL: Initialisierung des WikipediaScrapers fehlgeschlagen: {e}") + logger.critical(f"Bitte Logdatei prüfen: {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() + # serpapi_handler = SerpAPIHandler() - try: wiki_scraper = WikipediaScraper(); # Annahme: WikipediaScraper ist global definiert - except Exception as e: logging.critical(f"FATAL: Initialisierung WikipediaScrapers fehlgeschlagen: {e}"); logging.critical(f"Bitte Logdatei prüfen: {LOG_FILE}"); return; # Initialisiere DataProcessor Instanz mit Handlern - data_processor = DataProcessor(sheet_handler, wiki_scraper); # <<< KORRIGIERTER AUFRUF + # Übergeben Sie alle benötigten Handler + data_processor = DataProcessor(sheet_handler=sheet_handler, wiki_scraper=wiki_scraper) # Übergeben Sie die Instanzen - # --- Start der Benutzerinteraktion / Modusausführung --- - start_time = time.time(); logging.info(f"Starte Verarbeitung um {datetime.now().strftime('%H:%M:%S')}..."); + # --- Modusauswahl und Ausführung --- + start_time = time.time() + 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) --- + if args.mode: + selected_mode = args.mode.lower() + 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.") + return # Skript beenden + logger.info(f"Betriebsmodus (CLI gewählt): {selected_mode}") + 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 + 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 + + # Fügen Sie eine Option zum Abbrechen hinzu + print(f"\n 0: Abbrechen") + mode_options_map['0'] = 'exit' + + + while selected_mode is None: # Schleife, bis ein gültiger Modus gewählt wurde + try: + mode_input = input(f"Geben Sie den Modusnamen oder die Zahl ein: ").strip().lower() + + 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.") + + except EOFError: # Benutzer hat Ctrl+D gedrückt + logger.warning("Interaktive Modus-Eingabe abgebrochen (EOFError). Skript wird beendet.") + print("\nEingabe abgebrochen.") + return + except Exception as e: + 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 + + # --- Ausführung des gewählten Modus --- try: - # Rufe die Funktion auf, die das Menü und den Dispatching übernimmt - # Wenn CLI args gesetzt sind, wird das Menü übersprungen. - run_user_interface( - data_processor = data_processor, # Instanz übergeben - cli_mode = args.mode, - cli_limit = args.limit, - cli_start_row = args.start_row, - cli_steps = args.steps, # <<< NEU: steps Argument übergeben - cli_min_umsatz = args.min_umsatz, # <<< NEU: min_umsatz übergeben - cli_min_employees = args.min_employees # <<< NEU: min_employees übergeben - # Weitere CLI args hier übergeben, falls nötig (z.B. für train_technician_model) - ); + # Holen Sie die CLI-Argumente für 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': + 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) + 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.") + 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 + + + # Dispatching basierend auf dem gewählten Modus + logger.info(f"Starte Ausführung des Modus: {selected_mode}") + + if selected_mode == "combined_all": + # Führt die wichtigsten Batch-Modi nacheinander aus + logger.info("--- Start Kombinierter Modus: wiki_verify ---") + 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 ---") + 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 ---") + 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 ---") + 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 + + elif selected_mode == "wiki_verify": + 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": + 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": + 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": + 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. + # 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 + 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") + if start_data_index_no_header == -1: + logger.error("FEHLER bei automatischer Ermittlung der Startzeile.") + # Laden fehlgeschlagen, Skript muss beendet werden + return + + calculated_start_sheet_row = start_data_index_no_header + sheet_handler._header_rows + 1 # 1-basierte Sheet-Zeile + + + # Berechnen Sie die tatsächliche Anzahl der zu verarbeitenden Zeilen im Bereich + # (basierend auf Endzeile und Limit) + total_sheet_rows = len(sheet_handler.get_all_data_with_headers()) + 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] + + # 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) + + + 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 + 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 + 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 + # force_reeval_in_single_row=False # Normalerweise kein Re-Eval 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}.") + + + elif selected_mode == "find_wiki_serp": + # find_wiki_serp nutzt limit, min_employees, min_umsatz und automatische Startzeile (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 + ) + + elif selected_mode == "website_lookup": + # website_lookup sucht leere D. Nutzt limit und scannt ab Zeile 7 standardmäßig. + 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 + ) + + elif selected_mode == "contacts": + # contacts sucht leere AM. Nutzt limit und scannt ab Zeile 7 standardmäßig. + 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 + ) + + elif selected_mode == "update_wiki_suggestions": + # update_wiki_suggestions prüft Status S. Nutzt limit und scannt ab Zeile 7 standardmäßig. + 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 + ) + + 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. + 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 + ) + + elif selected_mode == "website_details": + # website_details sucht 'x' in A. Nutzt limit und scannt ab Zeile 7. + 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 + ) + + + 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.") + print(f"Interner Fehler: Unbekannter Modus '{selected_mode}'.") + + + except KeyboardInterrupt: + 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 + logger.exception("Traceback des kritischen Fehlers:") + 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}") - except KeyboardInterrupt: logging.warning("Skript durch Benutzer unterbrochen (KeyboardInterrupt)."); print("\n! Skript wurde manuell beendet."); - except Exception as e: logging.critical(f"FATAL: Unerwarteter Fehler im Hauptausführungsblock: {e}"); logging.exception("Traceback des kritischen Fehlers:"); # --- Abschluss --- - end_time = time.time(); duration = end_time - start_time; - logging.info(f"Verarbeitung abgeschlossen um {datetime.now().strftime('%H:%M:%S')}."); logging.info(f"Gesamtdauer: {duration:.2f} Sekunden."); logging.info(f"===== Skript beendet ====="); + end_time = time.time() + duration = end_time - start_time + 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 - logging.shutdown(); + logging.shutdown() - # Logfile Pfad für den Nutzer ausgeben - print(f"\nVerarbeitung abgeschlossen. Logfile: {LOG_FILE}"); + # Logfile Pfad für den Nutzer ausgeben (geht auf Konsole) + if LOG_FILE: + print(f"\nVerarbeitung abgeschlossen. Logfile: {LOG_FILE}") + else: + print("\nVerarbeitung abgeschlossen. Es konnte keine Logdatei erstellt werden.") -# ==================== __main__ BLOCK ==================== -# Dieser Block wird ausgeführt, wenn das Skript direkt gestartet wird. +# ============================================================================== +# 9. ENTRY POINT +# ============================================================================== + +# Führt die main-Funktion aus, wenn das Skript direkt gestartet wird if __name__ == '__main__': - # --- Sicherstellen, dass alle globalen Imports hier sind --- - # ... (alle Imports wie am Anfang des Skripts) ... + # 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. - # --- Sicherstellen, dass alle globalen Helfer-Funktionen hier oder importiert sind --- - # Kopieren Sie die Definitionen der globalen Helfer Funktionen hierher oder stellen Sie sicher, dass sie importiert werden können. - # Global Helper Functions: clean_text, simple_normalize_url, normalize_string, - # extract_numeric_value, get_numeric_filter_value, fuzzy_similarity, token_count, - # call_openai_chat, summarize_website_content, evaluate_branche_chatgpt, - # is_valid_wikipedia_article_url, serp_website_lookup, serp_wikipedia_lookup, - # search_linkedin_contacts, get_gender, get_email_address, load_target_schema, - # map_external_branch, alignment_demo, retry_on_failure, create_log_filename, - # debug_print, _process_batch (falls global), - # Kriterien-Funktionen (criteria_m_filled_an_empty, criteria_size_meets_threshold etc.), - # Übergangsfunktionen (process_wiki_reextract_missing_an). + # Die globale Variable LOG_FILE muss vor main() initialisiert werden (z.B. LOG_FILE = None) + # und wird dann in main() gesetzt. - # --- Sicherstellen, dass alle Klassen hier definiert sind --- - # Kopieren Sie die Definitionen der Klassen hierher oder stellen Sie sicher, dass sie importiert werden können. - # Klassen: Config, GoogleSheetHandler, WikipediaScraper, DataProcessor. - - # Die main Funktion aufrufen - main(); \ No newline at end of file + main() \ No newline at end of file