#!/usr/bin/env python3
"""
Automatisiertes Unternehmensbewertungs-Skript - Refactoring v1.7.8
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: Christian Godelmann
Version: v1.7.8

Hinweis zur Struktur:
Dieser Code wird in logischen Bloecken uebermittelt. Fuegen Sie die Bloecke
nacheinander in diese einzige Datei ein, achten Sie sorgfaeltig auf die
Einrueckung. Jeder Block muss auf oberster Ebene eingefuegt werden (keine Einrueckung).
Die Kommentare wie '# =================================================='
markieren den Beginn neuer logischer Sektionen oder Klassen.
"""

# ==============================================================================
# 1. IMPORTS
# ==============================================================================
# Standardbibliotheken
import os
import time
import re
import csv
import json
import pickle
import threading
import traceback
import logging # logging Modul importieren
import argparse
import random # Fuer Jitter im Retry Decorator
from datetime import datetime
from urllib.parse import urlparse, urlencode, unquote
import openai # Sicherstellen, dass openai global importiert wird
# Externe Bibliotheken
import gspread
# Stellen Sie sicher, dass gspread >= 5.0.0 installiert ist, da APIError anders behandelt wird
# (Unser Code sollte mit den neueren Versionen kompatibel sein)
import wikipedia
# Stellen Sie sicher, dass wikipedia-api nicht gleichzeitig installiert ist (Konflikt).
import requests # Fuer HTTP-Anfragen
# Stellen Sie sicher, dass requests >= 2.0.0 installiert ist
from bs4 import BeautifulSoup # Fuer HTML-Parsing
# Stellen Sie sicher, dass lxml oder html5lib installiert ist (empfohlen statt html.parser)
# z.B. pip install lxml
from oauth2client.service_account import ServiceAccountCredentials # gspread dependency
from difflib import SequenceMatcher # Fuer String-Aehnlichkeit
import unicodedata # Fuer Text-Normalisierung

# Bibliotheken fuer Datenanalyse und ML
import pandas as pd
import numpy as np
from imblearn.over_sampling import SMOTE
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.impute import SimpleImputer
from sklearn.tree import DecisionTreeClassifier, export_text
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
import concurrent.futures # Fuer parallele Verarbeitung
from sklearn.model_selection import GridSearchCV
from imblearn.pipeline import Pipeline as ImbPipeline # Alias, um Kollision mit sklearn.pipeline zu vermeiden

# Spezifische externe Tools
try:
    import gender_guesser.detector as gender # Fuer Geschlechtserkennung
    # Initialisieren Sie den Detector einmal global (wird im Helper benutzt)
    gender_detector = gender.Detector()
    print("gender_guesser.Detector initialisiert.")
except ImportError:
    gender = None
    gender_detector = None
    print("gender_guesser Bibliothek nicht gefunden. Geschlechtserkennung deaktiviert.")
except Exception as e:
    gender = None
    gender_detector = None
    print(f"Fehler bei Initialisierung von gender_guesser: {e}. Geschlechtserkennung deaktiviert.")


# Optional: tiktoken fuer Token-Zaehlung (Modus 8)
try:
    import tiktoken
    print("tiktoken importiert.") # Debugging-Ausgabe (geht nur an Konsole vor Logger Setup)
except ImportError:
    tiktoken = None
    print("tiktoken nicht gefunden. Token-Zaehlung wird geschaetzt.") # Debugging-Ausgabe


    # ==============================================================================
    # 2. GLOBALE KONSTANTEN UND KONFIGURATION
    #    (Logisch 'config.py')
    # ==============================================================================

    # --- Dateipfade ---
CREDENTIALS_FILE = "service_account.json"
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" # Enthält Zielschema
LOG_DIR = "Log"

# --- ML Modell Artefakte ---
MODEL_FILE = "technician_decision_tree_model.pkl"
IMPUTER_FILE = "median_imputer.pkl"
PATTERNS_FILE_TXT = "technician_patterns.txt" # Alt (Optional beibehalten)
PATTERNS_FILE_JSON = "technician_patterns.json" # Neu (Empfohlen)

def normalize_for_mapping(text):
    """
    Normalisiert einen String aggressiv für Mapping-Zwecke.
    Muss VOR der Config-Klasse definiert werden, da sie dort verwendet wird.
    """
    if not isinstance(text, str):
        return ""
    text = text.lower()
    text = text.strip()
    text = re.sub(r'[^a-z0-9]', '', text) 
    return text

# --- Globale Konfiguration Klasse ---
class Config:
    """Zentrale Konfigurationseinstellungen."""
    VERSION = "v1.7.8"
    LANG = "de" # Sprache fuer Wikipedia etc.
    # ACHTUNG: SHEET_URL ist hier ein Platzhalter. Ersetzen Sie ihn durch Ihre tatsaechliche URL.
    SHEET_URL = "https://docs.google.com/spreadsheets/d/1u_gHr9JUfmV1-iviRzbSe3575QEp7KLhK5jFV_gJcgo" # <<< ERSETZEN SIE DIES!
    MAX_RETRIES = 5 # Anzahl der Versuche (nicht Wiederholungen nach dem ersten Fehler) fuer wiederholbare Fehler
    RETRY_DELAY = 10 # Basiswartezeit (Sekunden) fuer Retries (exponentieller Backoff wird im Decorator angewendet)
    REQUEST_TIMEOUT = 20 # Timeout (Sekunden) fuer externe HTTP/API Anfragen (Requests)
    SIMILARITY_THRESHOLD = 0.65 # Schwelle fuer Namensaaehnlichkeit bei Wikipedia Validierung
    DEBUG = True # Detailliertes Logging aktivieren/deaktivieren
    WIKIPEDIA_SEARCH_RESULTS = 5 # Anzahl Ergebnisse bei Wikipedia Suche ueber Bibliothek
    HTML_PARSER = "html.parser" # Parser fuer BeautifulSoup ('lxml' ist schneller, erfordert aber Installation)
    TOKEN_MODEL = "gpt-3.5-turbo" # OpenAI Modell fuer Token-Zaehlung/Chat (Standard fuer die meisten Calls)
    USER_AGENT = 'Mozilla/5.0 (compatible; UnternehmenSkript/1.0; +https://www.example.com/bot)' # User-Agent fuer Web Scraping/Requests (Beispiel URL anpassen)


    # --- Konfiguration fuer Batching & Parallelisierung ---
    # Passen Sie diese Werte an die Leistung Ihres Systems und die API-Limits an.
    PROCESSING_BATCH_SIZE = 20          # Anzahl Zeilen pro Verarbeitungs-Batch (fuer _process_single_row in Batches)
    OPENAI_BATCH_SIZE_LIMIT = 4         # Max. Texte pro OpenAI Call fuer Zusammenfassung (nur fuer summarize_batch_openai)
    MAX_SCRAPING_WORKERS = 10           # Threads fuer paralleles Website-Scraping
    UPDATE_BATCH_ROW_LIMIT = 50         # Zeilen sammeln fuer gebuendelte Sheet Updates (effizienter)
    MAX_BRANCH_WORKERS = 10             # Threads fuer parallele Branchenbewertung
    OPENAI_CONCURRENCY_LIMIT = 3        # Max. gleichzeitige OpenAI Calls (Semaphore fuer Branch Evaluation)
    PROCESSING_BRANCH_BATCH_SIZE = 20   # Batch-Groesse fuer Branch-Evaluierung
    SERPAPI_DELAY = 1.5                 # Pause zwischen einzelnen SerpAPI-Aufrufen (Sekunden)


    PLAUSI_UMSATZ_MIN_WARNUNG = 50000 
    PLAUSI_UMSATZ_MAX_WARNUNG = 200000000000 
    PLAUSI_MA_MIN_WARNUNG_ABS = 1
    PLAUSI_MA_MIN_WARNUNG_BEI_UMSATZ = 3
    PLAUSI_UMSATZ_MIN_SCHWELLE_FUER_MA_CHECK = 1000000
    PLAUSI_MA_MAX_WARNUNG = 1000000
    PLAUSI_RATIO_UMSATZ_PRO_MA_MIN = 25000
    PLAUSI_RATIO_UMSATZ_PRO_MA_MAX = 1500000
    PLAUSI_ABWEICHUNG_CRM_WIKI_PROZENT = 30

    # Hartcodiertes Mapping von Detail-Branche zu Branchen-Gruppe
    # Key: Normalisierte Detail-Branche (wie sie von der KI kommt)
    # Value: Die gewünschte Branchen-Gruppe
    BRANCH_GROUP_MAPPING = {
        normalize_for_mapping("Baustoffhandel"): "Baubranche",
        normalize_for_mapping("Bauunternehmen"): "Baubranche",
        normalize_for_mapping("Versicherungsgutachten"): "Gutachter / Versicherungen",
        normalize_for_mapping("Technische Gutachten"): "Gutachter / Versicherungen",
        normalize_for_mapping("Baugutachter"): "Gutachter / Versicherungen",
        normalize_for_mapping("Medizinische Gutachten"): "Gutachter / Versicherungen",
        normalize_for_mapping("Energie (Brennstoffe)"): "Handel",
        normalize_for_mapping("Großhandel"): "Handel",
        normalize_for_mapping("Einzelhandel"): "Handel",
        normalize_for_mapping("Automaten (Vending / Slot)"): "Hersteller / Produzenten",
        normalize_for_mapping("Anlagenbau"): "Hersteller / Produzenten",
        normalize_for_mapping("IT / Telekommunikation"): "Hersteller / Produzenten", # Anmerkung: Diese Kategorie existiert doppelt, prüfen Sie ob das gewollt ist.
        normalize_for_mapping("Maschinenbau"): "Hersteller / Produzenten",
        normalize_for_mapping("Chemie & Pharma"): "Hersteller / Produzenten",
        normalize_for_mapping("Medizintechnik"): "Hersteller / Produzenten",
        normalize_for_mapping("Agrar / Pellets"): "Hersteller / Produzenten",
        normalize_for_mapping("Elektrotechnik"): "Hersteller / Produzenten",
        normalize_for_mapping("Gebäudetechnik Allgemein"): "Hersteller / Produzenten", # Wird zu Gebäudetechnik-Gruppe gemappt
        normalize_for_mapping("Fenster / Glas"): "Hersteller / Produzenten",
        normalize_for_mapping("Lebensmittelproduktion"): "Hersteller / Produzenten",
        normalize_for_mapping("Automobil"): "Hersteller / Produzenten",
        normalize_for_mapping("Gebäudetechnik Heizung / Lüftung / Klima"): "Hersteller / Produzenten", # Wird zu Gebäudetechnik-Gruppe gemappt
        normalize_for_mapping("Braune & Weiße Ware"): "Hersteller / Produzenten",
        normalize_for_mapping("Bürotechnik"): "Hersteller / Produzenten",
        normalize_for_mapping("Möbel"): "Hersteller / Produzenten",
        normalize_for_mapping("Getränke"): "Hersteller / Produzenten",
        normalize_for_mapping("Sozialbau Unternehmen"): "Housing",
        normalize_for_mapping("Renovierungsunternehmen"): "Housing",
        normalize_for_mapping("Anbieter für Soziales Wohnen"): "Housing",
        normalize_for_mapping("Logistik / Sonstige"): "Logistik",
        normalize_for_mapping("Auslieferdienste"): "Logistik",
        normalize_for_mapping("Logistik"): "Logistik",
        normalize_for_mapping("Facility Management"): "Service provider (Dienstleister)",
        normalize_for_mapping("Servicedienstleister / Reparatur ohne Produktion"): "Service provider (Dienstleister)",
        normalize_for_mapping("Feuer- und Sicherheitssysteme"): "Service provider (Dienstleister)",
        normalize_for_mapping("Healthcare/Pflegedienste"): "Service provider (Dienstleister)",
        normalize_for_mapping("Schädlingsbekämpfung"): "Service provider (Dienstleister)",
        normalize_for_mapping("Entsorgung"): "Service provider (Dienstleister)",
        normalize_for_mapping("Personentransport"): "Service provider (Dienstleister)",
        normalize_for_mapping("Messdienstleister"): "Service provider (Dienstleister)",
        normalize_for_mapping("Aufzüge und Rolltreppen"): "Service provider (Dienstleister)",
        normalize_for_mapping("Catering Services"): "Service provider (Dienstleister)",
        normalize_for_mapping("Sonstige"): "Sonstige",
        normalize_for_mapping("IT Beratung"): "Sonstige",
        normalize_for_mapping("Unternehmensberatung"): "Sonstige",
        normalize_for_mapping("Sonstiger Service"): "Sonstige",
        normalize_for_mapping("Öffentliche Verwaltung"): "Sonstige",
        normalize_for_mapping("Engineering"): "Sonstige",
        normalize_for_mapping("Telekommunikation"): "Versorger",
        normalize_for_mapping("Verteilnetzbetreiber"): "Versorger",
        normalize_for_mapping("Stadtwerke"): "Versorger",
        normalize_for_mapping("Gase & Mineralöl"): "Versorger",
    }

    # --- API Schluessel Speicherung (werden in main() geladen) ---
    API_KEYS = {}

    @classmethod
    def load_api_keys(cls):
        """Laedt API-Schluessel aus den definierten Dateien."""
        print("Lade API-Schluessel...")
        cls.API_KEYS['openai'] = cls._load_key_from_file(API_KEY_FILE)
        cls.API_KEYS['serpapi'] = cls._load_key_from_file(SERP_API_KEY_FILE)
        cls.API_KEYS['genderize'] = cls._load_key_from_file(GENDERIZE_API_KEY_FILE)

        # import openai # <--- DIESER IMPORT IST NUN ENTFERNT

        if cls.API_KEYS.get('openai'):
            # Stelle sicher, dass das 'openai' Modul hier im Scope ist,
            # indem wir auf den globalen Import zugreifen.
            # Da 'openai' schon global importiert wurde (ganz oben im Skript),
            # ist es hier direkt verfügbar.
            openai.api_key = cls.API_KEYS['openai']
            print("OpenAI API Key erfolgreich geladen.")
        else:
            print("WARNUNG: OpenAI API Key konnte nicht geladen werden (Datei fehlt oder ist leer?). OpenAI-Funktionen sind deaktiviert.")

        if not cls.API_KEYS.get('serpapi'):
             print("WARNUNG: SerpAPI Key konnte nicht geladen werden (Datei fehlt oder ist leer?). Bestimmte Suchfunktionen sind deaktiviert.")
        if not cls.API_KEYS.get('genderize'):
             print("WARNUNG: Genderize API Key konnte nicht geladen werden (Datei fehlt oder ist leer?). Geschlechtserkennung ist eingeschraenkt.")


    @staticmethod
    def _load_key_from_file(filepath):
        """Hilfsfunktion zum Laden eines Schluessels aus einer Datei."""
        try:
            # Verwenden Sie "r" fuer Textmodus und geben Sie das Encoding an
            with open(filepath, "r", encoding="utf-8") as f:
                key = f.read().strip()
                if key:
                    return key
                else:
                    print(f"WARNUNG: Datei '{filepath}' ist leer.")
                    return None
        except FileNotFoundError:
            print(f"INFO: API-Schluesseldatei '{filepath}' nicht gefunden.")
            return None
        except Exception as e:
            print(f"FEHLER beim Lesen der Schluesseldatei '{filepath}': {e}")
            return None


    # --- Globale Spalten-Mapping (WICHTIG: MUSS ZU IHREM SHEET PASSEN!) ---
    # --- Globale Spalten-Mapping (WICHTIG: MUSS ZU IHREM SHEET PASSEN!) ---
    # Version 1.7.6 - 57 Spalten (A-BE)
COLUMN_MAP = {
    # CRM-Daten Teil 1 (A-C)
    "ReEval Flag": 0, "CRM Name": 1, "CRM Kurzform": 2,
    
    # Parent Account Info (D)
    "Parent Account Name": 3,           # D (NEU)

    # CRM-Daten Teil 2 (E-N), alte D-M verschoben um +1
    "CRM Website": 4,                   # E 
    "CRM Ort": 5,                       # F 
    "CRM Land": 6,                      # G 
    "CRM Beschreibung": 7,              # H 
    "CRM Branche": 8,                   # I 
    "CRM Beschreibung Branche extern": 9, # J 
    "CRM Anzahl Techniker": 10,          # K 
    "CRM Umsatz": 11,                   # L 
    "CRM Anzahl Mitarbeiter": 12,         # M 
    "CRM Vorschlag Wiki URL": 13,       # N 

    # System Vorschlag Parent & Status & Timestamp (O-Q)
    "System Vorschlag Parent Account": 14, # O (NEU)
    "Parent Vorschlag Status": 15,       # P (NEU)
    "Parent Vorschlag Timestamp": 16,    # Q (NEU)

    # Wikipedia-Daten & -Status (R-AG), alte N-AB verschoben um +4
    "Wiki URL": 17,                     # R 
    "Wiki Sitz Stadt": 18,              # S 
    "Wiki Sitz Land": 19,               # T 
    "Wiki Absatz": 20,                  # U 
    "Wiki Branche": 21,                 # V 
    "Wiki Umsatz": 22,                  # W 
    "Wiki Mitarbeiter": 23,             # X 
    "Wiki Kategorien": 24,              # Y 
    "Wikipedia Timestamp": 25,          # Z 
    "Wiki Verif. Timestamp": 26,        # AA 
    "SerpAPI Wiki Search Timestamp": 27, # AB 
    "Chat Wiki Konsistenzpruefung": 28,   # AC 
    "Chat Begruendung Wiki Inkonsistenz": 29, # AD 
    "Chat Vorschlag Wiki Artikel": 30,    # AE 
    "Begruendung bei Abweichung": 31,      # AF 

    # Website-Daten (AH-AL), alte AC-AG verschoben um +4
    "Website Rohtext": 32,              # AG 
    "Website Zusammenfassung": 33,      # AH 
    "Website Meta-Details": 34,         # AI 
    "Website Scrape Timestamp": 35,     # AJ 
    "URL Prüfstatus": 36,               # AK 

    # ChatGPT Branchen- & weitere Schätzungen (AM-AY), alte AH-AT verschoben um +4
    "Chat Vorschlag Branche": 37,       # AL 
    "Chat Branche Konfidenz": 38,       # AM 
    "Chat Konsistenz Branche": 39,      # AN 
    "Chat Begruendung Abweichung Branche": 40, # AO 
    "Chat Pruefung FSM Relevanz": 41,    # AP 
    "Chat Begruendung für FSM Relevanz": 42, # AQ 
    "Chat Schaetzung Anzahl Mitarbeiter": 43, # AR 
    "Chat Konsistenzprüfung Mitarbeiterzahl": 44, # AS 
    "Chat Begruendung Abweichung Mitarbeiterzahl": 45, # AT 
    "Chat Einschätzung Anzahl Servicetechniker": 46, # AU 
    "Chat Begruendung Abweichung Anzahl Servicetechniker": 47, # AV 
    "Chat Schätzung Umsatz": 48,        # AW 
    "Chat Begruendung Abweichung Umsatz": 49, # AX 
    
    # LinkedIn-Kontakte (AZ-BC), alte AU-AX verschoben um +4
    "Linked Serviceleiter gefunden": 50,  # AY 
    "Linked It-Leiter gefunden": 51,      # AZ 
    "Linked Management gefunden": 52,     # BA 
    "Linked Disponent gefunden": 53,      # BB 

    # Timestamps, Konsolidierte Werte, ML & Plausi (BD-BP)
    "Contact Search Timestamp": 54,         # BC
    "Finaler Umsatz (Wiki>CRM)": 55,      # BD
    "Finaler Mitarbeiter (Wiki>CRM)": 56, # BE
    "Geschaetzter Techniker Bucket": 57,  # BF
    "Plausibilität Umsatz": 58,             # BG
    "Plausibilität Mitarbeiter": 59,        # BH
    "Plausibilität Umsatz/MA Ratio": 60,    # BI
    "Abweichung Umsatz CRM/Wiki": 61,       # BJ
    "Abweichung MA CRM/Wiki": 62,           # BK
    "Plausibilität Begründung": 63,         # BL
    "Plausibilität Prüfdatum": 64,          # BM
    "Timestamp letzte Pruefung": 65,     # BN (ChatGPT Eval TS)
    "Version": 66,                      # BO 
    "Tokens": 67,                       # BP 
}

    # --- Globale Variablen fuer Branch Mapping (werden von load_target_schema() befuellt) ---
BRANCH_MAPPING = {}
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar."
ALLOWED_TARGET_BRANCHES = []
FOCUS_TARGET_BRANCHES = [] # NEU
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar."
FOCUS_BRANCHES_PROMPT_PART = "" # NEU: Für den Prompt-Teil der Fokusbranchen

    # Marker für URLs, die erneut per SERP gesucht werden sollen
URL_CHECK_MARKER = "URL_CHECK_NEEDED" # <<< NEU HINZUFÜGEN

    # Liste gängiger User-Agents für Rotation
USER_AGENTS = [
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36',
    'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36',
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
    'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
    'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
    'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15',
    'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15',
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/109.0',
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:108.0) Gecko/20100101 Firefox/108.0',
    'Mozilla/5.0 (X11; Linux i686; rv:108.0) Gecko/20100101 Firefox/108.0',
    'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:108.0) Gecko/20100101 Firefox/108.0',
]

    # ==============================================================================
    # Ende Basis-Setup Block
    # ==============================================================================

    # ==============================================================================
    # GLOBALE HELPER FUNCTIONS (PART 1: Retry Decorator)
    # ==============================================================================

    # Imports für den Retry Decorator (um NameErrors zu vermeiden)
from openai.error import AuthenticationError, OpenAIError, RateLimitError, APIError, Timeout, InvalidRequestError, ServiceUnavailableError # Beispielhafte spezifische Fehler

    # Logger fuer den Retry Decorator selbst (Nutzt den globalen Root Logger)
decorator_logger = logging.getLogger(__name__ + ".Retry")

    # --- Retry Decorator ---
    # KORRIGIERTE Version (Behandelt SpreadsheetNotFound und 404/400/401/403 HTTPError explizit)
def retry_on_failure(func):
    """
    Decorator, der eine Funktion bei bestimmten Fehlern mehrmals wiederholt.
    Implementiert exponentiellen Backoff mit Jitter.
    """
    def wrapper(*args, **kwargs):
        func_name = func.__name__
        # Versuche, das 'self' Argument fuer Methoden zu extrahieren, falls vorhanden
        self_arg = args[0] if args and hasattr(args[0], func_name) and isinstance(args[0], object) else None
        # Konstruiere einen aussagekraeftigeren Funktionsnamen fuer die Logs
        effective_func_name = f"{self_arg.__class__.__name__}.{func_name}" if self_arg else func_name

        # Basiswartezeit und maximale Anzahl Versuche aus Config holen
        max_retries_config = getattr(Config, 'MAX_RETRIES', 3) # Anzahl der Versuche (nicht Wiederholungen nach dem ersten Fehler)
        base_delay = getattr(Config, 'RETRY_DELAY', 5)

        # Wenn max_retries_config 0 oder weniger ist, einfach einmal ausfuehren
        if max_retries_config <= 0:
             try:
                 return func(*args, **kwargs)
             except Exception as e:
                 # Fehler loggen und weitergeben, wenn keine Retries konfiguriert sind
                 decorator_logger.error(f"FEHLER bei '{effective_func_name}' (keine Retries konfiguriert). {type(e).__name__} - {str(e)[:150]}...")
                 # Log traceback fuer unerwartete Fehler (nicht die spezifischen API/Netzwerkfehler)
                 if not isinstance(e, (requests.exceptions.RequestException, gspread.exceptions.APIError, OpenAIError, wikipedia.exceptions.WikipediaException)): # <<< GEÄNDERT (openai.error.OpenAIError -> OpenAIError)
                      decorator_logger.exception("Details zum Fehler:")
                 raise e # Re-raise the exception


        # --- Retry logic for max_retries_config > 0 ---
        # Die Schleife laeuft max_retries_config mal.
        for attempt in range(max_retries_config):
            try:
                # Logge jeden Versuch, ausser den ersten (optional, um Log-Laerm zu reduzieren)
                if attempt > 0:
                    decorator_logger.warning(f"Wiederhole Versuch {attempt + 1}/{max_retries_config} fuer '{effective_func_name}'...")

                return func(*args, **kwargs) # Call the original function

            # Spezifische Exceptions, die ein Retry nicht rechtfertigen (permanente Fehler)
            except (gspread.exceptions.SpreadsheetNotFound, AuthenticationError, ValueError) as e: # <<< GEÄNDERT (openai.error.AuthenticationError -> AuthenticationError)
                 # Diese Fehler deuten auf ein permanentes Problem hin (falsche URL, falscher Key, falsche Eingabe)
                 decorator_logger.critical(f"❌ ENDGUELTIGER FEHLER bei '{effective_func_name}': Permanentes Problem erkannt. {type(e).__name__} - {str(e)[:150]}...")
                 decorator_logger.exception("Details:") # Log traceback fuer permanente Fehler
                 raise e # Leiten Sie diese Exception sofort weiter

            # Fangen Sie Requests HTTP Errors (wie 404)
            except requests.exceptions.HTTPError as e:
                 if hasattr(e, 'response') and e.response is not None:
                     status_code = e.response.status_code
                     # Definieren Sie hier eine Liste von Status-Codes, die NICHT wiederholt werden sollen
                     non_retryable_status_codes = [404, 400, 401, 403] # Not Found, Bad Request, Unauthorized, Forbidden

                     if status_code in non_retryable_status_codes:
                         decorator_logger.critical(f"❌ ENDGUELTIGER FEHLER bei '{effective_func_name}': HTTP Fehler {status_code} erhalten ({e.response.reason}). Nicht wiederholbar. {str(e)[:100]}...") # Kuerzer im Log
                         decorator_logger.exception("Details:") # Log traceback
                         raise e # Leiten Sie diese nicht-wiederholbare Exception sofort weiter
                     # Ansonsten behandle HTTP Errors wie andere RequestExceptions (weiter unten)

                 # Wenn kein Response-Objekt oder kein spezifischer Statuscode gehandhabt wurde,
                 # lassen Sie diesen Fehler durchfallen zur allgemeinen RequestException Behandlung.


            # Fangen Sie andere wiederholbare Exceptions (Netzwerk, Rate Limit, Timeout etc.)
            except (requests.exceptions.RequestException, gspread.exceptions.APIError, OpenAIError, wikipedia.exceptions.WikipediaException) as e: # <<< GEÄNDERT (openai.error.OpenAIError -> OpenAIError)
                 error_msg = str(e)
                 error_type = type(e).__name__

                 if attempt < max_retries_config - 1: # Wenn nicht der letzte Versuch
                     wait_time = base_delay * (2 ** attempt) + random.uniform(0, 1) # Exponentieller Backoff mit Jitter
                     # Loggen Sie den spezifischen Fehler und die Wartezeit
                     if isinstance(e, RateLimitError): # <<< GEÄNDERT (Prüfung auf spezifischen OpenAI Fehler)
                         decorator_logger.warning(f"🚦 RATE LIMIT ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...")
                     elif isinstance(e, Timeout) and isinstance(e, OpenAIError): # <<< GEÄNDERT (Prüfung auf OpenAI Timeout)
                          decorator_logger.warning(f"⏰ OPENAI TIMEOUT ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...")
                     elif isinstance(e, gspread.exceptions.APIError) and hasattr(e, 'response') and e.response is not None and e.response.status_code == 429: # <<< GEÄNDERT (Gspread Rate Limit Check)
                         decorator_logger.warning(f"🚦 GSPREAD RATE LIMIT ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...")
                     elif isinstance(e, requests.exceptions.Timeout):
                         decorator_logger.warning(f"⏰ REQUESTS TIMEOUT ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...")
                     elif isinstance(e, requests.exceptions.RequestException): # Allgemeine RequestException
                         decorator_logger.warning(f"🌐 NETZWERKFEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...")
                     elif isinstance(e, OpenAIError): # Allgemeine OpenAI Fehler
                         decorator_logger.warning(f"🤖 OPENAI FEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...")
                     elif isinstance(e, wikipedia.exceptions.WikipediaException): # Allgemeine Wikipedia Fehler
                          decorator_logger.warning(f"📚 WIKIPEDIA FEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...")
                     else: # Andere wiederholbare Exceptions
                          decorator_logger.warning(f"♻️ WIEDERHOLBARER FEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...")

                     time.sleep(wait_time) # Warte vor dem nächsten Versuch
                 else: # Letzter Versuch fehlgeschlagen
                     decorator_logger.error(f"❌ ENDGUELTIGER FEHLER bei '{effective_func_name}' nach {max_retries_config} Versuchen.")
                     raise e # Leite die ursprüngliche Exception weiter

            except Exception as e:
                # Fangen Sie sofort alle anderen unerwarteten Exceptions ab (z. B. Programmierfehler)
                # Diese sollten nicht wiederholt werden.
                decorator_logger.critical(f"💥 UNERWARTETER FEHLER ({type(e).__name__}) bei '{effective_func_name}'. KEIN RETRY VERSUCHT.")
                decorator_logger.exception("Details zum unerwarteten Fehler:") # Loggen Sie den vollständigen Traceback
                raise e # Leiten Sie die Exception sofort weiter

        # Dieser Teil sollte theoretisch nicht erreicht werden, wenn max_retries_config > 0
        # und eine Exception immer zu einer raise e Anweisung führt.
        raise RuntimeError(f"Retry decorator logic error: Loop completed unexpectedly for {effective_func_name}. This should not happen.")

    return wrapper # Gibt die Wrapper-Funktion zurück

    # ==============================================================================
    # Ende Retry Decorator Block
    # ==============================================================================

    # ==============================================================================
    # GLOBALE HELPER FUNCTIONS (PART 2: Logging & Token Count)
    # ==============================================================================

    # --- Token Count Funktion ---
def token_count(text, model=None):
    """Zaehlt Tokens via tiktoken oder schaetzt ueber Leerzeichen."""
    logger = logging.getLogger(__name__) # Logger-Instanz holen
    if not text or not isinstance(text, str): return 0
    # ... (Rest der token_count Funktion wie in Block 3/36 gesendet) ...
    current_model = model if model else getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo')
    if tiktoken:
        try:
            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 fuer Modell '{current_model}': {e} - Fallback zur Schaetzung.")
            return len(str(text).split())
    else:
        return len(str(text).split())


    # --- Logging Helpers ---
LOG_FILE = None # Initialisierung

def create_log_filename(mode):
    """Erstellt einen zeitgestempelten Logdateinamen im LOG_DIR."""
    logger = logging.getLogger(__name__) # Logger-Instanz holen
    log_dir_path = LOG_DIR
    # ... (Rest der create_log_filename Funktion wie in Block 3/36 gesendet) ...
    if not os.path.exists(log_dir_path):
        try:
            os.makedirs(log_dir_path, exist_ok=True)
            logger.info(f"Log-Verzeichnis '{log_dir_path}' erstellt.")
        except Exception as e:
            logger.error(f"FEHLER: Konnte Log-Verzeichnis '{log_dir_path}' nicht erstellen: {e}")
            log_dir_path = "."
            logger.warning(f"Versuche, Logdatei im aktuellen Verzeichnis '{log_dir_path}' zu erstellen.")
    try:
        now = datetime.now().strftime("%d-%m-%Y_%H-%M")
        ver_short = getattr(Config, 'VERSION', 'unknown').replace(".", "")
        filename = f"{now}_{ver_short}_Modus{mode}.txt"
        return os.path.join(log_dir_path, filename)
    except Exception as e_fallback:
         logger.error(f"FEHLER: Konnte Logdateinamen auch im Fallback-Verzeichnis '{log_dir_path}' nicht erstellen: {e_fallback}")
         return None

    # ==============================================================================
    # Ende Grundlegende Helfer Block
    # ==============================================================================

    # ==============================================================================
    # GLOBALE HELPER FUNCTIONS (PART 2: Text, String & URL Utilities)
    # ==============================================================================

    # --- Text Normalisierung & Reinigung ---
    # Basierend auf Code aus Teil 3.
    # Nutzt globale Helfer: re, unicodedata.
def simple_normalize_url(url):
    """Normalisiert URL zu domain.tld oder k.A. (ohne www, ohne Pfad)."""
    # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
    logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
    if not url or not isinstance(url, str): return "k.A."
    url = url.strip()
    # Pruefe auf Kleinbuchstaben "k.A." und leere Strings nach dem Strippen
    if not url or url.lower() == 'k.a.': return "k.A."
    # Falls kein Schema vorhanden ist, hinzufuegen (HTTPS bevorzugen)
    if not url.lower().startswith(("http://", "https://")):
        url = "https://" + url # Fuege HTTPS als Standard hinzu
    try:
        # Parse die URL in ihre Komponenten
        parsed = urlparse(url)
        # Extrahieren Sie den Netzwerkteil (Domain und Port)
        domain_part = parsed.netloc
        # Wenn der Netzwerkteil leer ist, kann die URL nicht normalisiert werden
        if not domain_part:
            logger.debug(f"URL '{url[:100]}...' konnte nicht sinnvoll geparst werden (leerer netloc).")
            return "k.A."
        # Port entfernen, falls vorhanden (z.B. :8080)
        domain_part = domain_part.split(":", 1)[0]
        # Entferne User/Passwort-Teile, falls vorhanden (user:pass@domain)
        if '@' in domain_part:
             domain_part = domain_part.split('@', 1)[1]

        # Wandle Punycode (IDN) in Unicode um fuer Lesbarkeit (z.B. xn--...)
        try:
            # Versuchen Sie die IDNA-Dekodierung
            domain_part = domain_part.encode('ascii').decode('idna')
        except UnicodeDecodeError:
             # Logge eine Warnung, wenn die Dekodierung fehlschlaegt, aber behalte den Original-Domain-Teil
             # logger.warning(f"Fehler bei IDNA-Dekodierung fuer Domain '{domain_part}' aus URL '{url[:100]}...'. Behalte Original.") # Zu viel Laerm im Debug
             pass # Behalte den urspruenglichen domain_part, wenn Dekodierung fehlschlaegt

        # Konvertiere den Domain-Teil zu Kleinbuchstaben
        domain_part = domain_part.lower()

        # Optional: "www." am Anfang entfernen (optional, kann an Praeferenz angepasst werden)
        if domain_part.startswith("www."):
             # Stellen Sie sicher, dass die Domain nach dem Entfernen nicht leer ist (z.B. nur "www.")
             domain_part = domain_part[4:]

        # Einfache Pruefung auf mindestens einen Punkt (Basic TLD check)
        # Stellen Sie auch sicher, dass der Domain-Teil nach Bearbeitung nicht leer ist.
        # Eine einfache Pruefung auf das Vorhandensein eines Punktes und alphabetische Zeichen am Ende der TLD.
        if domain_part and '.' in domain_part:
             # Pruefen Sie, ob der Teil nach dem letzten Punkt (die TLD) aus Buchstaben besteht und mindestens 2 Zeichen lang ist.
             parts = domain_part.split('.')
             if len(parts) > 1 and parts[-1].isalpha() and len(parts[-1]) >= 2:
                 # Rueckgabe des normalisierten Domain-Teils
                 return domain_part
             else:
                  # Wenn die TLD-Pruefung fehlschlaegt
                  logger.debug(f"URL '{url[:100]}...' normalisiert zu '{domain_part}', aber TLD-Pruefung schlug fehl.")
                  return "k.A."
        else:
             # Wenn kein Punkt im Domain-Teil ist oder er leer ist
             logger.debug(f"URL '{url[:100]}...' normalisiert zu '{domain_part}', enthaelt keinen Punkt oder ist leer.")
             return "k.A."

    except Exception as e:
        # Fange unerwartete Fehler beim Parsen oder Bearbeiten der URL ab
        logger.error(f"Fehler bei URL-Normalisierung fuer '{url[:100]}...': {e}")
        # Rueckgabe eines Fehlerwerts
        return "k.A. (Fehler Normalisierung)"


def normalize_string(s):
    """Normalisiert Umlaute und Sonderzeichen nach einer definierten Liste."""
    # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
    logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
    if not s or not isinstance(s, str): return ""
    # Ersetzungen fuer gaengige deutsche Umlaute und Sonderzeichen
    replacements = { 'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue', 'ß': 'ss', 'ä': 'ae', 'ö': 'oe', 'ü': 'ue',
                     'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Å': 'A', 'Æ': 'AE', 'à': 'a', 'á': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'æ': 'ae',
                     'Ç': 'C', 'ç': 'c', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e',
                     'Ì': 'I', 'Í': 'I', 'Î': 'I', 'Ï': 'I', 'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i', 'Ñ': 'N', 'ñ': 'n',
                     'Ò': 'O', 'Ó': 'O', 'Ô': 'O', 'Õ': 'O', 'Ø': 'O', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', 'ø': 'o', 'Œ': 'OE', 'œ': 'oe',
                     'Š': 'S', 'š': 's', 'Ž': 'Z', 'ž': 'z', 'Ý': 'Y', 'ý': 'y', 'ÿ': 'y', 'Đ': 'D', 'đ': 'd',
                     'č': 'c', 'Č': 'C', 'ć': 'c', 'Ć': 'C', 'ł': 'l', 'Ł': 'L', 'ğ': 'g', 'Ğ': 'G', 'ş': 's', 'Ş': 'S',
                     'ă': 'a', 'Ă': 'A', 'ı': 'i', 'İ': 'I', 'ň': 'n', 'Ň': 'N', 'ř': 'r', 'Ř': 'R', 'ő': 'o', 'Ő': 'O', 'ű': 'u', 'Ű': 'U', 'ț': 't', 'Ț': 'T', 'ș': 's', 'Ș': 'S'
                   }
    # Versuche unicodedata Normalisierung zuerst, um Akzente etc. zu entfernen, die nicht in der Liste sind
    try:
        # 'NFKD' zerlegt Zeichen in ihre Basisform + kombinierende Zeichen (z.B. 'ä' -> 'a', '¨').
        # .encode('ascii', 'ignore') entfernt dann die kombinierenden Zeichen.
        s = unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode('ascii')
    except Exception as e:
         # Logge Fehler bei der unicodedata Normalisierung, aber fahre mit manuellen Ersetzungen fort
         logger.debug(f"Fehler bei unicodedata Normalisierung fuer '{str(s)[:50]}...': {e}")
         pass # Faert mit dem urspruenglichen String fort, wenn unicodedata fehlschlaegt

    # Dann manuelle Ersetzungen fuer spezifische Faelle (wie Umlaute -> Ae etc.)
    for src, target in replacements.items():
        s = s.replace(src, target)

    return s


def clean_text(text):
    """
    Bereinigt Text (Unicode, Referenzen, Whitespace, etc.) von Wikipedia, Websites etc.
    Entfernt gaengige unerwuenschte Muster wie [1], [Bearbeiten].
    """
    # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
    logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
    if text is None: return "k.A." # Behandle None explizit
    try:
        text = str(text) # Sicherstellen, dass es ein String ist
        if not text.strip(): return "k.A." # Leere oder nur Whitespace-Strings

        # Normalisiert Whitespace, Ligaturen etc. (NFC ist oft ein guter Kompromiss)
        text = unicodedata.normalize("NFC", text)

        # Entfernt Referenz-Tags wie [1], [2]
        text = re.sub(r'\[\d+\]', '', text)
        # Entfernt gaengige Wikipedia-Bearbeitungslinks
        text = re.sub(r'\[\s*Bearbeiten\s*\|\s*Quelltext bearbeiten\s*\]', '', text, flags=re.IGNORECASE)
        # Entfernt Koordinaten-Spans, die manchmal im Text auftauchen koennen
        text = re.sub(r'\[koordinaten\]', '', text, flags=re.IGNORECASE)


        # Ersetzt multiple Leerzeichen/Tabs/Newlines durch ein einzelnes Leerzeichen
        text = re.sub(r'\s+', ' ', text).strip()

        # Wenn nach Bereinigung leer, gib k.A. zurueck
        return text if text else "k.A."
    except Exception as e:
        # Fehlermeldung beim Bereinigen
        logger.error(f"Fehler bei clean_text fuer Input '{str(text)[:50]}...': {e}")
        return "k.A. (Fehler Bereinigung)"


def normalize_company_name(name):
    """
    Entfernt gaengige Rechtsformzusaetze etc. fuer Vergleiche.
    Nutzt clean_text und normalize_string.
    """
    # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
    if not name: return ""
    # Vorab bereinigen und normalisieren (Umlaute etc.)
    name = clean_text(name)
    name = normalize_string(name)

    # Liste von Rechtsformen und generischen Zusaetzen (case-insensitive)
    # Verwenden Sie raw strings r'' fuer die Regex-Patterns
    forms = [
        r'gmbh', r'ges\.?\s*m\.?\s*b\.?\s*h\.?', r'gesellschaft mit beschraenkter haftung',
        r'ug', r'u\.g\.', r'unternehmergesellschaft', r'haftungsbeschraenkt',
        r'ag', r'a\.g\.', r'aktiengesellschaft',
        r'ohg', r'o\.h\.g\.', r'offene handelsgesellschaft',
        r'kg', r'k\.g\.', r'kommanditgesellschaft',
        r'gmbh\s*&\s*co\.?\s*kg', r'ges\.?\s*m\.?\s*b\.?\s*h\.?\s*&\s*co\.?\s*k\.g\.?',
        r'ag\s*&\s*co\.?\s*kg', r'a\.g\.?\s*&\s*co\.?\s*k\.g\.?',
        r'e\.k\.?', r'e\.kfm\.?', r'e\.kfr\.?', r'eingetragene[rn]? kauffrau', r'eingetragene[rn]? kaufmann',
        r'ltd\.?', r'limited',
        r'ltd\s*&\s*co\.?\s*kg',
        r's\.?a\.?r\.?l\.?', r'sarl', r'sagl', # CH/LU/etc. SARL, It. S.a.g.l.
        r's\.?a\.?', r'societe anonyme', r'sociedad anonima', # Franz/Span. SA
        r's\.?p\.?a\.?', r'societa per azioni', # It. SpA
        r'b\.?v\.?', r'besloten vennootschap', # NL BV
        r'n\.?v\.?', r'naamloze vennootschap', # NL NV
        r'plc\.?', r'public limited company', # UK Plc
        r'inc\.?', r'incorporated', # US Inc
        r'corp\.?', r'corporation', # US Corp
        r'llc\.?', r'limited liability company', # US LLC
        r'kgaa', r'kommanditgesellschaft auf aktien', # DE KGaA
        r'se', r'societas europaea', # SE (Europa)
        r'e\.?g\.?', r'eingetragene genossenschaft', r'genossenschaft', r'genmbh', # DE eG
        r'e\.?v\.?', r'eingetragener verein', r'verein', # DE eV
        r'stiftung', r'ggmbh', r'gemeinnuetzige gmbh', r'gemeinnuetzige[rn]? gmbh', r'gug', # DE Stiftungen, gemeinnuetzige
        r'partg\.?', r'partnerschaftsgesellschaft', r'partgmbb', # DE PartG
        r'og', r'o\.g\.', r'offene gesellschaft', # AT OG
        r'e\.u\.', r'eingetragenes unternehmen', # AT EU
        r'ges\.?n\.?b\.?r\.?', r'gesellschaft nach buergerlichem recht', # DE GbR
        r'kollektivgesellschaft', r'einzelfirma', # CH
        # Zusaetzliche generische Begriffe am Ende (koennen auch Firmenbestandteile sein, daher Vorsicht)
        r'gruppe', r'holding', r'international', r'systeme', r'technik', r'logistik',
        r'solutions', r'services', r'management', r'consulting', r'produktion',
        r'vertrieb', r'entwicklung', r'maschinenbau', r'anlagenbau',
        r'engineering', r'technologie' # Weitere gaengige Begriffe
    ]
    # Pattern fuer ganze Woerter (case-insensitive), escaped um Regex-Sonderzeichen zu behandeln
    forms_escaped = [re.escape(form) for form in forms]
    # \b fuer Wortgrenzen, (?:...) fuer non-capturing group
    pattern = r'\b(?:' + '|'.join(forms_escaped) + r')\b'
    normalized = re.sub(pattern, '', name, flags=re.IGNORECASE)

    # Interpunktion entfernen/ersetzen (ausser evtl. &)
    # Entferne Punkt, Komma, Semikolon, Doppelpunkt
    normalized = re.sub(r'[.,;:]', '', normalized)
    # Ersetze Bindestriche, Gedankenstriche, Schraegstriche durch ein einzelnes Leerzeichen
    normalized = re.sub(r'[\-–/]', ' ', normalized)
    # Reduziere multiple Leerzeichen auf ein einzelnes und trimme Enden
    normalized = re.sub(r'\s+', ' ', normalized).strip()

    return normalized.lower()


def fuzzy_similarity(str1, str2):
    """Berechnet Aehnlichkeit zwischen 0 und 1 (case-insensitive)."""
    # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
    if not str1 or not str2: return 0.0
    # Sicherstellen, dass beide Inputs Strings sind
    return SequenceMatcher(None, str(str1).lower(), str(str2).lower()).ratio()

    # ==============================================================================
    # Ende Text, String & URL Utilities Block
    # ==============================================================================

    # ==============================================================================
    # GLOBALE HELPER FUNCTIONS (PART 3: Numeric Extraction Utilities)
    # ==============================================================================

    # --- Numerische Extraktion ---
    # Basierend auf Code aus Teil 4 & Teil 2.
    # Extrahiert und normalisiert Zahlenwerte aus Strings.
    # Nutzt globale Helfer: clean_text, re.
# Globale Funktion (ersetzen Sie Ihre bestehende Version vollständig hiermit)
def extract_numeric_value(raw_value, is_umsatz=False):
    logger = logging.getLogger(__name__ + ".extract_numeric_value")
    if raw_value is None or pd.isna(raw_value):
        return "k.A."

    raw_value_str_original_for_debug = str(raw_value)
    text_to_parse = str(raw_value).strip()

    if not text_to_parse or text_to_parse.lower() in ['k.a.', 'n/a', '-']:
        return "k.A."
    
    text_to_parse = text_to_parse.replace("’", "'").replace("‘", "'")

    test_val_for_zero = text_to_parse.replace(',', '.').replace(' ', '')
    if test_val_for_zero in ['0', '0.0', '0.00', '0.000']:
        logger.debug(f"Input '{raw_value_str_original_for_debug}' direkt als '0' interpretiert.")
        return "0"

    try:
        text_processed = text_to_parse
        
        prefixes_to_remove = [
            r'ca\.?\s*', r'circa\s*', r'rund\s*', r'etwa\s*', r'über\s*', r'unter\s*', 
            r'mehr als\s*', r'weniger als\s*', r'bis zu\s*', r'about\s*', r'over\s*', 
            r'approx\.?\s*', r'around\s*', r'up to\s*', r'~\s*', r'rd\.?\s*'
        ]
        for prefix_pattern in prefixes_to_remove:
            text_processed = re.sub(f'(?i)^{prefix_pattern}', '', text_processed).strip()

        currency_patterns = [
            r'(?:US|USD)\$\s*', r'US\$\s*', r'EUR\s*€?\s*', r'€\s*', r'CHF\s*', r'GBP\s*£?\s*', r'£\s*', 
            r'JPY\s*¥?\s*', r'¥\s*', r'₹\s*', r'[Cc][Hh][Ff]\s*'
        ]
        for curr_pattern in currency_patterns:
            text_processed = re.sub(curr_pattern, '', text_processed, flags=re.IGNORECASE).strip()

        text_processed = re.sub(r'\(.*?\)|\[.*?\]', '', text_processed).strip()
        text_processed = re.split(r'\s*(-|–|bis)\s*', text_processed, 1)[0].strip()
        
        if not text_processed:
            logger.debug(f"Text nach erweiterter Vorreinigung leer für '{raw_value_str_original_for_debug}'")
            return "k.A."

        text_cleaned_for_units = clean_text(text_processed).lower()
        num_match = re.search(r'([\d.,\'\s]+)', text_processed)
        
        num_str_candidate = ""
        unit_part_str = ""

        if num_match:
            num_str_candidate = num_match.group(1).strip()
            potential_unit_start_index = num_match.end()
            unit_part_str = text_processed[potential_unit_start_index:].strip()
        else:
            logger.debug(f"Kein Zahlen-Match in '{text_processed}' (Original: '{raw_value_str_original_for_debug}')")
            return "k.A."
        
        if not num_str_candidate:
             logger.debug(f"Zahlenkandidat war leer oder nur Whitespace für '{raw_value_str_original_for_debug}' nach match in '{text_processed}'")
             return "k.A."

        cleaned_num_str = num_str_candidate.replace("'", "").replace(" ", "")
        if not cleaned_num_str:
            logger.debug(f"Zahlenkandidat '{num_str_candidate}' wurde zu leerem String nach Entfernung von ' und Leerraum.")
            return "k.A."
            
        has_dot = '.' in cleaned_num_str
        has_comma = ',' in cleaned_num_str

        if has_dot and has_comma:
            last_dot_pos = cleaned_num_str.rfind('.')
            last_comma_pos = cleaned_num_str.rfind(',')
            if last_dot_pos > last_comma_pos: 
                cleaned_num_str = cleaned_num_str.replace(',', '')
            else: 
                cleaned_num_str = cleaned_num_str.replace('.', '')
                cleaned_num_str = cleaned_num_str.replace(',', '.')
        elif has_comma:
            if is_umsatz: # Für Umsatz kann Komma Dezimal sein
                if re.search(r',\d{1,2}$', cleaned_num_str): # Letztes Komma, 1-2 Ziffern danach
                    parts = cleaned_num_str.rsplit(',', 1)
                    integer_part = parts[0].replace(',', '') 
                    cleaned_num_str = f"{integer_part}.{parts[1]}"
                else: # Alle Kommas sind Tausendertrenner
                    cleaned_num_str = cleaned_num_str.replace(',', '')
            else: # Für Mitarbeiter ist Komma IMMER Tausendertrenner
                cleaned_num_str = cleaned_num_str.replace(',', '')
        elif has_dot: 
            # Für Umsatz und Mitarbeiter kann Punkt Dezimal sein
            if re.search(r'\.\d{1,2}$', cleaned_num_str): # Letzter Punkt, 1-2 Ziffern danach
                parts = cleaned_num_str.rsplit('.', 1)
                integer_part = parts[0].replace('.', '')
                cleaned_num_str = f"{integer_part}.{parts[1]}"
            else: # Alle Punkte sind Tausendertrenner
                cleaned_num_str = cleaned_num_str.replace('.', '')
        
        if not re.fullmatch(r'-?\d+(\.\d+)?', cleaned_num_str):
            logger.debug(f"Kein gültiger numerischer String nach Trennzeichenbehandlung: '{cleaned_num_str}' (Num-Kandidat: '{num_str_candidate}', Original: '{raw_value_str_original_for_debug}')")
            return "k.A."
            
        num_as_float = float(cleaned_num_str)

        scaled_num = num_as_float
        string_for_unit_search = unit_part_str.lower() if unit_part_str else text_cleaned_for_units
        logger.debug(f"String für Einheitensuche: '{string_for_unit_search}' (num_as_float: {num_as_float})")

        if is_umsatz: 
            multiplikator = 1.0 
            einheit_gefunden = False
            if re.search(r'\b(mrd\.?|milliarden|billion|mia\.?)\b', string_for_unit_search):
                multiplikator = 1000.0
                einheit_gefunden = True
            elif re.search(r'\bcrore\b', string_for_unit_search):
                multiplikator = 10.0
                einheit_gefunden = True
            elif re.search(r'\b(mio\.?|mill\.?|millionen|mn)\b', string_for_unit_search):
                multiplikator = 1.0 
                einheit_gefunden = True
            elif re.search(r'\b(tsd\.?|tausend|k\b(?!\w))\b', string_for_unit_search):
                multiplikator = 0.001
                einheit_gefunden = True
            logger.debug(f"Umsatz: num_as_float={num_as_float}, gefundene Einheit? {einheit_gefunden}, Multiplikator={multiplikator}")
            scaled_num = num_as_float * multiplikator
        else: 
            multiplikator = 1.0 
            if re.search(r'\b(mrd\.?|milliarden|billion|mia\.?)\b', string_for_unit_search):
                multiplikator = 1000000000.0
            elif re.search(r'\b(mio\.?|mill\.?|millionen|mn)\b', string_for_unit_search):
                multiplikator = 1000000.0
            elif re.search(r'\b(tsd\.?|tausend|k\b(?!\w))\b', string_for_unit_search):
                multiplikator = 1000.0
            logger.debug(f"Mitarbeiter: num_as_float={num_as_float}, Multiplikator={multiplikator}")
            scaled_num = num_as_float * multiplikator
        
        if pd.isna(scaled_num):
            return "k.A."

        if scaled_num >= 0:
            return str(int(round(scaled_num)))
        else: 
            logger.debug(f"Negative Zahl nach Skalierung: {scaled_num} für Input '{raw_value_str_original_for_debug}'")
            return "k.A."

    except ValueError as e: 
        logger.debug(f"ValueError bei Konvertierung zu float: '{e}' (cleaned_num_str: '{cleaned_num_str if 'cleaned_num_str' in locals() else 'N/A'}', Original: '{raw_value_str_original_for_debug[:30]}...')")
        return "k.A."
    except Exception as e_general:
        logger.error(f"Unerwarteter Fehler in extract_numeric_value für '{raw_value_str_original_for_debug[:50]}...': {e_general}")
        logger.debug(traceback.format_exc())
        return "k.A."



    # --- Numerische Extraktion fuer FILTERLOGIK (gibt 0 statt k.A. zurueck) ---
    # Basierend auf Code aus Teil 2.
    # Extrahiert und normalisiert Zahlenwerte fuer Vergleichslogik.
    # Nutzt globale Helfer: clean_text, re.
# Globale Funktion (ersetzen Sie Ihre bestehende Version)
# Globale Funktion (ersetzen Sie Ihre bestehende Version)
def get_numeric_filter_value(value_str, is_umsatz=False):
    logger = logging.getLogger(__name__ + ".get_numeric_filter_value") # Spezifischerer Logger-Name
    if value_str is None or pd.isna(value_str) or str(value_str).strip() == '':
        return 0.0 if is_umsatz else 0

    raw_value_str_original = str(value_str).strip()

    if raw_value_str_original.lower() in ['k.a.', 'n/a', '-', '0', '0.0', '0,0', '0.00', '0,000', '0.000']:
        return 0.0 if is_umsatz else 0

    try:
        # Schritt 1: Grundlegende Textbereinigung
        processed_value = clean_text(raw_value_str_original)
        if processed_value.lower() in ['k.a.', 'n/a', '-']: 
            return 0.0 if is_umsatz else 0

        # Schritt 2: Präfixe, Suffixe, Währungssymbole und Spannen entfernen
        processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|ueber|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()
        
        # Schritt 3: Klammerinhalte entfernen (z.B. Jahreszahlen)
        num_extraction_str = re.sub(r'\(.*?\)', '', processed_value).strip()
        
        # Schritt 4: Apostrophe und Leerzeichen zwischen Ziffern entfernen
        num_extraction_str = num_extraction_str.replace("'", "")
        num_extraction_str = re.sub(r'(?<=\d)\s+(?=\d)', '', num_extraction_str) # Entfernt Leerzeichen nur zwischen Ziffern

        if not num_extraction_str: return 0.0 if is_umsatz else 0

        # Schritt 5: Punkte und Kommas als Tausender-/Dezimaltrennzeichen standardisieren
        has_dot = '.' in num_extraction_str
        has_comma = ',' in num_extraction_str

        if has_dot and has_comma:
            if num_extraction_str.rfind('.') > num_extraction_str.rfind(','): # US-Stil: 1,234.56
                num_extraction_str = num_extraction_str.replace(',', '') # Kommas als Tausendertrenner entfernen
            else: # EU-Stil: 1.234,56
                num_extraction_str = num_extraction_str.replace('.', '') # Punkte als Tausendertrenner entfernen
                num_extraction_str = num_extraction_str.replace(',', '.') # Komma zu Dezimalpunkt
        elif has_comma: # Nur Kommas
            # Wenn ein einzelnes Komma wahrscheinlich ein Dezimaltrennzeichen ist (z.B. "123,45")
            if num_extraction_str.count(',') == 1 and re.search(r',\d{1,2}$', num_extraction_str) and not re.search(r',\d{3}(,|\s|\Z)', num_extraction_str):
                num_extraction_str = num_extraction_str.replace(',', '.')
            else: # Ansonsten sind Kommas Tausendertrenner
                num_extraction_str = num_extraction_str.replace(',', '')
        elif has_dot: # Nur Punkte
            # Wenn ein einzelner Punkt wahrscheinlich ein Dezimaltrennzeichen ist (z.B. "123.45")
            if num_extraction_str.count('.') == 1 and re.search(r'\.\d{1,2}$', num_extraction_str) and not re.search(r'\.\d{3}(?!\d)', num_extraction_str): # (?!\d) stellt sicher, dass nicht .000 Teil einer größeren Zahl ist
                pass # Punkt ist Dezimal, bleibt
            else: # Ansonsten sind Punkte Tausendertrenner
                num_extraction_str = num_extraction_str.replace('.', '')
        
        # Schritt 6: Finale Validierung und Konvertierung zu float
        if not re.fullmatch(r'-?\d+(\.\d+)?', num_extraction_str): # Prüft ob String eine gültige Zahl ist
            logger.debug(f"Kein gültiger numerischer String nach Trennzeichenbehandlung: '{num_extraction_str}' (Original: '{raw_value_str_original}')")
            return 0.0 if is_umsatz else 0
            
        num_as_float = float(num_extraction_str)

        # Schritt 7: Einheiten-Skalierung
        scaled_num = num_as_float
        original_lower = raw_value_str_original.lower()

        if is_umsatz: # Zielwert soll in Millionen sein
            if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower):
                scaled_num = num_as_float * 1000.0 
            elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower):
                scaled_num = num_as_float / 1000.0 
            # Ansonsten: num_as_float wird als bereits in Mio. € interpretiert (da CRM-Umsatz so definiert ist)
        else: # Mitarbeiter (absolute Zahl)
            if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower): scaled_num = num_as_float * 1000000000.0
            elif re.search(r'\bmio\s*\b|\bmillionen\s*\b|\bmill[.]?\s*\b', original_lower): scaled_num = num_as_float * 1000000.0
            elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): scaled_num = num_as_float * 1000.0
        
        # Für Filterlogik nur positive Werte, sonst 0
        return scaled_num if scaled_num > 0 else (0.0 if is_umsatz else 0)

    except ValueError as e:
        logger.debug(f"ValueError '{e}' bei Konvertierung (get_numeric_filter_value) von '{num_extraction_str if 'num_extraction_str' in locals() and isinstance(num_extraction_str, str) else raw_value_str_original[:30]}...'")
        return 0.0 if is_umsatz else 0
    except Exception as e_general:
        logger.error(f"Unerwarteter Fehler in get_numeric_filter_value für '{raw_value_str_original[:50]}...': {e_general}")
        logger.debug(traceback.format_exc())
        return 0.0 if is_umsatz else 0


    # ==============================================================================
    # Ende Numerische Extraktion Utilities Block
    # ==============================================================================

    # ==============================================================================
    # GLOBALE HELPER FUNCTIONS (PART 4: Gender & Email Utilities)
    # ==============================================================================

    # --- Gender und Email Helpers ---
    # Basierend auf Code aus Teil 4.
    # Nutzt globale Helfer: gender_guesser, Config.API_KEYS, requests, retry_on_failure,
    # simple_normalize_url, normalize_string, re.

    # Annahme: gender_guesser ist installiert
    # Initialisieren Sie den Detector einmal global, um Ressourcen zu sparen
try:
    import gender_guesser.detector as gender
    gender_detector = gender.Detector()
    print("gender_guesser.Detector initialisiert.") # Verwendet print
except ImportError:
    gender = None # Setzen Sie den Namen auf None, falls der Import fehlschlaegt
    gender_detector = None
    print("WARNUNG: gender_guesser Bibliothek nicht gefunden. Geschlechtserkennung deaktiviert.") # Verwendet print
except Exception as e:
    gender = None # Setzen Sie den Namen auf None, falls der Import fehlschlaegt
    gender_detector = None
    print(f"FEHLER: Fehler bei Initialisierung von gender_guesser: {e}. Geschlechtserkennung deaktiviert.") # Verwendet print


def get_gender(firstname):
    """Ermittelt Geschlecht via gender-guesser und Fallback Genderize API."""
    # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
    logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
    if not firstname or not isinstance(firstname, str): return "unknown"
    # Nehmen Sie nur den ersten Teil des Vornamens und bereinigen Sie ihn
    firstname_clean = str(firstname).strip().split(" ")[0]
    if not firstname_clean: return "unknown"

    # 1. Versuch: gender-guesser (nutzt globale Instanz)
    result_gg = "unknown"
    if gender_detector: # Pruefe, ob der Detector initialisiert wurde
        try:
            # get_gender ist case-insensitive per Default
            result_gg = gender_detector.get_gender(firstname_clean)
            # logger.debug(f"GenderGuesser fuer '{firstname_clean}': {result_gg}") # Zu viel Laerm
        except Exception as e_gg:
            logger.warning(f"Fehler bei gender-guesser fuer '{firstname_clean}': {e_gg}")
            result_gg = "unknown" # Fallback bei Fehler

    # 2. Fallback: Genderize API (nur wenn gender-guesser unsicher ist)
    # Wenn gender-guesser ein unsicheres Ergebnis liefert ("andy", "unknown", "mostly_...")
    if result_gg in ["andy", "unknown", "mostly_male", "mostly_female"]:
        genderize_key = Config.API_KEYS.get('genderize')
        # Nur versuchen, wenn der API Key verfuegbar ist
        if not genderize_key:
            # logger.debug("Genderize API-Schluessel nicht verfuegbar, Fallback nicht moeglich.") # Zu viel Laerm
            # Geben Sie das Ergebnis von gender-guesser zurueck, wenn es "mostly_" war, sonst "unknown"
            return result_gg if result_gg.startswith("mostly_") else "unknown"

        # API Call nutzt den retry_on_failure Decorator
        # Definiere die Funktion hier, da sie spezifisch fuer diesen Anwendungsfall ist
        @retry_on_failure
        def call_genderize(name, api_key):
             params = {"name": name, "apikey": api_key, "country_id": "DE"} # DE als Standardland
             # logger.debug(f"Genderize API-Anfrage fuer '{name}'...") # Zu viel Laerm im Debug
             # Fuehrt die GET-Anfrage aus. Der retry_on_failure Decorator behandelt RequestsExceptions.
             response = requests.get("https://api.genderize.io", params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) # Nutze Config Timeout
             # Wirft HTTPError fuer schlechte Antworten (4xx oder 5xx), wird vom Decorator gefangen
             response.raise_for_status()
             # Gibt die JSON-Antwort zurueck
             data = response.json()
             # logger.debug(f"  -> Genderize Antwort fuer '{name}': {data}") # Zu viel Laerm im Debug
             return data

        try:
            # Rufen Sie die API-Wrapper-Funktion auf
            genderize_data = call_genderize(firstname_clean, genderize_key)

            # Extrahieren Sie die Daten aus der API-Antwort
            api_gender = genderize_data.get("gender")
            probability = genderize_data.get("probability", 0)
            count = genderize_data.get("count", 0) # Anzahl der Datenpunkte fuer diesen Namen

            # Nur bei ausreichender Sicherheit (z.B. Wahrscheinlichkeit > 0.7) und wenn Genderize ein Ergebnis liefert
            # und Datenpunkte vorhanden sind (count > 0).
            if api_gender and probability is not None and probability > 0.7 and count is not None and count > 0:
                # Loggen Sie die Uebernahme des API-Ergebnisses
                logger.debug(f"  -> Uebernehme Genderize Ergebnis '{api_gender}' (Prob: {probability}, Count: {count}) fuer '{firstname_clean}'")
                return api_gender # Geben Sie das API-Ergebnis zurueck
            else:
                # Wenn Genderize unsicher ist oder kein valides Ergebnis liefert
                # logger.debug(f"  -> Genderize unsicher/kein Ergebnis fuer '{firstname_clean}'. Nutze Fallback: '{result_gg}'") # Zu viel Laerm
                # Geben Sie das Ergebnis von gender-guesser zurueck, wenn es "mostly_" war, sonst "unknown"
                return result_gg if result_gg.startswith("mostly_") else "unknown"

        except Exception as e: # retry_on_failure wirft Exception im Fehlerfall erneut
            # Wenn der API Call nach Retries fehlschlaegt, fangen wir die Exception hier.
            # Der Fehler wird bereits vom retry_on_failure Decorator geloggt.
            logger.error(f"FEHLER bei der Genderize API-Anfrage fuer '{firstname_clean}' nach Retries: {e}")
            # Geben Sie das Ergebnis von gender-guesser als Fallback zurueck
            return result_gg if result_gg.startswith("mostly_") else "unknown"
    else:
        # Wenn gender-guesser sicher war ("male", "female"), geben Sie das Ergebnis direkt zurueck
        return result_gg


def get_email_address(firstname, lastname, website):
    """
    Generiert eine moegliche E-Mail-Adresse im Format vorname.nachname@domain.tld.
    Normalisiert Namen und extrahiert die Domain aus der Website-URL.
    """
    # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
    logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
    if not all([firstname, lastname, website]) or not all(isinstance(x, str) and x.strip() for x in [firstname, lastname, website]): # Pruefen Sie auf nicht-leere Strings
        logger.debug("get_email_address skipped: Fehlende oder ungueltige Eingabe (Name, Website).")
        return "" # Gebe leeren String bei fehlenden/ungueltigen Eingaben zurueck

    # Extrahieren und normalisieren Sie die Domain aus der Website-URL (nutzt globale Helfer)
    domain = simple_normalize_url(website)
    # Wenn die Domain nicht gueltig ist oder keinen Punkt enthaelt (was fuer eine E-Mail-Domain notwendig ist)
    if domain == "k.A." or '.' not in domain:
        logger.debug(f"get_email_address skipped: Ungueltige Domain extrahiert aus '{website}'.")
        return "" # Gebe leeren String bei ungueltiger Domain zurueck


    # Normalisiere Vor- und Nachname (nutzt globale Helfer), konvertiere zu Kleinbuchstaben
    normalized_first = normalize_string(firstname).lower()
    normalized_last = normalize_string(lastname).lower()

    # Ersetze Leerzeichen und mehrere Bindestriche durch einen einzelnen Bindestrich
    normalized_first = re.sub(r'\s+', '-', normalized_first)
    normalized_last = re.sub(r'\s+', '-', normalized_last)

    # Entferne alle Zeichen, die NICHT alphanumerisch ('\w') oder Bindestrich ('-') sind
    # Dies stellt sicher, dass die generierte E-Mail-Adresse gueltige Zeichen enthaelt
    normalized_first = re.sub(r'[^\w\-]+', '', normalized_first)
    normalized_last = re.sub(r'[^\w\-]+', '', normalized_last)

    # Entferne fuehrende oder endende Bindestriche, falls sie nach der Bereinigung entstanden sind
    normalized_first = normalized_first.strip('-')
    normalized_last = normalized_last.strip('-')

    # Stellen Sie sicher, dass sowohl Vor- als auch Nachname nicht leer sind nach der Bereinigung
    if normalized_first and normalized_last and domain:
        # Kombinieren Sie die bereinigten Teile zur E-Mail-Adresse
        email_address = f"{normalized_first}.{normalized_last}@{domain}"
        # logger.debug(f"Generierte E-Mail-Adresse: {email_address}") # Zu viel Laerm im Debug
        return email_address
    else:
        # Wenn Vorname oder Nachname nach der Bereinigung leer sind
        logger.debug("get_email_address skipped: Vorname oder Nachname leer nach Bereinigung.")
        return "" # Gebe leeren String zurueck


    # ==============================================================================
    # Ende Gender & Email Utilities Block
    # ==============================================================================

    # ==============================================================================
    # GLOBALE HELPER FUNCTIONS (PART 5: Schema Loading Utility)
    # ==============================================================================

    # --- Schema Loading (Ziel-Branchenschema) ---
    # Basierend auf Code aus Teil 4.
    # Lädt die Liste der erlaubten Zielbranchen aus einer CSV-Datei.
    # Nutzt globale Variablen: BRANCH_MAPPING, TARGET_SCHEMA_STRING, ALLOWED_TARGET_BRANCHES.
    # Nutzt globale Helfer: csv, os, logger.

    # Globale Variablen für Branch Mapping (werden von load_target_schema() befüllt)
BRANCH_MAPPING = {} # Wird in dieser Version nicht primaer fuer Mapping genutzt, kann aber beibehalten werden
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar." # String-Repraesentation des Schemas fuer Prompts
ALLOWED_TARGET_BRANCHES = [] # Liste der erlaubten Kurzformen


def load_target_schema(csv_filepath=BRANCH_MAPPING_FILE):
    logger = logging.getLogger(__name__)
    global ALLOWED_TARGET_BRANCHES, FOCUS_TARGET_BRANCHES, TARGET_SCHEMA_STRING, FOCUS_BRANCHES_PROMPT_PART

    ALLOWED_TARGET_BRANCHES = []
    FOCUS_TARGET_BRANCHES = []
    allowed_branches_set = set()
    focus_branches_set = set()
    line_count = 0

    logger.info(f"Lade Ziel-Schema und Fokus-Branchen aus '{csv_filepath}'...")

    try: # Diese Zeile muss auf derselben Ebene wie logger.info darüber sein
        with open(csv_filepath, "r", encoding="utf-8-sig") as f:
            reader = csv.reader(f, delimiter=';')
            try:
                header_row = next(reader) 
                logger.debug(f"Ueberspringe Header-Zeile im Schema: {header_row}")
            except StopIteration:
                logger.warning(f"Schema-Datei '{csv_filepath}' ist leer oder hat keinen Header.")
                TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar (Datei leer)."
                FOCUS_BRANCHES_PROMPT_PART = ""
                return

            for row_num, row in enumerate(reader, 1): # Starte Zählung bei 1 für Zeilennummern nach Header
                line_count = row_num
                if not row: # Leere Zeile überspringen
                    continue

                if len(row) >= 1:
                    target_branch = row[0].strip()
                    if target_branch:
                        allowed_branches_set.add(target_branch)
                        # Prüfe Spalte B (Index 1) für Fokus-Markierung
                        if len(row) >= 2 and row[1].strip().upper() in ["X", "FOKUS", "JA", "TRUE", "1"]:
                            focus_branches_set.add(target_branch)
                            logger.debug(f"  -> Fokusbranche gefunden: '{target_branch}'")
    except FileNotFoundError:
        logger.critical(f"FEHLER: Schema-Datei '{csv_filepath}' nicht gefunden.")
        TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar (Datei nicht gefunden)."
        FOCUS_BRANCHES_PROMPT_PART = ""
        return
    except Exception as e:
        logger.critical(f"FEHLER beim Laden des Ziel-Schemas aus '{csv_filepath}' (Zeile {line_count}): {e}")
        TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar (Fehler beim Lesen)."
        FOCUS_BRANCHES_PROMPT_PART = ""
        return

    ALLOWED_TARGET_BRANCHES = sorted(list(allowed_branches_set), key=str.lower)
    FOCUS_TARGET_BRANCHES = sorted(list(focus_branches_set), key=str.lower)

    logger.info(f"Ziel-Schema geladen: {len(ALLOWED_TARGET_BRANCHES)} eindeutige Zielbranchen, davon {len(FOCUS_TARGET_BRANCHES)} Fokusbranchen.")

    if ALLOWED_TARGET_BRANCHES:
        schema_lines = ["Ziel-Branchenschema: Folgende Branchenbereiche sind gueltig (Kurzformen):"]
        schema_lines.extend(f"- {branch}" for branch in ALLOWED_TARGET_BRANCHES)
        # Anweisungen für das Antwortformat (unverändert)
        schema_lines.append("\nBitte ordne das Unternehmen ausschliesslich in einen dieser Bereiche ein. Gib NUR den exakten Kurznamen der Branche zurueck (keine Praefixe oder zusaetzliche Erklaerungen ausser im 'Begruendung'-Feld).")
        schema_lines.append("Antworte ausschliesslich im folgenden Format (keine Einleitung, kein Schlusssatz):")
        schema_lines.append("Branche: <Exakter Kurzname der Branche aus der Liste>")
        # NEU: Konfidenz hier ins Antwortformat aufnehmen
        schema_lines.append("Konfidenz: <Hoch, Mittel oder Niedrig>")
        schema_lines.append("Uebereinstimmung: <ok oder X (Vergleich deines Vorschlags mit der extrahierten Kurzform der CRM-Referenz)>")
        schema_lines.append("Begruendung: <Sehr kurze Begruendung fuer deinen Branchenvorschlag>")
        TARGET_SCHEMA_STRING = "\n".join(schema_lines)

        if FOCUS_TARGET_BRANCHES:
            focus_prompt_lines = ["\nZusätzlicher Hinweis: Wenn die Wahl zwischen mehreren passenden Branchen besteht, priorisiere bitte, wenn möglich, eine der folgenden Fokusbranchen:"]
            focus_prompt_lines.extend(f"- {branch}" for branch in FOCUS_TARGET_BRANCHES)
            FOCUS_BRANCHES_PROMPT_PART = "\n".join(focus_prompt_lines)
        else:
            FOCUS_BRANCHES_PROMPT_PART = ""
            logger.info("Keine Fokusbranchen im Schema definiert.")
    else:
        TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar (Keine gueltigen Branchen in Datei gefunden)."
        FOCUS_BRANCHES_PROMPT_PART = ""
        logger.warning("Keine gueltigen Zielbranchen im Schema gefunden. Branchenbewertung ist nicht moeglich.")


    # map_external_branch ist in dieser Version nicht mehr notwendig,
    # da die Branchenevaluation ueber ChatGPT (evaluate_branche_chatgpt)
    # direkt gegen ALLOWED_TARGET_BRANCHES validiert.


    # ==============================================================================
    # Ende Schema Loading Utility Block
    # ==============================================================================

    # ==============================================================================
    # GLOBALE HELPER FUNCTIONS (PART 6: OpenAI API Call Wrapper)
    # ==============================================================================

    # --- OpenAI / CHATGPT FUNCTIONS ---
    # Zentrale Funktion fuer OpenAI Chat API Aufrufe.
    # Nutzt globale Helfer: Config.API_KEYS, openai, retry_on_failure, token_count (optional), logger, ValueError.
@retry_on_failure # Wende den Decorator auf diesen API Call an
def call_openai_chat(prompt, temperature=0.3, model=None):
    """
    Zentrale Funktion fuer OpenAI Chat API Aufrufe.
    Wird von anderen globalen Helfern oder DataProcessor Methoden aufgerufen.

    Args:
        prompt (str): Der Prompt-Text an die API.
        temperature (float, optional): Die Temperatur fuer die Textgenerierung. Defaults to 0.3.
        model (str, optional): Das zu verwendende OpenAI Modell. Defaults to Config.TOKEN_MODEL.

    Returns:
        str: Der bereinigte Antwortstring von der API.
        Wirft Exception bei API-Fehlern nach Retries.
    """
    # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
    logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
    # Pruefen Sie, ob der API Key konfiguriert ist
    if not Config.API_KEYS.get('openai'):
        logger.error("Fehler: OpenAI API Key nicht konfiguriert.")
        # Werfen Sie eine spezifische Exception, die vom retry_on_failure als permanent behandelt wird
        raise openai.error.AuthenticationError("OpenAI API Key nicht konfiguriert.")

    # Pruefen Sie, ob der Prompt leer ist
    if not prompt or not isinstance(prompt, str) or not prompt.strip():
         logger.error("Fehler: Leerer Prompt fuer OpenAI.")
         # Werfen Sie eine Value Error Exception, die vom retry_on_failure behandelt wird
         raise ValueError("Leerer Prompt fuer OpenAI.")

    # Bestimmen Sie das zu verwendende Modell (CLI > Config > Default)
    current_model = model if model else getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo')

    try:
        # Optional: Token zaehlen vor dem Senden (gut fuer Debugging/Monitoring)
        # try:
        #     # Schaetzen Sie die Token-Zahl des Prompts
        #     prompt_tokens = token_count(prompt, model=current_model)
        #     # Loggen Sie die geschaetzte Token-Zahl auf Debug-Level
        #     logger.debug(f"Sende Prompt an OpenAI ({current_model}, geschaetzt {prompt_tokens} Tokens)...")
        # except Exception as e_tc:
        #      # Logge Fehler beim Token-Zaehlen, aber fahre fort
        #      logger.debug(f"Fehler beim Token-Counting fuer Prompt: {e_tc}");


        # Fuehren Sie den API Call durch. Dieser kann verschiedene Exceptions werfen (APIError, RateLimitError, InvalidRequestError etc.).
        # Diese werden vom @retry_on_failure Decorator dieser Funktion behandelt.
        response = openai.ChatCompletion.create(
            model=current_model,
            messages=[{"role": "user", "content": prompt}],
            temperature=temperature
            # Fuegen Sie hier ggf. weitere Parameter hinzu (max_tokens, top_p, frequency_penalty etc.)
        )

        # Ueberpruefen Sie die Struktur der Antwort, um sicherzustellen, dass Choices vorhanden sind
        if not response or not hasattr(response, 'choices') or not response.choices:
            # Wenn die API erfolgreich antwortet, aber keine Choices liefert (unerwartet)
            logger.error(f"OpenAI Call erfolgreich, aber keine Choices in der Antwort erhalten. Response: {str(response)[:200]}...")
            # Werfen Sie eine spezifische Exception, die vom Decorator behandelt wird
            raise openai.error.APIError("Keine Choices in OpenAI Antwort erhalten.")

        # Extrahieren Sie den Inhalt der ersten (und normalerweise einzigen) Antwort
        # Sicherstellen, dass message und content Attribute existieren
        result = response.choices[0].message.content.strip() if hasattr(response.choices[0], 'message') and hasattr(response.choices[0].message, 'content') else ""

        # Wenn der extrahierte Inhalt leer ist
        if not result:
            logger.warning(f"OpenAI Call erfolgreich, erhielt aber leeren Inhalt in der Antwort. Prompt Anfang: {prompt[:100]}...")
            # Sie koennen hier entscheiden, ob dies ein Fehler ist, der ein Retry rechtfertigt
            # oder ob ein leerer String eine gueltige (wenn auch unerwuenschte) Antwort ist.
            # Furs Erste werfen wir eine spezifische Exception, um es im Log zu sehen und ggf. zu wiederholen.
            #raise openai.error.APIError("OpenAI Antwort hatte leeren Inhalt.") # Kann zu aggressiv sein

            # Lassen wir es einfach leer zurueckgeben, wenn die API einen leeren Inhalt liefert.
            return "" # Gebe einen leeren String zurueck


        # Optional: Token zaehlen fuer die Antwort
        # try:
        #      # Schaetzen Sie die Token-Zahl der Antwort
        #      completion_tokens = token_count(result, model=current_model)
        #      # Versuchen Sie, die tatsaechliche Gesamt-Token-Zahl aus dem Usage-Objekt zu holen
        #      total_tokens = response.usage.total_tokens if hasattr(response, 'usage') else 'N/A'
        #      logger.debug(f"OpenAI Antwort erhalten ({completion_tokens} Completion Tokens, {total_tokens} Gesamt).")
        #      # TODO: Token-Zahl irgendwo sammeln und im Sheet speichern (z.B. im DataProcessor)
        # except Exception as e_tc:
        #      logger.debug(f"Fehler beim Token-Zaehlen der Antwort oder Usage Info: {e_tc}");


        return result # Gibt den bereinigten Antwortstring zurueck

    # Die spezifischen OpenAI Exceptions werden vom retry_on_failure Decorator gefangen.
    # Nur andere unerwartete Exceptions kommen hier direkt an.
    except Exception as e:
        # Fangen Sie alle anderen unerwarteten Exceptions ab (z. B. Programmierfehler).
        # Diese werden bereits vom retry_on_failure Decorator als "UNERWARTETER FEHLER" geloggt
        # und dort (standardmaessig) nicht wiederholt, sondern sofort weitergeleitet.
        # Werfen Sie die Exception erneut, damit der retry_on_failure Decorator sie fangen kann.
        raise e # Leiten Sie die Exception weiter


    # ==============================================================================
    # Ende OpenAI API Call Wrapper Block
    # ==============================================================================

    # ==============================================================================
    # GLOBALE HELPER FUNCTIONS (PART 7: OpenAI Summary Helpers)
    # ==============================================================================

    # --- OpenAI Summary Helpers ---
    # Funktionen zur Zusammenfassung von Website-Inhalten mittels OpenAI.
    # Nutzt globale Helfer: call_openai_chat, logger, token_count (optional), retry_on_failure.

def summarize_website_content(raw_text):
    """
    Erstellt eine Zusammenfassung eines Website-Rohtextes ueber OpenAI.

    Args:
        raw_text (str): Der rohe Textinhalt der Website.

    Returns:
        str: Die generierte Zusammenfassung oder ein Fehlerwert ("k.A.", etc.).
        Wirft Exception bei API-Fehlern nach Retries (von call_openai_chat).
    """
    # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
    logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
    # Pruefe, ob gueltiger Rohtext vorhanden ist (nicht leer oder Standard-Fehlerwerte)
    if not raw_text or str(raw_text).strip() == "" or str(raw_text).strip().lower() in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]:
        logger.debug("summarize_website_content skipped: No valid raw text provided.")
        return "k.A." # Gebe "k.A." zurueck, wenn kein gueltiger Rohtext vorliegt


    # Kuerze den Rohtext, falls er sehr lang ist, um Token zu sparen/Limits zu vermeiden.
    # Die maximale Laenge des Prompts ist das Limit minus der erwarteten Antwortlaenge.
    # Eine konservative Schaetzung fuer den Eingabetext sind 3000 Zeichen.
    max_raw_length = 3000
    if len(str(raw_text)) > max_raw_length:
         logger.debug(f"Kuerze Rohtext fuer Zusammenfassung von {len(str(raw_text))} auf {max_raw_length} Zeichen.")
         raw_text = str(raw_text)[:max_raw_length] # Kuerzen des Textes


    # Erstelle den Prompt fuer die Zusammenfassung
    prompt = (
        "Du bist ein KI-Assistent, der Webinhalte analysiert.\n"
        "Fasse den folgenden Text einer Unternehmenswebsite praegnant zusammen. "
        "Konzentriere dich dabei auf:\n"
        "- Haupttaetigkeitsfeld des Unternehmens\n"
        "- Wichtigste Produkte und/oder Dienstleistungen\n"
        "- Zielgruppe (falls erkennbar)\n\n"
        f"Website-Text:\n```\n{raw_text}\n```\n\n"
        "Zusammenfassung (max. 100 Woerter):"
    )

    # Rufe die zentrale OpenAI Chat API Funktion auf.
    # call_openai_chat nutzt den retry_on_failure Decorator und wirft bei endgueltigem Fehler eine Exception.
    # Diese Exception wird hier NICHT gefangen, sondern weitergereicht (z.B. an _process_single_row).
    try:
        # Standard Temperatur 0.2 fuer Zusammenfassungen
        summary = call_openai_chat(prompt, temperature=0.2)
        # Wenn call_openai_chat erfolgreich ist, gibt es den String zurueck (auch wenn leer).
        # Wenn der extrahierte Inhalt leer ist, geben wir "k.A." zurueck.
        return summary if summary and summary.strip() else "k.A. (Keine Zusammenfassung erhalten)"
    except Exception as e:
        # Wenn call_openai_chat nach Retries eine Exception wirft
        # Der Fehler wird bereits vom retry_on_failure Decorator geloggt.
        # Geben Sie einen Fehlerwert zurueck, der im Sheet gespeichert werden kann.
        # Die Exception wird hier gefangen, um einen Rueckgabewert zu liefern, anstatt die Exception weiterzuleiten.
        logger.error(f"FEHLER bei Website Zusammenfassung nach Retries: {e}") # Logge den Fehler
        return f"k.A. (Fehler Zusammenfassung: {str(e)[:50]}...)" # Signalisiert Fehler


    # --- Batch-Zusammenfassungsfunktion ---
    # Fasst mehrere Texte in einem einzigen OpenAI API Call zusammen.
    # Basierend auf summarize_batch_openai aus Teil 7/9.
    # Nutzt globale Helfer: call_openai_chat, logger, token_count (optional), retry_on_failure.
@retry_on_failure # Wende den Decorator auf den gesamten Batch-API Call an
def summarize_batch_openai(tasks_data):
    """
    Fasst eine Liste von Rohtexten in einem einzigen OpenAI API Call zusammen.
    Dies ist effizienter fuer die Verarbeitung mehrerer Zusammenfassungen.

    Args:
        tasks_data (list): Eine Liste von Dictionaries, jedes enthaelt:
                           {'row_num': int, 'raw_text': str}

    Returns:
        dict: Ein Dictionary, das Zeilennummern auf ihre Zusammenfassungen mappt.
              z.B. {2122: "Zusammenfassung A", 2123: "Zusammenfassung B"}
              Bei Fehlern oder fehlenden Zusammenfassungen wird ein Fehlerstring verwendet.
        Wirft Exception bei endgueltigen API-Fehlern nach Retries.
    """
    # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
    logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
    if not tasks_data: return {} # Gebe leeres Dictionary zurueck, wenn keine Tasks da sind

    # Filtere Tasks, die gueltigen Text haben (nicht leer oder Standard-Fehlerwerte).
    valid_tasks = [t for t in tasks_data if t.get("raw_text") and str(t["raw_text"]).strip().lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]]
    # Wenn keine gueltigen Tasks vorhanden sind
    if not valid_tasks:
        logger.debug("Keine gueltigen Rohtexte fuer Batch-Zusammenfassung gefunden.")
        # Geben Sie ein Ergebnisdict zurueck, das dies fuer alle urspruenglichen Zeilen widerspiegelt
        return {t['row_num']: "k.A. (Kein gueltiger Rohtext im Batch)" for t in tasks_data}


    logger.debug(f"Starte Batch-Zusammenfassung fuer {len(valid_tasks)} gueltige Texte (Zeilen: {[t['row_num'] for t in valid_tasks]})...")

    # --- Aggregierten Prompt erstellen ---
    prompt_parts = [
        "Du bist ein KI-Assistent, der Webinhalte analysiert.",
        "Fasse fuer JEDEN der folgenden Texte einer Unternehmenswebsite praegnant zusammen. " # Umlaute vermeiden im Prompt
        "Konzentriere dich dabei auf:\n"
        "- Haupttaetigkeitsfeld des Unternehmens\n" # Umlaute vermeiden
        "- Wichtigste Produkte und/oder Dienstleistungen\n"
        "- Zielgruppe (falls erkennbar)\n\n"
        "Gib das Ergebnis fuer JEDEN Text im folgenden Format aus, auf einer neuen Zeile:\n" # Umlaute vermeiden
        "RESULTAT <Zeilennummer>: <Zusammenfassung fuer diese Zeilennummer>\n\n" # Umlaute vermeiden
        "Halte jede Zusammenfassung kurz, max. 100 Woerter.\n\n", # Umlaute vermeiden
        "--- Texte zur Zusammenfassung ---"
    ]
    text_block = ""
    row_numbers_in_batch = [] # Liste der Zeilennummern, die tatsaechlich in diesem API-Prompt enthalten sind

    # Baue den Textblock zusammen. Kuerze jeden einzelnen Text, um das Gesamtprompt-Limit nicht zu sprengen.
    max_chars_per_single_text_in_batch = 1500 # Zeichenlimit fuer jeden Text innerhalb des Batch-Prompts

    for task in valid_tasks:
        row_num = task['row_num']
        raw_text = str(task['raw_text']) # Sicherstellen, dass es ein String ist
        raw_text_short = raw_text[:max_chars_per_single_text_in_batch] # Kuerzen fuer den Prompt

        entry_text = f"\n--- TEXT Zeile {row_num} ---\n{raw_text_short}\n--- ENDE TEXT Zeile {row_num} ---\n"
        text_block += entry_text
        row_numbers_in_batch.append(row_num) # Fuege die Zeilennummer zur Liste im Batch hinzu


    # Wenn nach der Filterung und Kuerzung keine Zeilen mehr uebrig sind (sollte oben abgefangen sein, aber zur Sicherheit)
    if not row_numbers_in_batch:
        logger.debug("Keine Zeilen uebrig fuer OpenAI Prompt nach Filterung/Kuerzung im Batch.")
        return {t['row_num']: "k.A. (Kein Rohtext im Batch)" for t in tasks_data}


    prompt_parts.append(text_block)
    prompt_parts.append("\n--- Ende der Texte ---")
    prompt_parts.append("\nBitte gib NUR die 'RESULTAT <Zeilennummer>: ...' Zeilen zurueck.")
    final_prompt = "\n".join(prompt_parts)

    # Optional: Token zaehlen zur Info, aber nicht zur Blockade
    # try: prompt_tokens = token_count(final_prompt, model=getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo')); logger.debug(f"Geschaetzt Prompt-Tokens fuer Batch: {prompt_tokens}.");
    # except Exception as e_tc: logger.debug(f"Fehler beim Token-Zaehlen: {e_tc}");


    # --- OpenAI API Call ---
    # call_openai_chat nutzt den retry_on_failure Decorator und wirft bei endgueltigem Fehler eine Exception.
    # Der retry_on_failure Decorator auf dieser summarize_batch_openai Funktion faengt die Exception
    # von call_openai_chat und fuehrt die Retries fuer die GESAMTE Batch-Funktion durch.
    chat_response = None
    try:
        chat_response = call_openai_chat(final_prompt, temperature=0.2)
        # Wenn call_openai_chat erfolgreich ist, gibt es den String zurueck.
        # Exceptions werden nach Retries geworfen und vom aeusseren retry_on_failure dieser Funktion gefangen.

        if not chat_response:
             # Dieser Fall sollte nach der Aenderung in call_openai_chat nicht mehr auftreten (wuerde Exception werfen)
             logger.error("call_openai_chat gab unerwarteterweise None zurueck fuer Batch-Zusammenfassung.")
             # Werfen Sie eine spezifische Exception, damit der aeussere Decorator sie faengt
             raise openai.error.APIError("Keine Antwort von OpenAI erhalten fuer Batch-Zusammenfassung.")


    except Exception as e:
        # Wenn call_openai_chat oder der aeussere retry_on_failure eine Exception wirft
        # Die Exception wird hier gefangen, bevor sie an den Aufrufer (DataProcessor Methode) weitergeleitet wird.
        logger.error(f"Endgueltiger FEHLER beim OpenAI-Batch-Aufruf fuer Zusammenfassung (innerhalb Batch Decorator): {e}")
        # Geben Sie ein Dictionary zurueck, das signalisiert, dass fuer alle Zeilen im Batch ein Fehler aufgetreten ist
        return {row_num: f"FEHLER API: {str(e)[:100]}" for row_num in row_numbers_in_batch}


    # --- Antwort parsen ---
    summaries = {} # Initialisieren Sie das Ergebnis-Dictionary
    lines = chat_response.strip().split('\n')
    parsed_count = 0
    for line in lines:
        # Matcht "RESULTAT <Zeilennummer>:" und den Rest der Zeile
        match = re.match(r"RESULTAT (\d+): (.*)", line.strip())
        if match:
            row_num = int(match.group(1))
            summary_text = match.group(2).strip()
            # Stellen Sie sicher, dass die Zeilennummer im urspruenglichen Batch war
            if row_num in row_numbers_in_batch:
                 summaries[row_num] = summary_text
                 parsed_count += 1
            # else: logger.debug(f"Warnung: Antwort fuer unerwartete Zeilennummer {row_num} im Batch erhalten.") # Zu viel Laerm

    logger.debug(f"Batch-Zusammenfassung: {parsed_count} von {len(row_numbers_in_batch)} Zeilen erfolgreich geparst.")

    # Fuegen Sie einen Fehlerwert fuer Zeilen hinzu, die nicht geparst werden konnten (z.B. falsches Antwortformat)
    if parsed_count < len(row_numbers_in_batch):
         logger.warning(f"Nicht alle Zeilen aus dem Batch ({len(row_numbers_in_batch)}) konnten in der OpenAI-Antwort ({len(lines)} Zeilen) geparst werden.")
         logger.debug(f"Unerwartete Antwortteile (erste 500 Zeichen): {chat_response[:500]}")
         for row_num in row_numbers_in_batch:
             if row_num not in summaries:
                  summaries[row_num] = "FEHLER: Antwort nicht geparst"


    # Fuege k.A. fuer Tasks hinzu, die ungueltigen Rohtext hatten (aus valid_tasks gefiltert)
    # Diese waren nie Teil des OpenAI Prompts und hatten daher kein Ergebnis
    original_row_nums = {t['row_num'] for t in tasks_data}
    for row_num in original_row_nums:
        if row_num not in summaries:
             summaries[row_num] = "k.A. (Kein gueltiger Rohtext im Batch)"


    return summaries # Rueckgabe des Dictionarys mit Ergebnissen oder Fehlern


    # ==============================================================================
    # Ende OpenAI Summary Helpers Block
    # ==============================================================================

    # ==============================================================================
    # GLOBALE HELPER FUNCTIONS (PART 8: OpenAI Branch Helper)
    # ==============================================================================

    # --- OpenAI Branch Helper ---
    # Funktion zur Branchenbewertung mittels OpenAI.
    # Nutzt globale Helfer: ALLOWED_TARGET_BRANCHES, TARGET_SCHEMA_STRING,
    #                       call_openai_chat, logger, re, retry_on_failure.
@retry_on_failure
def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kategorien, website_summary):
    logger = logging.getLogger(__name__)
    # Zugriff auf die globalen, durch load_target_schema() befüllten Variablen
    global ALLOWED_TARGET_BRANCHES, TARGET_SCHEMA_STRING, FOCUS_BRANCHES_PROMPT_PART

    if not ALLOWED_TARGET_BRANCHES:
        logger.critical("FEHLER in evaluate_branche_chatgpt: Ziel-Branchenschema nicht geladen.")
        return {
            "branch": "FEHLER - SCHEMA FEHLT",
            "confidence": "N/A", # NEU: Konfidenz auch bei Fehler
            "consistency": "error_schema_missing",
            "justification": "Fehler: Ziel-Schema nicht geladen"
        }

    allowed_branches_lookup = {b.lower(): b for b in ALLOWED_TARGET_BRANCHES}

    # --- Prompt für ChatGPT erstellen ---
    # Beginnt mit den Regeln und der Liste der gueltigen Kurzformen (TARGET_SCHEMA_STRING)
    # Fügt dann den Hinweis auf Fokusbranchen hinzu (FOCUS_BRANCHES_PROMPT_PART)
    
    # ANPASSUNG: Antwortformat im TARGET_SCHEMA_STRING anpassen (dies geschieht in load_target_schema, aber der Effekt ist hier relevant)
    # Der TARGET_SCHEMA_STRING wird in load_target_schema() aufgebaut.
    # Wir müssen sicherstellen, dass die Anweisung für das Antwortformat dort auch "Konfidenz:" enthält.
    # Temporäre Anpassung hier, um den Logikfluss zu zeigen - idealerweise wird TARGET_SCHEMA_STRING direkt korrekt in load_target_schema generiert.
    
    # Neuer Prompt-Teil für das Antwortformat (wird in TARGET_SCHEMA_STRING integriert)
    # Dies ist nur zur Illustration, wie TARGET_SCHEMA_STRING aussehen sollte.
    # Die eigentliche Änderung für TARGET_SCHEMA_STRING muss in der Funktion load_target_schema erfolgen.
    # TARGET_SCHEMA_STRING sollte so aussehen (Auszug für Antwortformat):
    # "...
    # Antworte ausschliesslich im folgenden Format (keine Einleitung, kein Schlusssatz):
    # Branche: <Exakter Kurzname der Branche aus der Liste>
    # Konfidenz: <Hoch, Mittel oder Niedrig>
    # Uebereinstimmung: <ok oder X (Vergleich deines Vorschlags mit der extrahierten Kurzform der CRM-Referenz)>
    # Begruendung: <Sehr kurze Begruendung fuer deinen Branchenvorschlag>
    # ..."

    prompt_parts = [TARGET_SCHEMA_STRING, FOCUS_BRANCHES_PROMPT_PART] 
    prompt_parts.append("\nOrdne das Unternehmen anhand folgender Angaben exakt einer Branche des Ziel-Branchenschemas (Kurzformen) zu.")
    # NEU: Anweisung zur Konfidenz explizit hinzufügen, falls nicht schon im TARGET_SCHEMA_STRING enthalten
    prompt_parts.append("Gib zusätzlich eine Konfidenz für deine Branchenwahl an (Hoch, Mittel oder Niedrig).")


    # Informationen hinzufügen (unverändert)
    if crm_branche and str(crm_branche).strip() and str(crm_branche).strip().lower() != "k.a.": prompt_parts.append(f"- CRM-Branche (Referenz): {str(crm_branche).strip()}")
    if wiki_branche and str(wiki_branche).strip() and str(wiki_branche).strip().lower() != "k.a.":
        if beschreibung and str(beschreibung).strip() and str(beschreibung).strip().lower() != "k.a.":
            prompt_parts.append(f"- Beschreibung (CRM): {str(beschreibung).strip()[:500]}...")
        if website_summary and str(website_summary).strip() and str(website_summary).strip().lower() != "k.a." and not str(website_summary).strip().startswith("k.A. (Fehler"):
            prompt_parts.append(f"- Website-Zusammenfassung: {str(website_summary).strip()[:500]}...")
        prompt_parts.append(f"- Wikipedia-Branche: {str(wiki_branche).strip()[:300]}...")
        if wiki_kategorien and str(wiki_kategorien).strip() and str(wiki_kategorien).strip().lower() != "k.a.":
            prompt_parts.append(f"- Wikipedia-Kategorien: {str(wiki_kategorien).strip()[:500]}...")
    else: 
        logger.debug("evaluate_branche_chatgpt: Keine validen Wiki-Daten, nutze Website-Zusammenfassung als primäre Beschreibung (falls vorhanden).")
        if website_summary and str(website_summary).strip() and str(website_summary).strip().lower() != "k.a." and not str(website_summary).strip().startswith("k.A. (Fehler"):
            prompt_parts.append(f"- Website-Zusammenfassung (als Hauptbeschreibung): {str(website_summary).strip()[:800]}...")
        elif beschreibung and str(beschreibung).strip() and str(beschreibung).strip().lower() != "k.a.":
             prompt_parts.append(f"- Beschreibung (CRM, als Hauptbeschreibung): {str(beschreibung).strip()[:800]}...")


    if len(prompt_parts) < (3 + (1 if FOCUS_BRANCHES_PROMPT_PART else 0)): 
        logger.warning("Warnung in evaluate_branche_chatgpt: Zu wenige Informationen für Branchenevaluierung.")
        crm_short_branch_for_fallback = "k.A."
        if crm_branche and isinstance(crm_branche, str) and crm_branche.strip().lower() != "k.a.":
            # Da Spalte H jetzt 1:1 ist, können wir sie direkt nehmen
            crm_short_branch_for_fallback = crm_branche.strip()
            
        return {
            "branch": crm_short_branch_for_fallback if crm_short_branch_for_fallback.lower() != "k.a." else "FEHLER",
            "confidence": "N/A", # NEU
            "consistency": "error_no_info",
            "justification": "Fehler: Zu wenige Informationen fuer eine Einschaetzung"
        }

    prompt = "\n".join(filter(None, prompt_parts))
    # logger.debug(f"Erstellter Prompt fuer Branchenevaluierung:\n---\n{prompt}\n---") # Kann bei Bedarf aktiviert werden

    chat_response = None
    try:
        chat_response = call_openai_chat(prompt, temperature=0.0) 
        if not chat_response:
             logger.error("call_openai_chat gab unerwarteterweise None/leer zurueck fuer Branchenevaluation.")
             # Hier wird eine Exception vom Typ openai.error.APIError erwartet, die vom @retry_on_failure gefangen wird.
             # Wenn es hierher kommt, ist es ein unerwarteter Zustand.
             raise APIError("Keine Antwort von OpenAI erhalten fuer Branchenevaluation.") # Import APIError from openai.error
    except Exception as e:
        logger.error(f"Endgueltiger FEHLER beim OpenAI-Aufruf fuer Branchenevaluation: {e}")
        crm_short_branch_for_fallback = "k.A."
        if crm_branche and isinstance(crm_branche, str) and crm_branche.strip().lower() != "k.a.":
            crm_short_branch_for_fallback = crm_branche.strip()
            
        return {
            "branch": crm_short_branch_for_fallback if crm_short_branch_for_fallback.lower() != "k.a." else "FEHLER API",
            "confidence": "N/A", # NEU
            "consistency": "error_api_failed",
            "justification": f"Fehler API: {str(e)[:100]}"
        }

    # --- Antwort parsen ---
    lines = chat_response.strip().split("\n")
    result = {
        "branch": None,
        "confidence": "N/A", # NEU: Initialwert
        "consistency": None,
        "justification": ""
    }
    suggested_branch = ""
    parsed_branch = False
    parsed_confidence = False # NEU

    for line in lines:
        line_lower = line.lower(); line_stripped = line.strip()
        if line_lower.startswith("branche:"):
            suggested_branch = line_stripped.split(":", 1)[1].strip().strip('"\'')
            parsed_branch = True
        elif line_lower.startswith("konfidenz:"): # NEU
            confidence_text = line_stripped.split(":", 1)[1].strip().strip('"\'')
            # Validierung des Konfidenzwertes
            valid_confidences = ["hoch", "mittel", "niedrig"]
            if confidence_text.lower() in valid_confidences:
                result["confidence"] = confidence_text.capitalize() # Speichere mit großem Anfangsbuchstaben
            else:
                logger.warning(f"Ungueltiger Konfidenzwert '{confidence_text}' von ChatGPT erhalten. Setze auf 'N/A'.")
                result["confidence"] = "N/A (Ungueltig)"
            parsed_confidence = True
        elif line_lower.startswith("uebereinstimmung:") or line_lower.startswith("ubereinstimmung:"):
            pass # Wird spaeter basierend auf 'branch' und 'crm_branche' gesetzt
        elif line_lower.startswith("begruendung:") or line_lower.startswith("begruendung:"): # KORREKTUR: war "begruendung:"
            justification_text = line_stripped.split(":", 1)[1].strip()
            if result["justification"]: result["justification"] += " " + justification_text
            else: result["justification"] = justification_text

    if not parsed_branch or not suggested_branch or suggested_branch.lower() in ["k.a.", "n/a"]:
         logger.error(f"Fehler in evaluate_branche_chatgpt: Konnte 'Branche:' nicht oder nur leer/k.A. aus Antwort parsen: {chat_response[:500]}...")
         crm_short_branch_for_fallback = "k.A."
         if crm_branche and isinstance(crm_branche, str) and crm_branche.strip().lower() != "k.a.":
            crm_short_branch_for_fallback = crm_branche.strip()
            
         return {
             "branch": crm_short_branch_for_fallback if crm_short_branch_for_fallback.lower() != "k.a." else "FEHLER PARSING",
             "confidence": result.get("confidence", "N/A"), # NEU: Konfidenz auch bei Parsing-Fehler der Branche
             "consistency": "error_parsing",
             "justification": f"Fehler Parsing: Antwortformat unerwartet."
         }
    
    # Wenn Konfidenz nicht explizit geparst wurde, aber Branche schon
    if not parsed_confidence:
        logger.warning("Konnte 'Konfidenz:' nicht aus ChatGPT-Antwort parsen. Setze auf 'N/A'.")
        result["confidence"] = "N/A (Nicht geparst)"


    # --- Validierung und Fallback (wie gehabt, aber mit CRM-Branche direkt) ---
    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]
        logger.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist gueltig ('{final_branch}').")
        result["consistency"] = "pending_comparison" 
    else:
        logger.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist NICHT im Ziel-Schema. Starte Fallback...")
        # CRM Branche ist jetzt direkt die Kurzform
        crm_short_branch = crm_branche.strip() if crm_branche and isinstance(crm_branche, str) else "k.A."
        
        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: Ungueltiger ChatGPT-Vorschlag ('{suggested_branch}'). Gueltige CRM-Kurzform '{final_branch}' verwendet."
            result["justification"] = f"{fallback_reason} (ChatGPT Begruendung war: {result.get('justification', 'Keine')})"
            logger.info(f"Fallback auf gueltige CRM-Kurzform erfolgreich: '{final_branch}'")
            result["confidence"] = "N/A (Fallback)" # Konfidenz ist nicht relevant bei Fallback auf CRM
        else:
            final_branch = suggested_branch 
            result["consistency"] = "fallback_invalid"
            error_reason = f"Fehler: Ungueltiger ChatGPT-Vorschlag ('{suggested_branch}') und keine gueltige CRM-Kurzform ('{crm_short_branch}') als Fallback."
            result["justification"] = f"{error_reason} (ChatGPT Begruendung war: {result.get('justification', 'Keine')})"
            logger.warning(f"Fallback fehlgeschlagen. Ungueltiger Vorschlag: '{final_branch}', Ungueltige CRM-Kurzform: '{crm_short_branch}'")
            final_branch = "FEHLER - UNGUELTIGE ZUWEISUNG" 
            result["confidence"] = "N/A (Fehler)" # Konfidenz nicht relevant bei Fehler

    result["branch"] = final_branch if final_branch else "FEHLER"

    # Konsistenzprüfung: ok oder X
    crm_branch_to_compare = crm_branche.strip() if crm_branche and isinstance(crm_branche, str) else "k.A."
    # Nur prüfen, wenn kein Fallback auf CRM oder Fehler vorliegt
    if result["consistency"] == "pending_comparison" and result["branch"] != "FEHLER" and not result["branch"].startswith("FEHLER"):
         if result["branch"].lower() == crm_branch_to_compare.lower(): # Direktvergleich, da CRM Spalte H angepasst wurde
             result["consistency"] = "ok"
         else:
             result["consistency"] = "X"
    
    # Sicherstellen, dass consistency immer einen Wert hat
    if result["consistency"] == "pending_comparison": # Sollte nach obiger Logik nicht mehr der Fall sein
        result["consistency"] = "error_comparison_failed" 
    elif result["consistency"] is None: # Fallback, falls etwas schiefgelaufen ist
        result["consistency"] = "error_unknown_state"

    logger.debug(f"Finale Branch-Evaluation Ergebnis: Branch='{result.get('branch')}', Confidence='{result.get('confidence')}', Consistency='{result.get('consistency')}', Justification='{result.get('justification', '')[:100]}...'")
    return result


    # ==============================================================================
    # Ende OpenAI Branch Helper Block
    # ==============================================================================


    # ==============================================================================
    # GLOBALE HELPER FUNCTIONS (PART 9: SerpAPI Search Helpers)
    # ==============================================================================

    # --- SERP API / LINKEDIN FUNCTIONS ---
    # Funktionen zur Suche ueber SerpAPI (Google Search).
    # Nutzt globale Helfer: Config.API_KEYS, requests, retry_on_failure,
    #                       simple_normalize_url, normalize_company_name, unquote, logger, re.


    # serp_wikipedia_lookup ist bereits in Teil 1/18 enthalten (oder sollte es sein, da es direkt nach retry_on_failure kam).
    # Es ist hier im globalen Sektion 3 Block nun korrekt platziert.
@retry_on_failure # Wende den Decorator an
def serp_wikipedia_lookup(company_name, website=None, min_score=0.4):
    """
    Sucht ueber SerpAPI (Google) nach dem wahrscheinlichsten Wikipedia-Artikel fuer ein Unternehmen.
    Verwendet flexible Query, sammelt Top-10-Kandidaten, bewertet nach Titelaehnlichkeit
    und Keywords, bevorzugt deutsche/englische Artikel.

    Args:
        company_name (str): Der Name des Unternehmens.
        website (str, optional): Die Website des Unternehmens (fuer Kontext in Query). Defaults to None.
        min_score (float, optional): Mindest-Score (Kombination aus Aehnlichkeit
                                      und Boni) fuer einen gueltigen Treffer. Defaults to 0.4.

    Returns:
        str: Die URL des relevantesten Wikipedia-Artikels oder None.
        Wirft Exception bei API-Fehlern nach Retries.
    """
    # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
    logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
    serp_key = Config.API_KEYS.get('serpapi')
    if not serp_key:
        logger.error("Fehler: SerpAPI Key nicht verfuegbar fuer Wikipedia Lookup.")
        # Werfen Sie eine spezifische Exception, die vom retry_on_failure behandelt wird
        raise ConnectionRefusedError("SerpAPI Key nicht konfiguriert.")

    if not company_name or str(company_name).strip() == "":
        logger.warning("serp_wikipedia_lookup: Kein Firmenname angegeben.")
        # Werfen Sie einen ValueError, der vom retry_on_failure behandelt wird
        raise ValueError("Kein Firmenname fuer SerpAPI Wikipedia Lookup angegeben.")


    # --- Flexible Query Konstruktion ---
    # Ohne Anfuehrungszeichen fuer breitere Suche
    query = f'{company_name} Wikipedia'
    # Fuegen Sie die Domain als Kontext hinzu, wenn vorhanden und valide
    if website and simple_normalize_url(website) != "k.A.":
         # Fuegen Sie die Domain als zusaetzlichen Suchterm hinzu, um die Relevanz zu verbessern
         query = f'{company_name} Wikipedia {simple_normalize_url(website)}'
    logger.info(f"Starte SerpAPI Wikipedia-Suche fuer '{company_name}' mit Query: '{query[:100]}...'") # Logge gekuerzte Query

    params = {
        "engine": "google",
        "q": query,
        "api_key": serp_key,
        "hl": "de", # Host Language (Sprache der Benutzeroberflaeche)
        "gl": "de", # Geo Location (Land)
        "num": 10 # Top 10 Ergebnisse pruefen
    }
    api_url = "https://serpapi.com/search"

    try:
        # Der Requests Call wird vom retry_on_failure Decorator behandelt.
        # Timeout sollte aus Config kommen.
        response = requests.get(api_url, params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15))
        response.raise_for_status() # Wirft HTTPError fuer 4xx/5xx Antworten. Wird vom Decorator gefangen.
        data = response.json()

        candidates = [] # Liste von Dictionaries: {'url': str, 'title': str}
        if "organic_results" in data:
            logger.debug(f"  -> Pruefe {len(data['organic_results'])} organische Ergebnisse...")
            # Iteriere durch alle organischen Ergebnisse
            for result in data["organic_results"]:
                link = result.get("link")
                # Filtere gueltige Wiki-Artikel-Links (bevorzuge de oder en Wikipedia)
                if link and isinstance(link, str) and "wikipedia.org/wiki/" in link.lower() \
                   and (link.lower().startswith("https://de.wikipedia.org") or link.lower().startswith("https://en.wikipedia.org")) \
                   and not any(x in link.lower() for x in ['datei:', 'spezial:', 'portal:', 'hilfe:', 'diskussion:', 'template:']): # Filter fuer unerwuenschte Wiki-Seiten
                    try:
                        # Extrahiere den Artikel-Titel aus der URL
                        title_part = link.split('/wiki/', 1)[1]
                        # Handle eventuelle Anker (#) am Ende der URL
                        title_part = title_part.split('#')[0]
                        # Dekodiere URL-kodierte Zeichen und ersetze Unterstriche durch Leerzeichen
                        title = unquote(title_part).replace('_', ' ')
                        candidates.append({'url': link, 'title': title})
                        # logger.debug(f"    -> Kandidat gefunden: '{title}' ({link})") # Zu viel Laerm im Debug
                    except Exception as e_title_extract:
                         # Logge Fehler bei der Titel-Extraktion, aber fahre mit naechstem Kandidaten fort
                         logger.debug(f"    -> Fehler beim Extrahieren des Titels aus Link {link[:100]}...: {e_title_extract}")
                         continue # Pruefe naechsten Kandidaten

        # Wenn keine Kandidaten gefunden wurden
        if not candidates:
            logger.warning(f"  -> SerpAPI: Keine de/en Wikipedia-Kandidaten-URLs in Ergebnissen fuer '{company_name}' gefunden.")
            return None # Signalisiert, dass kein passender Artikel gefunden wurde

        # Bewerte Kandidaten nach Relevanz
        best_match_url = None
        highest_score = -1.0
        # Normalisiere den Suchnamen des Unternehmens (nutzt globale Helfer)
        normalized_search_name = normalize_company_name(company_name)

        logger.debug(f"  -> Bewerte {len(candidates)} Kandidaten...")
        # Iteriere durch die gesammelten Kandidaten
        for cand in candidates:
            url = cand['url']
            title = cand['title']
            try:
                 # Normalisiere den Titel des Kandidaten (nutzt globale Helfer)
                 normalized_title = normalize_company_name(title)
                 title_lower = title.lower() # Fuer Keyword-Suche
            except Exception as e_norm:
                 # Logge Fehler bei der Normalisierung des Titels, aber ueberspringe diesen Kandidaten
                 logger.warning(f"Fehler beim Normalisieren des Titels '{title[:100]}...': {e_norm}. Ueberspringe Kandidatenbewertung.")
                 continue # Ueberspringe diesen Kandidaten

            # 1. Basisscore: Titelaehnlichkeit (Normalisierte Namen verwenden)
            similarity = SequenceMatcher(None, normalized_title, normalized_search_name).ratio()
            score = similarity
            # logger.debug(f"    -> Kandidat '{title[:100]}...': Basis-Aehnlichkeit={similarity:.2f}")

            # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
            # +++ KORREKTUR / HINZUFÜGUNG HIER ++++++++++++++++++++++++++++++++++++++
            # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
            bonus = 0.0 # <<<<------ HIER INITIALISIEREN!
            logger.debug(f"      INIT BONUS: Wert von bonus direkt nach Initialisierung: {bonus} für Kandidat '{title[:50]}...'") 
            # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
            # +++ ENDE KORREKTUR / HINZUFÜGUNG ++++++++++++++++++++++++++++++++++++
            # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

            # NEU: Starker Bonus, wenn der normalisierte Suchname signifikant im normalisierten Titel enthalten ist
            # oder der normalisierte Titel im Suchnamen (für Fälle wie "Bosch (Unternehmen)" vs "Robert Bosch GmbH")
            # Sicherstellen, dass Strings nicht leer sind, bevor 'in' verwendet wird
            if normalized_search_name and normalized_title and \
               (normalized_search_name in normalized_title or normalized_title in normalized_search_name):
                bonus += 0.3 # Relativ starker Bonus für gute Namensübereinstimmung
                logger.debug(f"      -> Bonus +0.3 fuer Namensübereinstimmung: '{normalized_search_name}' in '{normalized_title}'")
            
            # Bestehende Boni anpassen oder leicht reduzieren, wenn der obige Bonus schon greift
            if "(unternehmen)" in title_lower:
                bonus += 0.15 # Etwas reduziert, da Namensmatch wichtiger
                logger.debug("      -> Bonus +0.15 fuer '(unternehmen)'")
            elif re.search(r'\b(?:gmbh|ag|kg|ltd|inc|corp|s\.?a\.?|se|group|holding)\b$', title_lower):
                bonus += 0.05 # Kleinerer Bonus
                logger.debug("      -> Bonus +0.05 fuer Rechtsform/Gruppen-Keyword")

            # 3. Bonus fuer Sprache (Deutsch bevorzugt)
            if url.lower().startswith("https://de.wikipedia.org"):
                 bonus += 0.05
                 logger.debug("      -> Bonus +0.05 fuer de.wikipedia.org") 

            total_score = score + bonus
            logger.debug(f"      -> Gesamtscore fuer '{title[:100]}...': {total_score:.3f} (Aehnlichkeit={similarity:.2f}, Bonus={bonus:.2f})")

            if total_score > highest_score and total_score >= min_score:
                highest_score = total_score
                best_match_url = url # Speichere die URL des besten Treffers
                logger.debug(f"      ====> Neuer bester Kandidat: {best_match_url[:100]}... (Score: {highest_score:.3f}) ====")


        # Wenn nach Pruefung aller Kandidaten ein bester Treffer gefunden wurde (Score >= min_score)
        if best_match_url:
            logger.info(f"  -> SerpAPI: Bester relevanter Wikipedia-Link ausgewaehlt: {best_match_url[:100]}... (Score: {highest_score:.3f})")
            return best_match_url # Gebe die gefundene URL zurueck
        else:
            # Wenn keiner der Kandidaten den Mindestscore erreicht hat
            logger.warning(f"  -> SerpAPI: Keiner der {len(candidates)} Kandidaten erreichte den Mindestscore ({min_score}) fuer '{company_name}'.")
            return None # Signalisiert, dass kein passender Artikel gefunden wurde

    except Exception as e: # retry_on_failure wirft Exception im Fehlerfall erneut
        # Fangen Sie alle anderen unerwarteten Exceptions ab.
        # Der Fehler wird bereits vom retry_on_failure Decorator geloggt.
        logger.error(f"FEHLER bei der SerpAPI Wikipedia Suche fuer '{company_name}': {e}")
        # Werfen Sie die Exception erneut, damit der retry_on_failure Decorator sie handhaben kann (z.B. loggen beim endgueltigen Scheitern).
        raise e # Leite die Exception weiter


@retry_on_failure
def serp_website_lookup(company_name):
    """
    Ermittelt die offizielle Website eines Unternehmens ueber SerpAPI (Google Suche).
    Gibt die normalisierte URL zurueck oder "k.A.".

    Args:
        company_name (str): Der Name des Unternehmens.

    Returns:
        str: Die normalisierte Website URL oder "k.A." bei Fehler/nicht gefunden.
        Wirft Exception bei API-Fehlern nach Retries.
    """
    # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
    logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
    serp_key = Config.API_KEYS.get('serpapi')
    if not serp_key:
        logger.error("Fehler: SerpAPI Key nicht verfuegbar fuer Website Lookup.")
        # Werfen Sie eine spezifische Exception, die vom retry_on_failure behandelt wird
        raise ConnectionRefusedError("SerpAPI Key nicht konfiguriert.")

    if not company_name or str(company_name).strip() == "":
        logger.warning("serp_website_lookup: Kein Firmenname angegeben.")
        # Werfen Sie einen ValueError
        raise ValueError("Kein Firmenname fuer SerpAPI Website Lookup angegeben.")


    # Blacklist unerwuenschter Domains (kann in Config verschoben werden)
    # Diese Domains sind in der Regel keine offiziellen Unternehmenswebsites.
    blacklist = ["bloomberg.com", "northdata.de", "finanzen.net", "handelsblatt.com", "wikipedia.org", "linkedin.com", "xing.com", "youtube.com", "facebook.com", "twitter.com", "instagram.com", "glassdoor.com", "kununu.com"]

    # Query anpassen fuer bessere Ergebnisse (suche nach der "offiziellen Website")
    query = f'{company_name} offizielle Website'
    # Fuegen Sie optional den Ort hinzu, wenn verfuegbar (muesste als Argument uebergeben werden)
    # if city and city != "k.A.": query = f'{company_name} offizielle Website {city}'
    logger.info(f"Starte SerpAPI Website-Suche fuer '{company_name}' mit Query: '{query[:100]}...'") # Logge gekuerzte Query


    params = {
        "engine": "google",
        "q": query,
        "api_key": serp_key,
        "hl": "de", # Host Language (Sprache der Benutzeroberflaeche)
        "gl": "de", # Geo Location (Land)
        "safe": "active" # SafeSearch aktivieren
    }
    api_url = "https://serpapi.com/search"

    try:
        # Der Requests Call wird vom retry_on_failure Decorator behandelt.
        # Timeout sollte aus Config kommen.
        response = requests.get(api_url, params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15))
        response.raise_for_status() # Wirft HTTPError fuer 4xx/5xx Antworten. Wird vom Decorator gefangen.
        data = response.json()

        # 1. Knowledge Graph pruefen (oft die offizielle Seite)
        # Dies ist oft der zuverlaessigste Treffer
        if "knowledge_graph" in data and "website" in data["knowledge_graph"]:
             kg_url = data["knowledge_graph"].get("website")
             if kg_url and isinstance(kg_url, str): # Stelle sicher, dass kg_url ein String ist
                  # Pruefen Blacklist VOR Normalisierung
                  if any(bad_domain in kg_url.lower() for bad_domain in blacklist):
                       logger.debug(f"  -> SerpAPI Website Lookup: KG URL '{kg_url[:100]}...' auf Blacklist. Uebersprungen.") # Gekuerzt loggen
                  else:
                       # Normalisiere die URL (nutzt globale Helfer)
                       normalized_url = simple_normalize_url(kg_url)
                       # Wenn die Normalisierung erfolgreich war (kein "k.A.")
                       if normalized_url != "k.A.":
                            logger.info(f"SERP Lookup: Website '{normalized_url}' aus Knowledge Graph fuer '{company_name}' gefunden.")
                            return normalized_url # Erfolgreich gefunden und zurueckgegeben


        # 2. Organische Ergebnisse pruefen
        if "organic_results" in data:
            # Iteriere durch die ersten Ergebnisse (pruefe nur die Top N Ergebnisse)
            # N ist hier fest auf 5 gesetzt, kann aber angepasst werden.
            for result in data["organic_results"][:5]:
                url = result.get("link", "")
                title = result.get("title", "") # Titel kann Kontext geben
                snippet = result.get("snippet", "") # Snippet kann Kontext geben

                # Filtere: Muss gueltige URL sein, darf nicht auf Blacklist sein, muss http/https starten
                if url and isinstance(url, str) and url.lower().startswith(("http://", "https://")) and not any(bad_domain in url.lower() for bad_domain in blacklist):

                    # Normalisiere die URL (nutzt globale Helfer)
                    normalized_url = simple_normalize_url(url)

                    # Wenn die Normalisierung erfolgreich war (kein "k.A.")
                    if normalized_url != "k.A.":
                         # Zusaetzliche Plausibilitaetspruefung: Ist die Domain oder der Firmenname
                         # im Titel oder Snippet des Suchergebnisses relevant fuer das Unternehmen?
                         # normalize_company_name nutzt globale Funktion
                         normalized_company = normalize_company_name(company_name)
                         # Extrahieren Sie den ersten Teil der Domain (vor dem ersten Punkt)
                         domain_part_normalized = normalized_url.replace('www.', '').split('.')[0]
                         title_lower = title.lower()
                         snippet_lower = snippet.lower()

                         # Pruefe, ob der normalisierte Domain-Teil im normalisierten Firmennamen enthalten ist
                         domain_name_match = domain_part_normalized in normalized_company

                         # Pruefe, ob der normalisierte Firmenname (oder Teile davon) im Titel oder Snippet vorkommt
                         # Kann auch auf Kurzform pruefen
                         name_in_result_text = normalized_company in title_lower or normalized_company in snippet_lower # Oder eine praezisere Fuzzy-Suche

                         # Definieren Sie Kriterien fuer einen guten Treffer im organischen Ergebnis
                         # Eine gute URL sollte entweder einen Domain/Name-Match haben ODER der Firmenname sollte prominent im Ergebnis-Text vorkommen.
                         if domain_name_match or name_in_result_text:
                              logger.info(f"SERP Lookup: Website '{normalized_url}' aus Organic Results fuer '{company_name}' gefunden (Domain/Name Match oder Text-Match).")
                              return normalized_url # Erfolgreich gefunden und zurueckgegeben
                         else:
                              # Loggen Sie, warum die URL uebersprungen wurde (nur auf Debug)
                              # logger.debug(f"  -> SerpAPI Website Lookup: URL '{normalized_url}' uebersprungen (Domain/Name Match oder Text-Match fehlgeschlagen). Domain='{domain_part_normalized}', Name='{normalized_company}'.") # Zu viel Laerm
                              pass # Fahren Sie fort, um den naechsten organischen Treffer zu pruefen


        # Wenn die Schleife durchlaeuft und kein passender Treffer gefunden wurde (weder KG noch Organic)
        logger.info(f"SERP Lookup: Keine passende Website fuer '{company_name}' gefunden nach Pruefung KG und Top Organic Results.")
        return "k.A." # Signalisiert, dass keine passende URL gefunden wurde

    except Exception as e: # retry_on_failure wirft Exception im Fehlerfall erneut
        # Fangen Sie alle anderen unerwarteten Exceptions ab.
        # Der Fehler wird bereits vom retry_on_failure Decorator geloggt.
        logger.error(f"FEHLER bei der SerpAPI Website Suche fuer '{company_name}': {e}")
        # Geben Sie einen Fehlerwert zurueck oder "k.A."
        return f"k.A. (Fehler Suche: {str(e)[:100]}...)" # Signalisiert Fehler bei der Suche


@retry_on_failure
def search_linkedin_contacts(company_name, website, position_query, crm_kurzform, num_results=10):
    """
    Sucht LinkedIn Kontakte fuer ein Unternehmen und eine Position ueber SerpAPI (Google).
    Gibt eine Liste von Kontakt-Dictionaries zurueck.

    Args:
        company_name (str): Der volle Unternehmensname.
        website (str): Die Website des Unternehmens (fuer Email-Generierung).
        position_query (str): Der Suchbegriff fuer die Position (z.B. "Serviceleiter").
        crm_kurzform (str): Die Kurzform des Firmennamens (wichtig fuer Suchgenauigkeit).
        num_results (int, optional): Anzahl der Suchergebnisse pro Query. Defaults to 10.

    Returns:
        list: Eine Liste von Dictionaries, jedes repraesentiert einen gefundenen Kontakt.
              Leere Liste bei Fehler oder nicht gefunden.
        Wirft Exception bei API-Fehlern nach Retries.
    """
    # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
    logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
    serp_key = Config.API_KEYS.get('serpapi')
    if not serp_key:
        logger.error("Fehler: SerpAPI Key nicht verfuegbar fuer LinkedIn Suche.")
        raise ConnectionRefusedError("SerpAPI Key nicht konfiguriert.")

    # Grundlegende Pruefung der Eingaben
    if not all([company_name, position_query, crm_kurzform]) or not all(isinstance(x, str) and x.strip() for x in [company_name, position_query, crm_kurzform]):
        logger.warning(f"search_linkedin_contacts: Fehlende oder ungueltige Eingabedaten (Name, Position, Kurzform sind Pflicht).")
        # Werfen Sie einen ValueError
        raise ValueError("Fehlende oder ungueltige Eingabedaten fuer LinkedIn Suche.")

    # Query anpassen fuer bessere Ergebnisse
    # Suche nach "[Position]" UND "[Firmenkurzform]" auf der LinkedIn /in/ Seite
    # Die Firmenkurzform ist oft im Titel oder der Beschreibung des Profils
    query = f'site:linkedin.com/in/ "{position_query.strip()}" "{crm_kurzform.strip()}"'
    # Optional: Fuegen Sie den vollen Firmennamen hinzu als zusaetzlichen Begriff,
    # kann aber die Ergebnisse stark einschraenken, wenn der volle Name lang ist oder variiert.
    # query = f'site:linkedin.com/in/ "{position_query.strip()}" "{crm_kurzform.strip()}" "{company_name.strip()}"'
    logger.info(f"Starte SerpAPI LinkedIn-Suche fuer '{crm_kurzform}' (Position: '{position_query}') mit Query: '{query[:100]}...'") # Logge gekuerzte Query


    params = {
        "engine": "google",
        "q": query,
        "api_key": serp_key,
        "hl": "de", # Host Language
        "gl": "de", # Geo Location
        "num": num_results # Anzahl der Ergebnisse pro SerpAPI Call (max 100, aber oft weniger geliefert)
    }
    api_url = "https://serpapi.com/search"

    found_contacts = [] # Liste zur Sammlung der gefundenen Kontakte

    try:
        # Der Requests Call wird vom retry_on_failure Decorator behandelt.
        # Timeout sollte aus Config kommen.
        response = requests.get(api_url, params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15))
        response.raise_for_status() # Wirft HTTPError fuer 4xx/5xx Antworten. Wird vom Decorator gefangen.
        data = response.json()

        if "organic_results" in data:
            # Gehe durch die organischen Suchergebnisse
            for result in data["organic_results"]:
                title = result.get("title", "") # Titel des Suchergebnisses (oft Name - Position bei Firma)
                linkedin_url = result.get("link", "") # Die URL des LinkedIn Profils
                snippet = result.get("snippet", "") # Snippet kann Position oder Firma enthalten

                # Filtere: Muss eine LinkedIn Profil-URL sein und darf kein "sales" Profil sein
                if not linkedin_url or not isinstance(linkedin_url, str) or "linkedin.com/in/" not in linkedin_url.lower() or "/sales/" in linkedin_url.lower():
                     #logger.debug(f"  -> LinkedIn Treffer uebersprungen (kein Profil-URL oder Sales): {linkedin_url[:100]}...") # Gekuerzt loggen
                     continue

                # Zusaetzliche Relevanzpruefung:
                # Die Firmenkurzform ODER der gesuchte Positionsterm MUSS im Titel oder Snippet vorkommen.
                title_lower = title.lower()
                snippet_lower = snippet.lower()
                crm_kurzform_lower = crm_kurzform.lower()
                position_query_lower = position_query.lower()

                # Pruefe, ob Kurzform ODER Position im Titel oder Snippet vorkommen
                is_relevant_result = (crm_kurzform_lower in title_lower or crm_kurzform_lower in snippet_lower) or \
                                     (position_query_lower in title_lower or position_query_lower in snippet_lower)

                # Wenn das Suchergebnis nicht relevant erscheint, ueberspringe es
                if not is_relevant_result:
                     #logger.debug(f"  -> LinkedIn Treffer uebersprungen (nicht relevant fuer '{crm_kurzform}'/{position_query}): '{title[:100]}...'") # Gekuerzt loggen
                     continue


                # --- Extrahiere Name und Position aus dem Titel (Heuristik) ---
                name_part = ""
                pos_part = position_query # Fallback fuer die Position
                # Versuche gaengige Trennzeichen im Titel (z.B. Name - Position | Firma)
                separators = [" – ", " - ", " | ", " at ", " bei "] # Laengere Trenner zuerst
                title_cleaned = title.replace("...", "").strip() # Bereinige gaengige Zeichen

                found_sep = False
                for sep in separators:
                    if sep in title_cleaned:
                        parts = title_cleaned.split(sep, 1)
                        name_part = parts[0].strip() # Teil vor dem ersten Trenner ist wahrscheinlich der Name

                        # Der Teil nach dem ersten Trenner enthaelt wahrscheinlich Position und Firma
                        potential_pos_company = parts[1].strip()
                        # Versuche, LinkedIn-Suffixe zu entfernen (z.B. " | LinkedIn")
                        potential_pos_company = re.sub(r'[\s|\-]*LinkedIn[\s|\-]*Profile.*$', '', potential_pos_company, flags=re.IGNORECASE).strip()
                        potential_pos_company = re.sub(r'[\s|\-]*LinkedIn$', '', potential_pos_company, flags=re.IGNORECASE).strip()


                        # Versuche, die Firmenkurzform aus dem Position/Firma-Teil zu entfernen (ganzes Wort)
                        if crm_kurzform_lower in potential_pos_company.lower():
                            # Ersetze nur die erste gefundene Instanz der Kurzform
                            pos_company_cleaned = re.sub(r'\b' + re.escape(crm_kurzform_lower) + r'\b', '', potential_pos_company, flags=re.IGNORECASE).strip()
                            pos_company_cleaned = re.sub(r'\s+', ' ', pos_company_cleaned).strip() # Leerzeichen reduzieren nach Entfernung
                        else:
                             pos_company_cleaned = potential_pos_company # Behalte den Teil, wenn Kurzform nicht gefunden


                        pos_part = pos_company_cleaned if pos_company_cleaned else position_query # Nimm den bereinigten Teil oder den Suchbegriff
                        found_sep = True
                        break # Hoere beim ersten gefundenen Trennzeichen auf


                # Wenn kein Trennzeichen gefunden wurde, versuche andere einfache Muster oder nimm den ganzen Titel als Name
                if not found_sep:
                    # Muster: "[Name] [Position_Query]" oder "[Name] - LinkedIn" etc.
                    # Versuche, den gesuchten Positionsterm aus dem Titel zu entfernen, um den Namen zu isolieren
                    if position_query_lower in title_lower:
                         name_before_pos = title_lower.split(position_query_lower, 1)[0].strip()
                         name_part = title_cleaned[:len(name_before_pos)].strip() # Nimm Originaltext bis zum Beginn des Positionsterms
                         pos_part = position_query # Position ist der Suchbegriff

                    else:
                         # Wenn der Positionsterm nicht gefunden wurde und kein Trenner da war,
                         # nimm den gesamten Titel vor " - LinkedIn" als Name (weniger zuverlaessig)
                         name_part = re.sub(r'[\s|\-]*LinkedIn[\s|\-]*Profile.*$', '', title_cleaned, flags=re.IGNORECASE).strip()
                         name_part = re.sub(r'[\s|\-]*LinkedIn$', '', name_part, flags=re.IGNORECASE).strip()
                         pos_part = position_query # Position bleibt der Suchbegriff


                # Teile den extrahierten Namen in Vor- und Nachname (einfache Annahme: erstes Wort = Vorname)
                firstname = ""
                lastname = ""
                name_parts = name_part.split()
                if len(name_parts) > 1:
                    firstname = name_parts[0]
                    lastname = " ".join(name_parts[1:]) # Der Rest ist Nachname
                elif len(name_parts) == 1:
                    firstname = name_parts[0] # Nur Vorname gefunden?
                # Wenn der Name leer ist (z.B. nach Entfernung von Rechtsformen), ueberspringe diesen Kontakt
                if not firstname or not name_part.strip():
                     #logger.debug(f"LinkedIn Treffer uebersprungen: Name konnte nicht extrahiert werden aus Titel '{title[:100]}...'.") # Zu viel Laerm im Debug
                     continue


                # Wenn wir bis hierher gekommen sind, scheint es ein gueltiger Kontakt zu sein.
                contact_data = {
                         # Fuer die spaetere Verarbeitung (Gender, Email)
                        "Firmenname": company_name, # Originalname fuer Kontext
                        "CRM Kurzform": crm_kurzform,
                        "Website": website, # Website der Firma (fuer Email-Generierung)
                        "Vorname": firstname,
                        "Nachname": lastname,
                        "Position": pos_part, # Extrahierte oder Fallback Position
                        "LinkedInURL": linkedin_url
                     }
                found_contacts.append(contact_data)
                # logger.debug(f"  -> Gefundener LinkedIn Kontakt: {firstname} {lastname} - {pos_part} (URL: {linkedin_url[:100]}...)") # Zu viel Laerm im Debug


        # Wenn die Schleife durchlaeuft und Kontakte gefunden wurden
        logger.info(f"LinkedIn Suche fuer '{position_query}' bei '{crm_kurzform}' ergab {len(found_contacts)} Kontakte.")
        return found_contacts # Gibt die Liste der gefundenen Kontakte zurueck (leer bei nicht gefunden oder Fehler)

    except Exception as e: # retry_on_failure wirft Exception im Fehlerfall erneut
        # Fangen Sie alle anderen unerwarteten Exceptions ab.
        # Der Fehler wird bereits vom retry_on_failure Decorator geloggt.
        logger.error(f"FEHLER bei der SerpAPI LinkedIn Suche (Query: '{position_query}', Firma: '{crm_kurzform}'): {e}")
        # Geben Sie eine leere Liste zurueck, da bei Fehler keine Kontakte gefunden wurden
        return [] # Signalisiert Fehler bei der Suche


    # ==============================================================================
    # Ende SerpAPI Search Helpers Block
    # ==============================================================================

    # ==============================================================================
    # GLOBALE HELPER FUNCTIONS (PART 10: Website Raw Scraping Function)
    # ==============================================================================

    # --- Globale Funktion zum Scrapen des Website Rohtextes ---
    # Basierend auf get_website_raw aus Teil 7. Global platziert.
    # Nutzt globale Helfer: simple_normalize_url, clean_text, re, requests, BeautifulSoup, Config, getattr, logger, retry_on_failure, USER_AGENTS, URL_CHECK_MARKER.
@retry_on_failure # Wende den Decorator auf diese Funktion an
def get_website_raw(url, max_length=20000, verify_cert=False): # verify_cert Default ist jetzt False
    """
    Holt Textinhalt von einer Website, versucht Cookie-Banner zu umgehen.
    Ignoriert standardmäßig SSL-Zertifikatfehler und gibt spezifischere Fehlerwerte
    oder einen Marker fuer erneute URL-Suche zurueck.
    """
    logger = logging.getLogger(__name__)
    if not url or not isinstance(url, str) or url.strip().lower() in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"]:
        logger.debug(f"get_website_raw skipped: Ungueltige oder leere URL '{url}'.")
        return "k.A."

    if not url.lower().startswith(("http://", "https://")):
        url = "https://" + url

    headers = {
        "User-Agent": random.choice(USER_AGENTS) # Wählt zufälligen User-Agent aus der Liste
    }
    response = None
    error_reason = "Unbekannter Fehler" # Default
    return_marker = False # Flag für URL_CHECK_MARKER

    try:
        logger.debug(f"Versuche Website abzurufen: {url[:100]}... (verify={verify_cert})")
        response = requests.get(
            url,
            timeout=getattr(Config, 'REQUEST_TIMEOUT', 20), # Timeout aus Config (Standard 20s)
            headers=headers,
            verify=verify_cert, # Nutzt den Default oder den übergebenen Wert
            allow_redirects=True,
            stream=False
        )
        response.raise_for_status() # Wirft HTTPError fuer 4xx/5xx
        error_reason = None # Kein Fehler, wenn erfolgreich

    except requests.exceptions.SSLError as e_ssl:
        error_reason = f"SSL Fehler: {str(e_ssl)[:100]}..."
        logger.warning(f"SSL Fehler (verify={verify_cert}) fuer {url[:100]}...: {e_ssl}")
        # Wenn verify=True war, hätte man hier nochmal mit False versuchen können,
        # aber da der Default jetzt False ist, ist ein erneuter Versuch meist redundant.

    except requests.exceptions.Timeout as e_timeout:
        error_reason = f"Timeout ({getattr(Config, 'REQUEST_TIMEOUT', 20)}s)"
        logger.warning(f"{error_reason} fuer {url[:100]}...")

    except requests.exceptions.ConnectionError as e_conn:
         error_reason = f"Connection Error: {str(e_conn)[:100]}..."
         logger.warning(f"{error_reason} fuer {url[:100]}...")
         # Prüfe, ob es ein 'Name or service not known' oder 'Connection refused' Fehler ist etc.
         if "[Errno -2] Name or service not known" in str(e_conn) or \
            "[Errno -3] Temporary failure in name resolution" in str(e_conn) or \
            "[Errno 111] Connection refused" in str(e_conn) or \
            "[Errno 113] No route to host" in str(e_conn) or \
            "Failed to establish a new connection" in str(e_conn):
             return_marker = True # Starker Hinweis auf falsche/unerreichbare URL

    except requests.exceptions.HTTPError as e_http:
        status_code = e_http.response.status_code
        error_reason = f"HTTP Error {status_code} ({e_http.response.reason})"
        logger.warning(f"{error_reason} fuer {url[:100]}...")
        if status_code == 404: # Bei 404 Fehler auch Marker setzen
             return_marker = True

    except Exception as e_gen:
        error_reason = f"Allg. Fehler: {type(e_gen).__name__} - {str(e_gen)[:100]}..."
        logger.error(f"Allgemeiner Fehler beim Abrufen von {url[:100]}...: {e_gen}")
        logger.debug(traceback.format_exc())


    if return_marker:
        logger.warning(f"Markiere URL {url[:100]}... zur erneuten Prüfung (Grund: {error_reason}).")
        return URL_CHECK_MARKER # <<< Speziellen Marker zurückgeben
    elif response is None or error_reason:
        return f"k.A. ({error_reason})"


    # --- Ab hier: Verarbeitung der erfolgreichen Response (Code bleibt gleich wie vorher) ---
    try:
        response.encoding = response.apparent_encoding
        soup = BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser'))

        content_selectors = [
            'main', 'article', '#content', '#main-content', '.main-content', '.content',
            'div[role="main"]', 'div.page-content', 'div.container'
        ]
        content_area = None
        for selector in content_selectors:
             content_area = soup.select_one(selector)
             if content_area: break

        if not content_area:
            content_area = soup.find('body')
            if content_area:
                banner_selectors = [
                    '[id*="cookie"]', '[class*="cookie"]', '[id*="consent"]', '[class*="consent"]',
                    '.cookie-banner', '.consent-banner', '.modal', '#modal', '.popup', '#popup',
                    '[role="dialog"]', '[aria-modal="true"]'
                ]
                banners_removed_count = 0
                for selector in banner_selectors:
                    try:
                        potential_banners = content_area.select(selector)
                        for banner in potential_banners:
                            banner_text = banner.get_text(" ", strip=True).lower()
                            keywords = ["cookie", "zustimm", "ablehnen", "einverstanden", "datenschutz", "privacy", "akzeptier", "einstellung", "partner", "analyse", "marketing"]
                            element_id_class = (banner.get('id', '') + ' ' + ' '.join(banner.get('class', []))).lower()
                            if any(keyword in banner_text for keyword in keywords) or any(keyword in element_id_class for keyword in keywords):
                                banner.decompose()
                                banners_removed_count += 1
                    except Exception as e_select:
                         logger.debug(f"Fehler beim Versuch Banner mit Selektor '{selector}' zu entfernen: {e_select}")
                if banners_removed_count > 0:
                     logger.debug(f"{banners_removed_count} potenzielle Banner-Elemente fuer {url[:100]}... entfernt.")

        if content_area:
            for script_or_style in content_area(["script", "style"]):
                script_or_style.decompose()

            text = content_area.get_text(separator=' ', strip=True)
            text = re.sub(r'\s+', ' ', text).strip()

            banner_keywords_strict = ["cookie", "zustimmen", "ablehnen", "einverstanden", "datenschutz", "privacy", "akzeptier", "einstellung", "partner", "analyse", "marketing"]
            text_lower = text.lower()
            keyword_hits = sum(1 for keyword in banner_keywords_strict if keyword in text_lower)

            if len(text) < 500 and keyword_hits >= 3:
                logger.warning(f"WARNUNG: Extrahierter Text fuer {url[:100]}... scheint nur Cookie-Banner zu sein (Laenge {len(text)}, {keyword_hits} Keywords). Verwerfe Text.")
                return "k.A. (Nur Cookie-Banner erkannt)"

            if len(text.split()) < 10 or len(text) < 50: # Ursprünglich 50, aber das ist sehr restriktiv für kurze Seiten
                 pass # Nur kurze Texte sind nicht unbedingt ein Fehler

            result = text[:max_length]
            logger.debug(f"Website {url[:100]}... erfolgreich gescrapt. Extrahierter Text (Laenge {len(result)}).")
            return result if result else "k.A. (Extraktion leer)"
        else:
            logger.warning(f"Kein <body> oder spezifischer Inhaltsbereich gefunden in {url[:100]}...")
            return "k.A. (Kein Body gefunden)"

    except Exception as e_parse:
        logger.error(f"Fehler beim Parsen von HTML von {url[:100]}...: {type(e_parse).__name__} - {e_parse}")
        logger.debug(traceback.format_exc())
        return f"k.A. (Fehler Parsing: {str(e_parse)[:50]}...)"

    # ==============================================================================
    # Ende Website Raw Scraping Funktion Block
    # ==============================================================================

    # ==============================================================================
    # GLOBALE HELPER FUNCTIONS (PART 11: Website Details Scraping Function)
    # ==============================================================================

    # --- Experimentelle Website Details Scraping Funktion ---
    # Basierend auf scrape_website_details aus Teil 10. Global platziert.
    # Nutzt globale Helfer: retry_on_failure, requests, BeautifulSoup, Config, getattr, clean_text, logger.
    # Diese Funktion ist als experimentelles Dienstprogramm gedacht.
    # Ihre Implementierung hängt stark von der Struktur der Zielwebsites ab.
    # Derzeit extrahiert sie nur grundlegende Meta-Informationen.
def scrape_website_details(url):
    """
    EXPERIMENTELL: Scrapt eine Website und extrahiert spezifische Details.
    Diese Funktion muss je nach Zielwebsite(s) implementiert/angepasst werden.

    Args:
        url (str): Die URL der Website.

    Returns:
        str: Extrahierte Details als String oder Fehler/k.A.
    """
    # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
    logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
    # Pruefen Sie auf ungueltige oder leere URLs
    if not url or not isinstance(url, str) or url.strip().lower() in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"]:
        logger.debug(f"scrape_website_details skipped: Ungueltige oder leere URL '{url}'.")
        return "k.A." # Gebe "k.A." zurueck bei ungueltigen Eingaben


    logger.warning(f"Ausführe 'scrape_website_details' fuer URL {url[:100]}...") # Logge den Start auf Warning

    try:   # Beginn des try-Blocks
        # Hilfsfunktion zum Abrufen des Soup-Objekts mit Retry.
        @retry_on_failure
        def get_soup_for_details(target_url):
             if not target_url or not isinstance(target_url, str): 
                 logger.error(f"get_soup_for_details: Ungültige URL '{target_url}' erhalten.")
                 raise ValueError(f"Ungültige URL für get_soup_for_details: {target_url}") 

             if not target_url.lower().startswith(("http://", "https://")):
                 logger.debug(f"get_soup_for_details: Füge 'https://' zu URL '{target_url}' hinzu.")
                 target_url = "https://" + target_url
             
             response = requests.get(target_url, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15), verify=True) 
             response.raise_for_status()
             response.encoding = response.apparent_encoding
             return BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser'))
        # Ende der Definition von get_soup_for_details

        # Rufen Sie die Hilfsfunktion auf, um das Soup-Objekt zu erhalten
        # Diese Zeile muss auf derselben Einrückungsebene wie die @retry_on_failure Zeile sein,
        # da sie die nächste Anweisung innerhalb des try-Blocks nach der Funktionsdefinition ist.
        soup = get_soup_for_details(url) # Korrekte Einrückung (innerhalb des try-Blocks)

        if soup: # Korrekte Einrückung (innerhalb des try-Blocks)
             # --- Extrahiere spezifische Details ---
             title = soup.find('title') 
             meta_desc = soup.find('meta', attrs={'name': 'description'}) 
             h1 = soup.find('h1') 

             details_list = [] 

             if title: details_list.append(f"Title: {clean_text(title.get_text())}")
             if meta_desc and meta_desc.get('content'): details_list.append(f"Description: {clean_text(meta_desc['content'])}")
             if h1: details_list.append(f"H1: {clean_text(h1.get_text())}")

             if details_list:
                  result_string = " | ".join(details_list)
                  logger.debug(f"Details fuer {url[:100]}... extrahiert: {result_string[:100]}...")
                  return result_string 
             else:
                  logger.debug(f"Keine Standard-Details (Title, Description, H1) gefunden fuer {url[:100]}...")
                  return "k.A. (Keine Standard-Details gefunden)"
        else:
            logger.error(f"Scraping fuer Details fehlgeschlagen nach Retries fuer {url[:100]}...") 
            return "k.A. (Scraping fehlgeschlagen)" 

    except Exception as e: # Dieser except-Block gehört zum äußeren try
        logger.error(f"FEHLER in scrape_website_details fuer {url[:100]}...: {type(e).__name__} - {e}")
        logger.debug(traceback.format_exc()) # Hinzugefügt für besseres Debugging
        return f"k.A. (Fehler: {str(e)[:100]}...)"


def is_valid_wikipedia_article_url(url_to_check, lang=None):
    """
    Prueft, ob eine gegebene URL zu einem gueltigen, existierenden Wikipedia-Artikel
    fuehrt (keine Begriffsklaerung, kein Fehler).
    Nutzt die wikipedia-Bibliothek.

    Args:
        url_to_check (str): Die zu pruefende Wikipedia-URL.
        lang (str, optional): Die Sprache der Wikipedia (z.B. 'de', 'en').
                              Wenn None, wird die aktuell in der wikipedia-Bibliothek
                              gesetzte Sprache verwendet.

    Returns:
        bool: True, wenn die URL zu einem gueltigen Artikel fuehrt, sonst False.
    """
    logger = logging.getLogger(__name__)
    if not url_to_check or not isinstance(url_to_check, str) or "wikipedia.org/wiki/" not in url_to_check.lower():
        logger.debug(f"is_valid_wikipedia_article_url: Ungueltige URL-Struktur: {url_to_check[:100]}...")
        return False

    original_lang = None
    if lang:
        try:
            original_lang = wikipedia.get_lang() # Speichere aktuelle Sprache
            wikipedia.set_lang(lang)
            logger.debug(f"Temporaer Wikipedia-Sprache auf '{lang}' gesetzt für Validierung.")
        except Exception as e_lang:
            logger.warning(f"Konnte Wikipedia-Sprache nicht auf '{lang}' setzen für Validierung: {e_lang}")
            # Fahre mit der global eingestellten Sprache fort


    is_valid = False
    try:
        # Extrahiere den Titel aus der URL
        title_part = url_to_check.split('/wiki/', 1)[1].split('#')[0]
        title = unquote(title_part).replace('_', ' ')

        logger.debug(f"Validiere Wikipedia-Artikel: '{title[:100]}...' (URL: {url_to_check[:100]}...)")
        # Versuche, die Seite zu laden. auto_suggest=False, um keine alternativen Vorschlaege zu bekommen.
        # preload=True laedt den Inhalt direkt, um Fehler frueh zu erkennen.
        page = wikipedia.page(title, auto_suggest=False, preload=True)
        # Wenn keine Exception geworfen wurde, existiert die Seite.
        # Wir nehmen an, dass es ein gueltiger Artikel ist, wenn keine DisambiguationError auftritt.
        # Eine genauere Pruefung, ob es wirklich ein *Unternehmens*-Artikel ist,
        # wuerde die Logik von WikipediaScraper._validate_article erfordern.
        is_valid = True
        logger.debug(f"  -> Artikel '{title[:100]}...' scheint valide zu sein (Seite geladen).")

    except wikipedia.exceptions.PageError:
        logger.debug(f"  -> Seite '{title[:100]}...' nicht gefunden (PageError).")
        is_valid = False
    except wikipedia.exceptions.DisambiguationError as e_disamb:
        logger.debug(f"  -> Seite '{title[:100]}...' ist eine Begriffsklaerungsseite. Optionen: {str(e_disamb.options)[:100]}...")
        is_valid = False # Begriffsklaerungen sind keine direkten Artikel
    except Exception as e:
        logger.error(f"  -> Unerwarteter Fehler bei Validierung von '{title[:100]}...': {type(e).__name__} - {e}")
        logger.debug(traceback.format_exc())
        is_valid = False
    finally:
        if original_lang: # Setze Sprache zurueck, falls sie geaendert wurde
            try:
                wikipedia.set_lang(original_lang)
                logger.debug(f"Wikipedia-Sprache zurueck auf '{original_lang}' gesetzt.")
            except Exception as e_lang_reset:
                 logger.warning(f"Konnte Wikipedia-Sprache nicht zurueck auf '{original_lang}' setzen: {e_lang_reset}")

    return is_valid


# ==============================================================================
# Ende Website Details Scraping Funktion Block
# ==============================================================================

# ==============================================================================
# GLOBALE HELPER FUNCTIONS (PART 12: Alignment Demo Function)
# ==============================================================================

# Annahme: Als globale Funktion, wie ursprünglich von Ihnen gezeigt.
# Wenn sie in DataProcessor ist, ändern Sie `sheet` zu `self.sheet_handler.sheet`
# und fügen `self` als erstes Argument der Methode hinzu.

def alignment_demo(sheet):
    logger = logging.getLogger(__name__)
    logger.info("Starte Alignment Demo für das Hauptblatt...")

    new_headers = [
        [  # Zeile 1: Spaltenname (68 Spalten)
            "ReEval Flag", "CRM Name", "CRM Kurzform", "Parent Account Name", 
            "CRM Website", "CRM Ort", "CRM Land", "CRM Beschreibung", "CRM Branche", "CRM Beschreibung Branche extern", "CRM Anzahl Techniker", "CRM Umsatz", "CRM Anzahl Mitarbeiter", "CRM Vorschlag Wiki URL",
            "System Vorschlag Parent Account", "Parent Vorschlag Status", "Parent Vorschlag Timestamp",
            "Wiki URL", "Wiki Sitz Stadt", "Wiki Sitz Land", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Wikipedia Timestamp", "Wiki Verif. Timestamp", "SerpAPI Wiki Search Timestamp", "Chat Wiki Konsistenzpruefung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", "Begründung bei Abweichung", 
            "Website Rohtext", "Website Zusammenfassung", "Website Meta-Details", "Website Scrape Timestamp", "URL Prüfstatus",
            "Chat Vorschlag Branche", "Chat Branche Konfidenz", "Chat Konsistenz Branche", "Chat Begruendung Abweichung Branche", "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", "Finaler Umsatz (Wiki>CRM)", "Finaler Mitarbeiter (Wiki>CRM)", "Geschaetzter Techniker Bucket",
            "Plausibilität Umsatz", "Plausibilität Mitarbeiter", "Plausibilität Umsatz/MA Ratio", "Abweichung Umsatz CRM/Wiki", "Abweichung MA CRM/Wiki", "Plausibilität Begründung", 
            "Plausibilität Prüfdatum",
            "Timestamp letzte Pruefung", "Version", "Tokens"
        ],
        [  # Zeile 2: Quelle der Daten (68 Spalten)
            "CRM", "CRM", "CRM", "CRM/Manuell", 
            "CRM", "CRM", "CRM/Manuell", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM",
            "System", "Manuell/System", "System",
            "Wikipediascraper/SerpAPI/ChatGPT/Manuell", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "System", "System", "System", "ChatGPT API", "ChatGPT API", "ChatGPT API", "System/Manuell",
            "Web Scraper", "ChatGPT API", "Web Scraper", "System", "System/Web Scraper", 
            "ChatGPT API", "ChatGPT API", "System/ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API", "System/ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API",
            "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", 
            "System", "Skript (Wiki/CRM Logik)", "Skript (Wiki/CRM Logik)", "ML Modell / Skript",
            "Skript (Plausi-Check)", "Skript (Plausi-Check)", "Skript (Plausi-Check)", "Skript (Plausi-Check)", "Skript (Plausi-Check)", "Skript (Plausi-Check)", 
            "System (Plausi-Check TS)",
            "System", "System", "System"
        ],
        [  # Zeile 3: Feldkategorie (68 Spalten)
            "Prozess", "Firmenname", "Firmenname", "Konzernstruktur",
            "Website", "Ort", "Land", "Beschreibung (Text)", "Branche", "Branche", "Anzahl Servicetechniker", "Umsatz", "Anzahl Mitarbeiter", "Wikipedia Artikel URL",
            "Konzernstruktur (Vorschlag)", "Konzernstruktur (Status)", "Timestamp",
            "Wikipedia Artikel URL", "Ort", "Land", "Beschreibung (Text)", "Branche", "Umsatz", "Anzahl Mitarbeiter", "Kategorien (Text)", "Timestamp", "Timestamp", "Timestamp", "Verifizierung Wiki-Artikel", "Begründung Verifizierung", "Wikipedia Artikel URL (Vorschlag)", "Begründung URL-Abweichung",
            "Website-Content", "Website-Content (Zusammenfassung)", "Website-Content (Meta)", "Timestamp", "Prozess-Status", 
            "Branche (Vorschlag KI)", "Branche (Konfidenz KI)", "Branche (Konsistenz)", "Branche (Begründung KI)", "FSM Relevanz (KI)", "FSM Relevanz (Begründung KI)", "Anzahl Mitarbeiter (KI)", "Anzahl Mitarbeiter (Konsistenz KI)", "Anzahl Mitarbeiter (Begründung KI)", "Anzahl Servicetechniker (KI)", "Anzahl Servicetechniker (Begründung KI)", "Umsatz (KI)", "Umsatz (Begründung KI)",
            "Kontakte (Anzahl)", "Kontakte (Anzahl)", "Kontakte (Anzahl)", "Kontakte (Anzahl)", 
            "Timestamp", "Umsatz (Konsolidiert)", "Anzahl Mitarbeiter (Konsolidiert)", "Anzahl Servicetechniker (Bucket ML)",
            "Plausibilität", "Plausibilität", "Plausibilität", "Datenqualitäts-Indikator", "Datenqualitäts-Indikator", "Plausibilität (Text)", 
            "Timestamp (Plausi)",
            "Timestamp", "Skript Version", "API Tokens"
        ],
        [  # Zeile 4: Kurze Beschreibung (68 Spalten - JETZT AUSFÜHRLICH)
            "Systemspalte, irrelevant für den Prompt. Wird genutzt um die manuelle Neuprüfung dieses Accounts durchzuführen.", #A ReEval Flag
            "Enthält den Firmennamen nach bestem Gewissen. Firmennamen sind manchmal herausfordernd, insbesondere was unterschiedliche Schreibweisen, Firmierung, Tochter/Mutterfirmen etc. anbelangt. Zur besseren Trefferquote in der Wikipedia-Suche normalisieren wir den Firmennamen und entfernen sämtliche Firmenformen, wie z.B. AG, GmbH, SE etc.", #B CRM Name
            "Enthält eine manuell gepflegte (normalisierte) Kurzform des Firmennamens, wie auch ein Mensch die Firma nennen würde. Dies bedeutet insbesondere, dass die Firmenform wie z.B. GmbH oder AG aus dem Namen entfernt wird. Meist entspricht die Kurzform den ersten beiden Worten des Firmennamens. Manchmal sind auch Worte nötig, wenn die ersten beiden worte zu wenig Aussagekraft haben. Beispiele dafür sind beispielsweise Firmen wie 'Schmidt & Söhne', bei denen 'Schmidt &' wenig Sinn machen würde,  oder 'Philip Morris Tabakwaren' - weil in diesem Fall 'Philip Morris' zu generisch wäre bzw. wenig eindeutig.", #C CRM Kurzform
            "Name der direkten Muttergesellschaft / des Hauptkonzerns (falls zutreffend). Manuell gepflegt oder aus CRM. Beeinflusst Konsolidierung und Plausi-Checks.", #D Parent Account Name
            "Von uns ermittelte Website des Unternehmens, sofern verfügbar.", #E CRM Website
            "von uns ermittelter Ort des Unternehmens", #F CRM Ort
            "Land des Unternehmenssitzes laut CRM oder manueller Recherche. Wichtig für regionale Analysen (z.B. DACH).", #G CRM Land
            "Kurze Beschreibung der Haupttätigkeit des Unternehmens aus dem CRM-System. Dient als Input für KI-Analysen.", #H CRM Beschreibung
            "Branchenzuweisung aus dem CRM-System. Entspricht idealerweise einer Branche aus dem Ziel-Branchenschema.", #I CRM Branche
            "Von externen Datenanbietern (z.B. Dealfront) gelieferte Beschreibung der Branche des Unternehmens. Diese Branchenbeschreibung sollte in den allermeisten Fällen sehr zutreffend sein und ist vermutlich verlässlicher als die aktuelle Branche aus Spalte I.", #J CRM Beschreibung Branche extern
            "Bekannte Anzahl der Servicetechniker des Unternehmens (aus CRM oder Recherche). Dient als Ground Truth für ML.", #K CRM Anzahl Techniker
            "Umsatz des Unternehmens in Millionen Euro laut CRM oder Recherche.", #L CRM Umsatz
            "Anzahl der Mitarbeiter des Unternehmens laut CRM oder Recherche.", #M CRM Anzahl Mitarbeiter
            "Enthält aus einer alten Recherche Vorschläge für die Wikipedia URL zum Unternehmen. Dieser muss aber nicht stimmen. Sollte als Ausgangs- und Vergleichspunkt für die nachgelagerte Wikipedia-Suche dienen. Der Wert soll mit den üblichen Methoden geprüft werden z.B. kommt die normalisierte Website vor, Ähnlichkeitsprüfung des Firmennamens mit dem Artikelnamen von Wikipedia etc.", #N CRM Vorschlag Wiki URL
            "Vom System heuristisch ermittelter Vorschlag für den Parent Account (basierend auf Namensähnlichkeiten, Wiki-Infos etc.).", #O System Vorschlag Parent Account
            "Status des System-Vorschlags für Parent Account (z.B. 'x' für akzeptiert, '-' für abgelehnt, '?' für unklar zur manuellen Prüfung).", #P Parent Vorschlag Status
            "Zeitstempel der letzten Generierung/Änderung des Parent-Vorschlags/-Status.", #Q Parent Vorschlag Timestamp
            "Wikipedia URL aus der Recherche im laufenden Prozess", #R Wiki URL (alt M)
            "Aus Wikipedia-Infobox extrahierte Stadt des Unternehmenssitzes.", #S Wiki Sitz Stadt (neu)
            "Aus Wikipedia-Infobox extrahiertes Land des Unternehmenssitzes.", #T Wiki Sitz Land (neu)
            "Erster Absatz des Wikipedia-Artikels", #U Wiki Absatz (alt N)
            "Branche aus Wikipedia-Artikel soweit verfügbar", #V Wiki Branche (alt O)
            "Umsatz aus Wikipediaartikel soweit verfügbar.", #W Wiki Umsatz (alt P)
            "Anzahl Mitarbeiter laut Wikipedia sofern verfügbar.", #X Wiki Mitarbeiter (alt Q)
            "Komma-separierte Liste der Kategorien, denen der Artikel in Wikipedia zugewiesen wurde. Hier ist auch häufig eine Branche enthalten, häufig auch noch weitere Informationen etwa zur Gründung, ob sie etwa im DAX gelistet ist etc. Guter Anhaltspunkt zur Differenzierung von Unternehmenseinträgen und Wikipedia-Seiten, die kein Unternehmen beschreiben und fälschlicherweise zugewiesen wurden. \nBei jeder Unternehmensseite MUSS das Wort unternehmen in irgendeiner Art und Weise vorkommen.\nNEGATIVSIGNAL: EHEMALIGES UNTERNEHMEN -> Weist darauf hin, dass das Unternehmen nicht mehr besteht.", #Y Wiki Kategorien (alt R)
            "Zeitstempel der letzten Wikipedia-Suche und Datenextraktion für diese Zeile (jetzt für Spalten R-Y).", #Z Wikipedia Timestamp (alt AN)
            "Zeitstempel der letzten Wikipedia-Artikel-Verifizierung durch ChatGPT (Ergebnis in Spalten AC-AE).", #AA Wiki Verif. Timestamp (alt AX)
            "Zeitstempel des letzten Versuchs, eine fehlende Wiki-URL (R) über SerpAPI zu suchen.", #AB SerpAPI Wiki Search Timestamp (alt AY)
            "\"OK\" wird bei Firmen eingetragen, wo Firma und Wikipedia-Eintrag zusammenpassen. \"X\" wird bei Firmen eingetragen, wo Firma und Wikipedia-Eintrag nicht zusammenpassen.", #AC Chat Wiki Konsistenzpruefung (alt S)
            "Begründung welche Inkonsistenz aus den Daten hervorgeht.", #AD Chat Begründung Wiki Inkonsistenz (alt T)
            "URL des durch ChatGPT recherchierten Wikipedia-Artikels", #AE Chat Vorschlag Wiki Artikel (alt U)
            "XXX derzeit nicht verwendet, wird vermutlich gelöscht xxx", #AF Begründung bei Abweichung (alt V)
            "Roh extrahierter Textinhalt der Firmenwebsite. Basis für Zusammenfassung und KI-Analysen.", #AG Website Rohtext (neu)
            "KI-generierte Zusammenfassung des Website-Rohtextes (AG). Input für Branchenbewertung.", #AH Website Zusammenfassung (neu)
            "Extrahierte Meta-Daten der Website (Title, Description, H-Tags). Für schnelle Analyse & Validierung.", #AI Website Meta-Details (neu)
            "Zeitstempel des letzten Website-Scraping/Summarization-Versuchs (für AG-AI).", #AJ Website Scrape Timestamp (neu)
            "Status der URL-Prüfung (z.B. 'URL_CHECK_NEEDED', 'URL_OK', 'FEHLER_SSL'). Wird von 'check_urls' Modus gesetzt/genutzt.", #AK URL Prüfstatus (neu)
            "Durch ChatGPT ermittelte Branche des Unternehmens", #AL Chat Vorschlag Branche (alt W)
            "Konfidenz des ChatGPT-Branchenvorschlags (AL), z.B. Hoch/Mittel/Niedrig.", #AM Chat Branche Konfidenz (neu)
            "\"OK\" wird bei Firmen eingetragen, wo die Einschätzung zur Branche mit der CRM Branche übereinstimmt. \"X\" wird ausgegeben, wenn die Einschätzungen nicht zusammenpassen.", #AN Chat Konsistenz Branche (alt X)
            "Begründung für Abweichung der Branche von CRM Branche", #AO Chat Begruendung Abweichung Branche (alt Y)
            "\"OK\" wird bei Firmen eingetragen, für die FSM relevant ist, \"X\" für Firmen, für die FSM irrelevant ist.", #AP Chat Prüfung FSM Relevanz (alt Z)
            "Begründung für die Beurteilung in Spalte Chat Begründung für FSM Relevanz", #AQ Chat Begründung für FSM Relevanz (alt AA)
            "Anzahl der Mitarbeiter durch ChatGPT geschätzt.", #AR Chat Schaetzung Anzahl Mitarbeiter (alt AB)
            "\"OK\" wird bei Firmen eingetragen, für die Anzahl der Mitarbeiter grob mit der aus Spalte CRM Anzahl Mitarbeiter bzw. der Spalte Wiki Mitarbeiter übereinstimmt. \"X\" für Firmen, bei denen dies nicht zutrifft.", #AS Chat Konsistenzprüfung Mitarbeiterzahl (alt AC)
            "Begründung für Abweichende Mitarbeiterzahl", #AT Chat Begründung Abweichung Mitarbeiterzahl (alt AD)
            "Anzahl der Servicetechniker geschätzt durch Chat GPT", #AU Chat Einschätzung Anzahl Servicetechniker (alt AE)
            "Begründung für Abweichungen zur Anzahl der Techniker", #AV Chat Begründung Abweichung Anzahl Servicetechniker (alt AF)
            "Umsatz durch ChatGTP geschätzt", #AW Chat Schätzung Umsatz (alt AG)
            "Begründung für Abweichungen zum Umsatz", #AX Chat Begründung Abweichung Umsatz (alt AH)
            "Anzahl der Kontakte die zur Suche 'Serviceleiter', 'Leiter Service', 'technischer Leiter', 'Service Manager', 'Leiter Kundendienst' gefunden wurden", #AY Linked Serviceleiter gefunden (alt AI)
            "Anzahl der Kontakte die zur Suche 'Leiter IT', 'IT Leiter', 'Head of IT', 'IT-Leiter', 'CIO' gefunden wurden", #AZ Linked It-Leiter gefunden (alt AJ)
            "Anzahl der Kontakte die zur Suche 'Geschäftsführer', 'Geschäftsführung', 'GF', 'CEO', 'Geschäftsführerin', 'Managing Director', 'Geschäftsführender Gesellschafter' gefunden wurden", #BA Linked Management gefunden (alt AK)
            "Anzahl der Kontakte die zur Suche 'Disponent', 'Einsatzplaner' gefunden wurden", #BB Linked Disponent gefunden (alt AL)
            "Timestamp des Zeitpunkts zu dem die Kontaktsuche fertiggestellt wurde", #BC Contact Search Timestamp (alt AM)
            "Konsolidierter Umsatzwert in Millionen Euro. Priorisiert Wiki (W) > CRM (L). Berücksichtigt Parent-Account (D).", #BD Finaler Umsatz (Wiki>CRM)
            "Konsolidierte Mitarbeiterzahl (absolut). Priorisiert Wiki (X) > CRM (M). Berücksichtigt Parent-Account (D).", #BE Finaler Mitarbeiter (Wiki>CRM)
            "Ergebnis der Schätzung durch das trainierte Machine-Learning-Modell (Techniker-Bucket).", #BF Geschaetzter Techniker Bucket
            "Plausibilitätsstatus für den finalen Umsatzwert (BD) (z.B. OK, WARNUNG_HOCH, FEHLER_FORMAT).", #BG Plausibilität Umsatz
            "Plausibilitätsstatus für die finale Mitarbeiterzahl (BE) (z.B. OK, WARNUNG_NIEDRIG).", #BH Plausibilität Mitarbeiter
            "Plausibilitätsstatus für die Umsatz-pro-Mitarbeiter-Ratio (BD/BE).", #BI Plausibilität Umsatz/MA Ratio
            "Indikator für Abweichung (>30%) zwischen CRM-Umsatz (L) und Wiki-Umsatz (W). Berücksichtigt Parent-Logik.", #BJ Abweichung Umsatz CRM/Wiki
            "Indikator für Abweichung (>30%) zwischen CRM-MA (M) und Wiki-MA (X). Berücksichtigt Parent-Logik.", #BK Abweichung MA CRM/Wiki
            "Gesammelte Begründungen für Plausibilitätswarnungen oder -fehler aus den Spalten BG-BK.", #BL Plausibilität Begründung
            "Zeitstempel des letzten Laufs der Plausibilitäts-Checks für diese Zeile.", #BM Plausibilität Prüfdatum
            "Timestamp des Zeitpunkts zu dem die Validierung durch ChatGPT durchgeführt wurde", #BN Timestamp letzte Prüfung (alt AO)
            "Systemspalte zur Ausgabe der Skriptversion die das Ergebnis generiert hat", #BO Version (alt AP)
            "Zeigt an, wie viele Tokens für den Request benötigt wurden" #BP Tokens (alt AQ)
        ],
        [  # Zeile 5: Aufgabe / Funktion (68 Spalten - JETZT AUSFÜHRLICH)
            "Datenquelle/Prozesssteuerung: 'x' markiert Zeile für Re-Evaluation im Modus 'reeval'.", #A
            "Datenquelle: Firmenname aus CRM.", #B
            "Datenquelle: Manuell gepflegte Kurzform des Firmennamens, primär für API-Suchen (LinkedIn, SerpAPI) und Matching genutzt.", #C
            "Datenquelle: Manuell oder aus CRM gepflegter Name der Muttergesellschaft. Wird verwendet, um bei Konsolidierung und Plausibilitätsabgleich von Tochterfirmen korrekte Bezüge herzustellen.", #D
            "Datenquelle/Ziel: Offizielle Website des Unternehmens. Wird für Web-Scraping und als Info für ChatGPT genutzt. Kann durch 'website_lookup' oder 'check_urls' aktualisiert werden.", #E
            "Datenquelle: Ort des Unternehmenssitzes aus CRM.", #F
            "Datenquelle: Land des Unternehmenssitzes laut CRM. Wichtig für regionale Analysen.", #G
            "Datenquelle: Beschreibung aus CRM. Wichtiger Input für KI-Analysen (Branchen, FSM etc.).", #H
            "Datenquelle: Branchenkategorie aus CRM. Dient als Referenz und für Vergleiche mit KI-Vorschlägen.", #I
            "Datenquelle: Externe Branchenbeschreibung (z.B. von Dealfront). Zusätzlicher Input für KI-Branchenbewertung.", #J
            "Datenquelle: Recherchierte Anzahl Servicetechniker. Dient als Ground Truth für ML-Training und Validierung der KI-Schätzung.", #K
            "Datenquelle: Umsatz aus CRM (in Mio. €). Input für Konsolidierung, Plausi-Checks und ML.", #L
            "Datenquelle: Mitarbeiterzahl aus CRM (absolut). Input für Konsolidierung, Plausi-Checks und ML.", #M
            "Datenquelle: Alte/vorgeschlagene Wiki-URL aus CRM. Dient als initialer Input oder Vergleichswert für den Wiki-Prozess.", #N
            "Ziel/System: Vom Skript generierter Vorschlag für die Muttergesellschaft (basierend auf Heuristiken), zur manuellen Prüfung.", #O
            "Prozesssteuerung/Manuell: Bestätigung ('x'), Ablehnung ('-') oder Unklarheit ('?') des System-Vorschlags. Kann Übernahme in Spalte D steuern.", #P
            "System: Zeitstempel für die Bearbeitung von Spalte O und P (Parent Vorschlag).", #Q
            "Wird durch Wikipedia Scraper bereitgestellt", #R Wiki URL (alt M)
            "Quelle: Aus Wikipedia-Infobox extrahierte Stadt des Unternehmenssitzes. Ziel: Geografische Analyse.", #S Wiki Sitz Stadt
            "Quelle: Aus Wikipedia-Infobox extrahiertes Land des Unternehmenssitzes. Ziel: Geografische Analyse (DACH).", #T Wiki Sitz Land
            "Wird zunächst nicht verwendet, kann möglicherweise in einem späteren Schritt z.B. zum Vergleich mit der CRM Beschreibung genutzt werden, um auf Textähnlichkeit / Übereinstimmende Worte geprüft zu werden und damit eine Validierung des Artikels zum Account sicherzustellen.", #U Wiki Absatz (alt N)
            "Wird u.a. zur finalen Ermittlung der Branche im Ziel-Branchenschema genutzt und mit der CRM Branche bzw. CRM Beschreibung Branche Extern verglichen. Stimmen alle drei Einstufungen grob überein, bestärkt dies die urpsrüngliche Einstufung. Laufen diese Branchen weit auseinander, soll, sofern der Wikipedia-Artikel verifiziert ist, die Branche von Wikipedia als zuverlässigste Quelle bewertet werden, danach folgen die CRM Beschreibung Branche Extern und die CRM Branche an dritter Stelle.", #V Wiki Branche (alt O)
            "Wird u.a. mit CRM Umsatz zur Validierung des Unternehmens verglichen bzw. zur Bewertung der größe / Einschätzung Anzahl der Techniker bzw. Bewertung der Relevanz für FSM genutzt.", #W Wiki Umsatz (alt P)
            "Wird u.a. mit CRM Anzahl Mitarbeiter zur Validierung des Unternehmens verglichen bzw. zur Bewertung der größe / Einschätzung Anzahl der Techniker bzw. Bewertung der Relevanz für FSM genutzt.", #X Wiki Mitarbeiter (alt Q)
            "Wenn Wiki Branche nicht gepflegt ist, wird dieses Feld zur finalen Ermittlung der Branche im Ziel-Branchenschema genutzt und mit der CRM Branche bzw. CRM Beschreibung Branche Extern verglichen. Dabei muss aus dem Feld natürlich die Branche ermittelt werden, die auch hier nicht zwingend eingetragen ist. Stimmen alle drei Einstufungen grob überein, bestärkt dies die urpsrüngliche Einstufung. Laufen diese Branchen weit auseinander, soll, sofern der Wikipedia-Artikel verifiziert ist, die Branche von Wikipedia als zuverlässigste Quelle bewertet werden, danach folgen die CRM Beschreibung Branche Extern und die CRM Branche an dritter Stelle.", #Y Wiki Kategorien (alt R)
            "System: Timestamp der letzten Wikipedia-Suche/Datenextraktion (für Spalten R-Y). Steuert Wiederholung.", #Z Wikipedia Timestamp
            "System: Timestamp der letzten ChatGPT-Verifizierung des Wiki-Artikels (R). Steuert Wiederholung.", #AA Wiki Verif. Timestamp
            "System: Timestamp des letzten Versuchs, eine fehlende Wiki-URL (R) via SerpAPI zu suchen. Steuert Wiederholung.", #AB SerpAPI Wiki Search Timestamp
            "\"Es soll durch ChatGPT geprüft werden, ob anhand der vorliegenden Daten bestätigt werden kann, dass der Wikipedia-Eintrag sicher das Unternehmen beschreibt. Hierzu können sämtliche Daten miteinander verglichen werden. u.a. stimmt die Website überein, ist der Umsatz in einer ähnlichen größenordnung, passt die mitarbeiterzahl etc. Bei allen daten darf eine gewisse Unschärfe zum Vergleich (+-30%) gelten.\n Es muss teilweise etwas großzügig bewertet werden, insbesondere bei Konzernstrukturen, wo oft Töchter keinen eigenen Wikipedia-Artikel haben, aber quasi am Umsatz der Mutter hängen und damit prinzipiell die gleichen Daten zur Bewertung herangezogen werden können wie für die Mutter.\nAbweichungen sollen in der Spalte Chat Begründung Wiki Inkonsistenz begründet werden.\"", #AC Chat Wiki Konsistenzpruefung (alt S)
            "\"Liegt eine Inkonsistenz zwischen gefundenem Wikipedia-Artikel und dem Unternehmen vor, soll dies hier kurz begründet werden.\nWurde der Artikel als unpassend identifiziert, soll Chat GPT selbst einen passenden wikipedia-Artikel zum Unternehmen finden und diesen in Spalte Chat Vorschlag Wiki Artikel ausgeben.\"", #AD Chat Begründung Wiki Inkonsistenz (alt T)
            "\"Sollte durch die Wikipedia-Suche kein Artikel gefunden werden, oder der Artikel von Chat GPT als nicht zum Unternehmen passend identifiziert werden, soll Chat GPT eigenständig nach einem Wikipedia-Artikel recherchieren. Auch dieser soll die gleichen Stufen zur Qualitätsprüfung durchlaufen wie bei der Wikipedia-Suche üblich.\nDer von ChatGPT gefundene Artikel muss vom als falsch bewerteten Artikel abweichen. Wurde kein passender Artikel gefunden, soll 'kein Artikel verfügbar' ausgegeben werden\"", #AE Chat Vorschlag Wiki Artikel (alt U)
            "XXX derzeit nicht verwendet, wird vermutlich gelöscht xxx", #AF Begründung bei Abweichung (alt V)
            "Ziel: Rohtext der Website. Input für Zusammenfassung (AH).", #AG Website Rohtext
            "Ziel: KI-generierte Zusammenfassung des Website-Rohtextes (AG). Input für Branchenbewertung.", #AH Website Zusammenfassung
            "Ziel: Strukturierte Meta-Daten der Website. Für schnelle Analyse & Validierung.", #AI Website Meta-Details
            "System: Timestamp des letzten Website-Scraping/Summarization-Versuchs (für AG-AI).", #AJ Website Scrape Timestamp
            "System/Ziel: Status der URL-Prüfung. 'URL_CHECK_NEEDED' triggert Neusuche.", #AK URL Prüfstatus
            "\"Chat GPT soll anhand der vorliegenden Informationen prüfen, welcher Branche des Ziel-Branchenschemas das Unternehmen am ehesten zugeordnet werden kann. Das Ziel-Branchenschema darf nicht verändert oder erweitert werden, sondern die Vorschläge müssen genau dem Ziel-Branchenschema entsprechen.\nDie Bewertung soll möglichst ohne Abgleich mit der CRM Branche bewertet werden, da diese falsch sein könnte. ChatGPT soll auch die Firmenwebsite und ähnliche Quellen zur Bewertung des Unernehmens heranziehen.\"", #AL Chat Vorschlag Branche (alt W)
            "Ziel: Von ChatGPT eingeschätzte Konfidenz (Hoch/Mittel/Niedrig) für den Branchenvorschlag (AL).", #AM Chat Branche Konfidenz
            "Die durch uns festgelegte Branche in Spalte CRM Branche soll mit der von ChatGPT ermittelten Branche in Spalte Chat Vorschlag Branche verglichen werden.", #AN Chat Konsistenz Branche (alt X)
            "Weicht die Branche von unserer Eisntufung in Spalte CRM Branche ab, soll ChatGPT die Abweichung kurz begründen.", #AO Chat Begruendung Abweichung Branche (alt Y)
            "Chat GPT soll anhand der vorliegenden Informationen sowie eigener Recherche prüfen, ob für das Unternehmen der Einsatz einer Fieldservice Management Lösung vorteilhaft ist. Sprich hat das Unternehmen mutmaßlich einen technischen Außendienst bzw. Disponenten die mit der Planung mobiler Resourcen beschäftigt sind.", #AP Chat Prüfung FSM Relevanz (alt Z)
            "Die in Spalte Chat Begründung für FSM Relevanz soll begründet werden.", #AQ Chat Begründung für FSM Relevanz (alt AA)
            "Nur wenn kein Wikipedia-Eintrag  vorliegt (Wiki URL = \"\") soll ChatGPT auf Basis öffentlich verfügbarer Informationen z.B. durch Auswertung der Firmen-Website herausfinden oder schätzen, wieviele Mitarbeiter das Unternehmen hat. Wenn keine Schätzung möglich ist, soll \"keine Schätzung möglich\" ausgegeben werden.", #AR Chat Schaetzung Anzahl Mitarbeiter (alt AB)
            "Entspricht die durch ChatGPT ermittelte Mitarbeiterzahl der von uns ermittelten (Spalte CRM Anzahl Mitarbeiter) bzw. der durch Wikipedia ermittelten Mitarbeiterzahl (Spalte Wiki Mitarbeiter). Begründung bei Abweichung über +-30% in Spalte Chat Begründung Abweichung Mitarbeiter", #AS Chat Konsistenzprüfung Mitarbeiterzahl (alt AC)
            "Weicht die durch Chat GPT ermittelte Mitarbeiterzahl erheblich von der Anzahl der Mitarbeiter aus dem CRM (Spalte CRM Anzahl Mitarbeiter) bzw. der von Wikipedia ermittelten Anzahl (Spalte Wiki Mitarbeiter) ab, soll dies kurz begründet werden.", #AT Chat Begründung Abweichung Mitarbeiterzahl (alt AD)
            "Chat GPT soll auf basis öffentlich zugänglicher Information eine Schätzung abgeben, wieviele Servicetechniker das Unternehmen hat. Hierzu können auch Querverbindungen zwischen Anzahl der Mitarbeiter, Umsatz, Branche hergestellt werden, um eine möglichst solide Schätzung abgeben zu können. Die Schätzung soll in den Abstufungen 0, <50 , >100, >200, >500 Techniker abgegeben werden. In Entwicklung ist eine Aggegierung von branchenspezifischen Merkmalen (z.B. Umsatz, Mitarbeiterzahl) die für jede Gruppe typisch ist. In weiterer Zukunft kann hierzu möglicherweise auf ein RAG-System (Retrieval-Augmented Generation) zurückgegriffen werden.  Abweichungen der Einschätzung von der durch uns ermittelten Anzahl Servicetechniker (die relativ zuverlässig ist) sollen in Spalte Chat Begründung Abweichung Anzahl Servicetechniker ausgegeben werden. Wenn die Einschätzung zu einem ähnlichen Ergebnis kommt, soll \"OK\" ausgegeben werden.", #AU Chat Einschätzung Anzahl Servicetechniker (alt AE)
            "Weicht die Einschätzung in Spalte Chat Einschätzung Anzahl Servicetechniker von den durch uns recherchierten Ergebnissen in Spalte CRM Anzahl Techniker ab, soll dies begründet werden.", #AV Chat Begründung Abweichung Anzahl Servicetechniker (alt AF)
            "Nur wenn kein wikipedia-Eintrag vorliegt (Wiki URL = \"\") soll ChatGPT den Umsatz auf Basis seiner Daten oder der Unternehmenswebsite ermitteln. Wenn keine Schätzung möglich ist, soll \"keine Schätzung möglich\" ausgegeben werden.", #AW Chat Schätzung Umsatz (alt AG)
            "ChatGPT soll erhebliche Abweichungen beim Umsatz zwischen Chat Schätzung Umsatz, Wiki Umsatz und CRM Umsatz begründen. Sind alle Umsätze einigermaßen deckungsgleich (+-30%) soll \"OK\" ausgegeben werden.", #AX Chat Begründung Abweichung Umsatz (alt AH)
            "über SerpAPI wird gemeinsam mit der Kurzform des Unternehmensnamens (Spalte CRM Kurzform) und der folgenden Liste per ODER verknüpfung gesucht.\n- 'Serviceleiter'\n- 'Leiter Service'\n- 'technischer Leiter'\n- 'Service Manager'\n- 'Leiter Kundendienst'", #AY Linked Serviceleiter gefunden (alt AI)
            "über SerpAPI wird gemeinsam mit der Kurzform des Unternehmensnamens (Spalte CRM Kurzform) und der folgenden Liste per ODER verknüpfung gesucht.\n- 'Leiter IT'\n- 'IT Leiter'\n- 'Head of IT'\n- 'IT-Leiter'\n- 'CIO'", #AZ Linked It-Leiter gefunden (alt AJ)
            "über SerpAPI wird gemeinsam mit der Kurzform des Unternehmensnamens (Spalte CRM Kurzform) und der folgenden Liste per ODER verknüpfung gesucht.\n- 'Geschäftsführer'\n- 'Geschäftsführung'\n- 'GF'\n- 'CEO'\n- 'Geschäftsführerin'\n- 'Managing Director'\n- 'Geschäftsführender Gesellschafter'", #BA Linked Management gefunden (alt AK)
            "über SerpAPI wird gemeinsam mit der Kurzform des Unternehmensnamens (Spalte CRM Kurzform) und der folgenden Liste per ODER verknüpfung gesucht.\n- 'Disponent'\n- 'Einsatzplaner'", #BB Linked Disponent gefunden (alt AL)
            "System: Timestamp der letzten Kontaktsuche. Steuert Wiederholung.", #BC Contact Search Timestamp (alt AM)
            "Ziel: Konsolidierter Umsatz (Mio. €), Wiki > CRM, berücksichtigt Parent. Input für ML/Plausi.", #BD Finaler Umsatz (Wiki>CRM)
            "Ziel: Konsolidierte Mitarbeiterzahl, Wiki > CRM, berücksichtigt Parent. Input für ML/Plausi.", #BE Finaler Mitarbeiter (Wiki>CRM)
            "Ziel: Vom ML-Modell vorhergesagter Techniker-Bucket.", #BF Geschaetzter Techniker Bucket
            "Ziel: Plausi-Flag für finalen Umsatz (BD).", #BG Plausibilität Umsatz
            "Ziel: Plausi-Flag für finale Mitarbeiterzahl (BE).", #BH Plausibilität Mitarbeiter
            "Ziel: Plausi-Flag für Umsatz/MA-Ratio.", #BI Plausibilität Umsatz/MA Ratio
            "Ziel: Datenqualitäts-Indikator Umsatz CRM vs. Wiki, berücksichtigt Parent.", #BJ Abweichung Umsatz CRM/Wiki
            "Ziel: Datenqualitäts-Indikator MA CRM vs. Wiki, berücksichtigt Parent.", #BK Abweichung MA CRM/Wiki
            "Ziel: Zusammenfassung Plausi-Probleme.", #BL Plausibilität Begründung
            "System: Timestamp letzter Plausi-Check. Steuert Wiederholung.", #BM Plausibilität Prüfdatum
            "Wenn die ChatGPT Bewertung gestartet wird, wird der erste Eintrag ohne Zeitstempel in dieser Spalte gesucht und die Bearbeitung ab hier forgesetzt. Zeilen die bereits einen Zeitstempel haben werden bei der Bearbeitung übersprungen", #BN Timestamp letzte Prüfung (alt AO)
            "Wird durch das System befüllt", #BO Version (alt AP)
            "Wird durch Ticktokens berechnet" #BP Tokens (alt AQ)
        ]
    ]
    
    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} Spalten pro Zeile, aber Längen sind: {[len(row) for row in new_headers]}.")
         num_cols = max(len(row) for row in new_headers) if new_headers and any(row for row in new_headers) else 0 
         if num_cols == 0:
             logger.error("FEHLER: Konnte keine Spaltenanzahl für Alignment-Demo Header bestimmen.")
             return

    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) # Sollte "BQ" für 68 Spalten sein
    header_range = f"A1:{end_col_letter}{len(new_headers)}"

    logger.info(f"Schreibe Alignment-Demo Header in Bereich {header_range}...")
    try:
        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())


# ==============================================================================
# Ende Alignment Demo Funktion Block
# ==============================================================================

# ==============================================================================
# 4. HANDLER CLASSES (Google Sheet & Wikipedia)
# ==============================================================================

# --- GOOGLE SHEET HANDLER CLASS ---
# Kapselt die Interaktionen mit dem Google Sheet.
# Nutzt globale Helfer: retry_on_failure, Config, CREDENTIALS_FILE, logger,
# gspread, ServiceAccountCredentials, os, datetime, time, traceback.
class GoogleSheetHandler:
    """
    Kapselt die Interaktionen mit dem Google Sheet, inklusive Verbindung,
    Daten laden und Batch-Updates. Nutzt den retry_on_failure Decorator.
    """
    def __init__(self):
        """
        Initialisiert den Handler, stellt die Verbindung her und laedt die Daten.
        """
        # Holen Sie eine Logger-Instanz fuer diese Klasse
        self.logger = logging.getLogger(__name__ + ".GoogleSheetHandler") # <<< HINZUGEFÜGT
        # Initialisieren Sie die Attribute
        self.sheet = None
        # Daten werden hier als Instanzvariable gespeichert, um nicht bei jedem Zugriff neu laden zu muessen
        self.sheet_values = []
        # header_rows sind fix, aber wir koennen sie hier zur Klarheit definieren
        self._header_rows = 5 # Annahme: Die ersten 5 Zeilen sind Header

        self.logger.info("Initialisiere GoogleSheetHandler...") # <<< GEÄNDERT
        try:
            # Verbindung wird bei der Initialisierung aufgebaut
            self._connect()
            # Daten werden ebenfalls bei der Initialisierung geladen, nur wenn die Verbindung erfolgreich war
            if self.sheet:
                self.load_data()  # Erste Datenladung nach erfolgreicher Verbindung
            else:
                # Wenn die Verbindung fehlschlug (sheet ist None), aber keine Exception geworfen wurde (sollte nicht passieren)
                self.logger.critical( # <<< GEÄNDERT
                    "GoogleSheetHandler Init FEHLER: Verbindung konnte nicht hergestellt werden (sheet ist None)."
                )
                raise ConnectionError(
                    "Google Sheet Handler Init failed: Verbindung konnte nicht hergestellt werden."
                )
        except Exception as e:
            # Fehler bei der Initialisierung (entweder von _connect oder load_data nach Retries)
            self.logger.critical( # <<< GEÄNDERT
                f"FATAL: Fehler bei Initialisierung von GoogleSheetHandler: {type(e).__name__} - {e}"
            )
            self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
            raise ConnectionError(f"Google Sheet Handler Init failed: {e}")

    @retry_on_failure
    def _connect(self):
        """Stellt Verbindung zum Google Sheet her."""
        self.sheet = None
        self.logger.info("Versuche Verbindung mit Google Sheets herstellen...") # <<< GEÄNDERT
        try:
            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)
            self.sheet = sh.sheet1
            self.logger.info("Verbindung zu Google Sheets erfolgreich.") # <<< GEÄNDERT
        except (gspread.exceptions.APIError, requests.exceptions.RequestException, FileNotFoundError) as e:
            raise e
        except Exception as e:
            self.logger.error(f"FEHLER bei der Google Sheets Verbindung: {type(e).__name__} - {e}") # <<< GEÄNDERT
            self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
            raise e

    @retry_on_failure
    def load_data(self):
        """
        Laedt alle Daten aus dem Sheet und aktualisiert self.sheet_values.
        """
        if not self.sheet:
            self.logger.error("Fehler: Keine Sheet-Verbindung zum Laden der Daten.") # <<< GEÄNDERT
            self.sheet_values = []
            return False

        self.logger.info("Lade Daten aus Google Sheet...") # <<< GEÄNDERT
        try:
            self.sheet_values = self.sheet.get_all_values()

            if not self.sheet_values:
                self.logger.warning( # <<< GEÄNDERT
                    "Google Sheet scheint leer zu sein oder get_all_values() lieferte keine Daten."
                )
                self.headers = []
                return True

            num_rows = len(self.sheet_values)
            num_cols = len(self.sheet_values[0]) if num_rows > 0 else 0
            self.logger.info(f"Daten neu geladen: {num_rows} Zeilen, {num_cols} Spalten.") # <<< GEÄNDERT

            try:
                max_col_idx_in_map = max(COLUMN_MAP.values())
                if num_cols <= max_col_idx_in_map:
                    self.logger.warning( # <<< GEÄNDERT
                        f"Geladenes Sheet hat {num_cols} Spalten, erwartet werden aber mindestens "
                        f"{max_col_idx_in_map + 1} basierend auf COLUMN_MAP."
                    )
            except ValueError:
                self.logger.warning( # <<< GEÄNDERT
                    "COLUMN_MAP scheint leer zu sein oder enthaelt keine Werte. Kann Spaltenanzahl nicht pruefen."
                )
            except Exception as e:
                self.logger.error(f"Fehler bei der Pruefung der Spaltenanzahl gegen COLUMN_MAP: {e}") # <<< GEÄNDERT

            if num_rows > 0:
                self.headers = self.sheet_values[0]
            else:
                self.headers = []

            return True
        except (gspread.exceptions.APIError, requests.exceptions.RequestException) as e:
            raise e
        except Exception as e:
            self.logger.error( # <<< GEÄNDERT
                f"Allgemeiner Fehler beim Laden der Google Sheet Daten: {type(e).__name__} - {e}"
            )
            self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
            raise e

    def get_data(self):
        """
        Gibt die aktuell im Handler gespeicherten Datenzeilen zurueck
        (ohne die ersten N Header-Zeilen).
        """
        if not self.sheet_values or len(self.sheet_values) <= self._header_rows:
            self.logger.debug( # <<< GEÄNDERT
                f"get_data: Keine Datenzeilen verfuegbar "
                f"(geladen: {len(self.sheet_values) if self.sheet_values else 0} Zeilen, "
                f"{self._header_rows} Header)."
            )
            return []
        return self.sheet_values[self._header_rows:].copy()

    def get_all_data_with_headers(self):
        """Gibt alle aktuell im Handler gespeicherten Daten inklusive Header zurueck."""
        if not self.sheet_values:
            self.logger.debug("get_all_data_with_headers: Keine Daten im Handler gespeichert.") # <<< GEÄNDERT
            return []
        return self.sheet_values.copy()

    def _get_col_letter(self, col_idx_1_based):
        """
        Konvertiert einen 1-basierten Spaltenindex in den entsprechenden
        Google Sheets Spaltenbuchstaben (A, B, ..., Z, AA, ...).
        """
        if not isinstance(col_idx_1_based, int) or col_idx_1_based < 1:
            self.logger.error( # <<< GEÄNDERT
                f"Ungueltiger Spaltenindex ({col_idx_1_based}) fuer _get_col_letter erhalten."
            )
            return None

        string = ""
        n = col_idx_1_based
        while n > 0:
            n, remainder = divmod(n - 1, 26)
            string = chr(65 + remainder) + string
        return string

    def get_start_row_index(self, check_column_key, min_sheet_row=7):
        """
        Findet den 0-basierten Index in der DATENliste (ohne Header),
        ab einer Mindestzeilennummer im Sheet, in der der Wert in der
        Spalte (definiert durch check_column_key) EXAKT LEER ("") ist.
        Laedt die Daten vor der Pruefung neu.

        Args:
            check_column_key (str): Der Schluessel in COLUMN_MAP fuer die zu pruefende Spalte.
            min_sheet_row (int): Die 1-basierte Zeilennummer im Sheet, ab der gesucht werden soll.
                                 Standardmaessig ab Zeile 7 (erste Zeile nach 5 Headern und einer leeren).

        Returns:
            int: Der 0-basierten Index in der Datenliste (ohne Header) der ersten Zeile
                 mit einem EXAKT leeren Wert in der Zielspalte innerhalb des Suchbereichs.
                 Gibt -1 zurueck bei schwerwiegenden Fehlern (z.B. Schluessel fehlt in COLUMN_MAP).
                 Gibt die Laenge der Datenliste zurueck, wenn keine leere Zelle im Suchbereich gefunden wurde.
        """
        if not self.load_data():
            self.logger.error("Fehler beim Laden der Daten fuer get_start_row_index.") # <<< GEÄNDERT
            return -1

        data_rows = self.get_data()
        if not data_rows:
            self.logger.info("Keine Datenzeilen im Sheet gefunden. Startindex fuer leere Zelle ist 0.") # <<< GEÄNDERT
            return 0

        check_column_index = COLUMN_MAP.get(check_column_key)
        if check_column_index is None:
            self.logger.critical( # <<< GEÄNDERT
                f"FEHLER: Schluessel '{check_column_key}' nicht in COLUMN_MAP gefunden fuer get_start_row_index!"
            )
            return -1

        actual_col_letter = self._get_col_letter(check_column_index + 1)
        if actual_col_letter is None:
            self.logger.error( # <<< GEÄNDERT
                f"FEHLER: Konnte Spaltenbuchstaben fuer Index {check_column_index + 1} nicht ermitteln."
            )
            actual_col_letter = f"Index_{check_column_index + 1}"

        search_start_index_in_data = max(0, (min_sheet_row - 1) - self._header_rows)

        self.logger.info( # <<< GEÄNDERT
            f"get_start_row_index: Suche ab Daten-Index {search_start_index_in_data} "
            f"(Sheet-Zeile {search_start_index_in_data + self._header_rows + 1}) "
            f"nach EXAKT LEEREM Wert (=='') in Spalte '{check_column_key}' ({actual_col_letter})..."
        )

        if search_start_index_in_data >= len(data_rows):
            self.logger.warning( # <<< GEÄNDERT
                f"Start-Suchindex in Daten ({search_start_index_in_data}) liegt hinter der letzten Datenzeile ({len(data_rows)}). Keine leere Zelle gefunden im Suchbereich."
            )
            return len(data_rows)

        for i in range(search_start_index_in_data, len(data_rows)):
            row = data_rows[i]
            current_sheet_row = i + self._header_rows + 1

            cell_value = ""
            is_exactly_empty = True
            if len(row) > check_column_index:
                cell_value = row[check_column_index]
                if cell_value != "":
                    is_exactly_empty = False
            else:
                is_exactly_empty = True

            log_debug = (i < search_start_index_in_data + 5) or (i % 1000 == 0) or is_exactly_empty
            if log_debug:
                self.logger.debug( # <<< GEÄNDERT
                    f"  -> Pruefe Daten-Index {i} (Sheet {current_sheet_row}): "
                    f"Wert in {actual_col_letter}='{str(cell_value).strip()}' "
                    f"(Roh='{cell_value}' Typ: {type(cell_value)}). Leer? {is_exactly_empty}"
                )

            if is_exactly_empty:
                self.logger.info( # <<< GEÄNDERT
                    f"Erste Zeile ab Sheet-Zeile {min_sheet_row} mit EXAKT LEEREM Wert in Spalte "
                    f"{actual_col_letter} gefunden: Zeile {current_sheet_row} (Daten-Index {i})"
                )
                return i

        last_data_index = len(data_rows)
        self.logger.info( # <<< GEÄNDERT
            f"Alle Zeilen ab Daten-Index {search_start_index_in_data} im Suchbereich haben einen "
            f"nicht-leeren Wert in Spalte {actual_col_letter}. Naechster Daten-Index waere {last_data_index}."
        )
        return last_data_index

    @retry_on_failure
    def batch_update_cells(self, update_data):
        """
        Fuehrt ein Batch-Update im Google Sheet durch. Beinhaltet robustere
        Fehlerbehandlung.

        Args:
            update_data (list): Eine Liste von Dictionaries, jedes mit 'range' (str)
                                und 'values' (list of lists).
                                z.B. [{'range': 'A1', 'values': [['Wert']]}, ...]

        Returns:
            bool: True bei Erfolg (nach allen Retries), False bei endgueltigem Fehler.
        """
        if not self.sheet:
            self.logger.error("FEHLER: Keine Sheet-Verbindung fuer Batch-Update.") # <<< GEÄNDERT
            return False

        if not update_data:
            return True

        try:
            total_cells_to_update = sum(
                len(row) for item in update_data for row in item.get('values', [])
            )
            self.logger.debug( # <<< GEÄNDERT
                f"  -> Versuche sheet.batch_update mit {len(update_data)} Anfragen "
                f"({total_cells_to_update} Zellen)..."
            )
            self.sheet.batch_update(update_data, value_input_option='USER_ENTERED')
            return True
        except Exception:
            self.logger.error( # <<< GEÄNDERT
                f"Endgueltiger Fehler beim Batch-Update nach Retries. Kann {len(update_data)} Operationen nicht durchfuehren."
            )
            return False


# --- WIKIPEDIA SCRAPER CLASS ---
# Handhabt das Suchen und Extrahieren von Daten aus Wikipedia.
# Nutzt globale Helfer: wikipedia, requests, BeautifulSoup, Config, getattr, logger,
#                      retry_on_failure, simple_normalize_url, normalize_company_name,
#                      extract_numeric_value, clean_text, unquote, time, traceback.
class WikipediaScraper:
    """
    Handhabt das Suchen von Wikipedia-Artikeln und das Extrahieren relevanter
    Unternehmensdaten. Beinhaltet Validierungslogik fuer Artikel.
    Nutzt die wikipedia-Bibliothek und Requests fuer direktes HTML-Scraping.
    """
    def __init__(self, user_agent=None):
        """
        Initialisiert den Scraper mit einer Requests-Session und konfigurierter
        Wikipedia-Bibliothek.

        Args:
            user_agent (str, optional): Der User-Agent fuer Requests.
                                        Defaults to Config.USER_AGENT.
        """
        # Erhalten Sie eine Logger-Instanz fuer diese Klasse
        self.logger = logging.getLogger(__name__ + ".WikipediaScraper") # <<< HINZUGEFÜGT
        self.logger.debug("WikipediaScraper initialisiert.") # <<< GEÄNDERT

        # User-Agent fuer Requests (nutzt Config, Fallback wenn nicht gesetzt)
        self.user_agent = user_agent or getattr(
            Config, 'USER_AGENT',
            'Mozilla/5.0 (compatible; UnternehmenSkript/1.0; +http://www.example.com/bot)'
        )
        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.") # <<< GEÄNDERT

        # Keywords fuer die Infobox-Extraktion
        self.keywords_map = {
            'branche': ['branche', 'wirtschaftszweig', 'industry', 'taetigkeit', 'sektor', 'produkte', 'leistungen'],
            'umsatz': ['umsatz', 'erloes', 'revenue', 'jahresumsatz', 'konzernumsatz', 'ergebnis'],
            'mitarbeiter': ['mitarbeiter', 'mitarbeiterzahl', 'beschaeftigte', 'employees', 'number of employees', 'personal', 'belegschaft'],
            'sitz': ['sitz', 'hauptsitz', 'unternehmenssitz', 'firmensitz', 'headquarters', 'standort', 'sitz des unternehmens', 'anschrift', 'adresse'] # <<< NEU / ERWEITERT            
        }

        # Konfiguriere die wikipedia-Bibliothek
        try:
            wiki_lang = getattr(Config, 'LANG', 'de')
            wikipedia.set_lang(wiki_lang)
            # wikipedia.set_rate_limiting(True, min_wait=0.1) # <<< ALTE ZEILE (auskommentieren oder löschen)
            wikipedia.set_rate_limiting(False) # <<< NEUE ZEILE: Ratenbegrenzung deaktivieren
            self.logger.info( # <<< GEÄNDERT
                f"Wikipedia library language set to '{wiki_lang}'. Rate limiting DISABLED." # <<< KORRIGIERTE LOG-NACHRICHT
            )
        except Exception as e:
            self.logger.warning(f"Fehler beim Setzen der Wikipedia-Sprache oder Rate Limiting: {e}") # <<< GEÄNDERT

    def _get_full_domain(self, website):
        """Extrahiert die normalisierte Domain (ohne www, ohne Pfad) aus einer URL."""
        return simple_normalize_url(website)

    def _generate_search_terms(self, company_name, website):
        """
        Generiert eine Liste von Suchbegriffen fuer die Wikipedia-Suche,
        inklusive normalisiertem Namen, Kurzformen und Domain.
        """
        if not company_name:
            return []
        terms = set()

        original_name_cleaned = str(company_name).strip()
        if original_name_cleaned:
            terms.add(original_name_cleaned)

        normalized_name = normalize_company_name(company_name)
        if normalized_name:
            terms.add(normalized_name)
            name_parts = normalized_name.split()
            if len(name_parts) > 0:
                terms.add(name_parts[0])
            if len(name_parts) > 1:
                terms.add(" ".join(name_parts[:2]))

        full_domain = self._get_full_domain(website)
        if full_domain != "k.A.":
            terms.add(full_domain)

        final_terms = [term for term in list(terms) if term][:getattr(Config, 'WIKIPEDIA_SEARCH_RESULTS', 5)]
        self.logger.debug(f"Generierte Suchbegriffe fuer '{company_name[:100]}...': {final_terms}") # <<< GEÄNDERT
        return final_terms

    @retry_on_failure
    def _get_page_soup(self, url):
        """
        Holt HTML von einer URL (requests) und gibt ein BeautifulSoup-Objekt zurueck.
        """
        if not url or not isinstance(url, str) or not url.lower().startswith(("http://", "https://")):
            self.logger.warning(f"_get_page_soup: Ungueltige URL '{url[:100]}...'.") # <<< GEÄNDERT
            return None
        try:
            self.logger.debug(f"_get_page_soup: Rufe URL ab: {url[:100]}...") # <<< GEÄNDERT
            response = self.session.get(url, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15))
            response.raise_for_status()
            response.encoding = response.apparent_encoding
            soup = BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser'))
            self.logger.debug(f"_get_page_soup: Parsen von {url[:100]}... erfolgreich.") # <<< GEÄNDERT
            return soup
        except Exception as e:
            self.logger.error(f"_get_page_soup: Fehler beim Abrufen oder Parsen von HTML von {url[:100]}...: {type(e).__name__} - {e}") # <<< GEÄNDERT
            raise e

    def _validate_article(self, page, company_name, website):
        """
        Validiert, ob ein Wikipedia-Artikel (represented by the Page object)
        zum Unternehmen passt.
        """
        if not page or not company_name:
            return False
        self.logger.debug( # <<< GEÄNDERT
            f"Validiere Artikel '{page.title[:100]}...' (URL: {page.url[:100]}...) "
            f"fuer Firma '{company_name[:100]}' (Website: {website[:100]})..."
        )

        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 moeglich, da Normalisierung eines Namens fehlschlug.") # <<< GEÄNDERT
            return False

        standard_threshold = getattr(Config, 'SIMILARITY_THRESHOLD', 0.65)
        similarity = fuzzy_similarity(normalized_title, normalized_company)
        self.logger.debug(f"  -> Gesamt-Aehnlichkeit (normalized): {similarity:.2f}") # <<< GEÄNDERT

        company_tokens = normalized_company.split()
        title_tokens = normalized_title.split()
        first_word_match = False
        first_two_words_match = False
        if company_tokens and title_tokens and 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

        domain_found = False
        full_domain = self._get_full_domain(website)
        if full_domain != "k.A.":
            self.logger.debug(f"  -> Suche nach Domain '{full_domain}' in externen Links des Artikels...") # <<< GEÄNDERT
            try:
                article_html = page.html() 
                if article_html:
                    soup = BeautifulSoup(article_html, getattr(Config, 'HTML_PARSER', 'html.parser'))
                    external_links = soup.select('a[href^="http"]') 
                    relevant_links = []
                    for link_tag in external_links: 
                        href = link_tag.get('href', '')
                        if href and isinstance(href, str) and full_domain in simple_normalize_url(href):
                            if not any(ex in href.lower() for ex in ['wikipedia.org', 'wikimedia.org', 'wikidata.org', 'archive.org', 'webcitation.org']):
                                relevant_links.append(link_tag)
                    if relevant_links:
                        domain_found = True
            except KeyError as e_key: 
                if 'extlinks' in str(e_key).lower(): # Spezifisch für diesen häufigen Fehler
                    self.logger.warning(f"KeyError ('{e_key}') beim Zugriff auf page.html() oder Parsing für Domain-Check bei Artikel '{page.title[:100]}...'. Domain-Validierung übersprungen.")
                    domain_found = False 
                else:
                    self.logger.error(f"Unerwarteter KeyError bei Domain-Prüfung für '{page.title[:100]}...': {e_key}")
                    domain_found = False # Im Zweifel als nicht gefunden werten
                    # Überlegen, ob hier nicht `raise e_key` besser wäre, wenn es nicht 'extlinks' ist.
            except Exception as e_link_check:
                 self.logger.error(
                    f"Allgemeiner Fehler waehrend der Domain-Link-Pruefung fuer '{page.title[:100]}...': "
                    f"{type(e_link_check).__name__} - {e_link_check}"
                )
                 domain_found = False


        # Dynamische Schwellenwert-Entscheidung
        is_valid = False
        reason = "Keine Validierungsregel traf zu"
        if similarity >= standard_threshold:
            is_valid = True
            reason = f"Gesamt-Aehnlichkeit ({similarity:.2f}) >= Standard-Schwelle ({standard_threshold:.2f})"
        elif domain_found and first_two_words_match:
            is_valid = True
            reason = f"Domain gefunden UND erste 2 normalisierte Worte stimmen ueberein (Sim={similarity:.2f})"
        elif domain_found and first_word_match and similarity >= 0.40:
            is_valid = True
            reason = f"Domain gefunden UND erstes normalisiertes Wort stimmt ueberein UND Aehnlichkeit >= 0.40 (Sim={similarity:.2f})"
        elif first_two_words_match and similarity >= 0.45:
            is_valid = True
            reason = f"Erste zwei normalisierte Worte stimmen ueberein UND Aehnlichkeit >= 0.45 (Sim={similarity:.2f})"
        elif domain_found and similarity >= 0.50:
            is_valid = True
            reason = f"Domain gefunden UND Aehnlichkeit >= 0.50 (Sim={similarity:.2f})"
        elif first_word_match and similarity >= 0.55:
            is_valid = True
            reason = f"Erstes normalisiertes Wort stimmt ueberein UND Aehnlichkeit >= 0.55 (Sim={similarity:.2f})"

        log_level = logging.INFO if is_valid else logging.DEBUG
        self.logger.log( # <<< GEÄNDERT
            log_level,
            f"  => Artikel '{page.title[:100]}...' "
            f"{'VALIDIERT' if is_valid else 'NICHT validiert'} "
            f"(Grund: {reason}. Details: Sim={similarity:.2f}, Domain? {domain_found}, "
            f"1stWord? {first_word_match}, 2ndWord? {first_two_words_match})"
        )
        return is_valid

    def _extract_first_paragraph_from_soup(self, soup):
        """
        Extrahiert den ersten aussagekraeftigen Absatz aus dem Soup-Objekt eines Wikipedia-Artikels.
        Entfernt Referenzen und unerwuenschte Elemente.
        """
        if not soup:
            return "k.A."
        paragraph_text = "k.A."
        try:
            content_div = soup.find('div', class_='mw-parser-output')
            search_area = content_div if content_div else soup

            paragraphs = search_area.find_all('p', recursive=False)
            if not paragraphs:
                paragraphs = search_area.find_all('p')

            for p in paragraphs:
                for sup in p.find_all('sup', class_='reference'):
                    sup.decompose()
                for span in p.find_all('span', style=lambda v: v and 'display:none' in v):
                    span.decompose()
                for span in p.find_all('span', id='coordinates'):
                    span.decompose()

                text = clean_text(p.get_text(separator=' ', strip=True))
                if text != "k.A." and len(text) > 50:
                    if not re.match(
                        r'^(Datei:|Abbildung:|Siehe auch:|Einzelnachweise|Siehe auch|Literatur)',
                        text,
                        re.IGNORECASE
                    ):
                        paragraph_text = text[:1500]
                        break

            if paragraph_text == "k.A.":
                self.logger.debug("Kein passender erster Absatz gefunden nach Pruefung der <p>-Tags.") # <<< GEÄNDERT
        except Exception as e:
            self.logger.error(f"Fehler beim Extrahieren des ersten Absatzes: {type(e).__name__} - {e}") # <<< GEÄNDERT
            self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
        return paragraph_text

    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 isinstance(c, str) and c.strip() and "kategorien:" not in c.lower()
                    ]
                    self.logger.debug(f"Kategorien gefunden: {cats_filtered}") # <<< GEÄNDERT
                else:
                    self.logger.debug("Kein 'ul' Tag in 'mw-normal-catlinks' gefunden.") # <<< GEÄNDERT
            else:
                self.logger.debug("Kein 'div#mw-normal-catlinks' gefunden.") # <<< GEÄNDERT
        except Exception as e:
            self.logger.error(f"Fehler beim Extrahieren der Kategorien: {type(e).__name__} - {e}") # <<< GEÄNDERT
            self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
        return ", ".join(cats_filtered) if cats_filtered else "k.A."

    def _extract_infobox_value(self, soup, target):
        """
        Extrahiert gezielt Branche, Umsatz oder Mitarbeiter aus der Infobox
        eines Wikipedia-Artikels Soup-Objekts.
        """
        self.logger.debug(f"--- Entering _extract_infobox_value for target '{target}' ---") # <<< GEÄNDERT
        if not soup or target not in self.keywords_map:
            self.logger.debug( # <<< GEÄNDERT
                f"_extract_infobox_value: Ungueltiger 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}") # <<< GEÄNDERT

        infobox = soup.select_one('table[class*="infobox"]')
        if not infobox:
            self.logger.debug("  -> KEINE Infobox via select_one 'table[class*=\"infobox\"]' gefunden.") # <<< GEÄNDERT
            return "k.A."
        self.logger.debug("  -> Infobox gefunden.") # <<< GEÄNDERT

        value_found = "k.A."
        try:
            rows = infobox.find_all('tr')
            for idx, row in enumerate(rows):
                cells = row.find_all(['th', 'td'], recursive=False)
                header_text = None
                value_cell = None

                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]
                elif len(cells) >= 2 and cells[0].name == 'td' and cells[1].name == 'td':
                    style = cells[0].get('style', '').lower()
                    first_cell_is_header_like = False
                    if 'font-weight' in style and ('bold' in style or '700' in style):
                        first_cell_is_header_like = True
                    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]

                if header_text is not None and value_cell is not None:
                    header_text_lower = header_text.lower()
                    matched_keyword = None
                    for kw in keywords:
                        if kw in header_text_lower:
                            matched_keyword = kw
                            break
                    if matched_keyword:
                        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'])
                            ):
                                sup.decompose()

                        raw_value_text = value_cell.get_text(separator=' ', strip=True)

                        if target == 'branche':
                            clean_val = clean_text(raw_value_text)
                            clean_val = re.sub(r'\s*\([^)]*\)', '', clean_val).strip()
                            clean_val = clean_val.split('\n')[0].strip()
                            value_found = clean_val if clean_val else "k.A."
                            self.logger.info(f"        --> Branche extrahiert: '{value_found}'") # <<< GEÄNDERT
                        elif target == 'umsatz':
                            numeric_val_str = extract_numeric_value(raw_value_text, is_umsatz=True)
                            value_found = numeric_val_str
                            self.logger.info( # <<< GEÄNDERT
                                f"        --> Umsatz extrahiert (aus '{raw_value_text[:50]}...'): '{value_found}'"
                            )
                        elif target == 'mitarbeiter':
                            numeric_val_str = extract_numeric_value(raw_value_text, is_umsatz=False)
                            value_found = numeric_val_str
                            self.logger.info( # <<< GEÄNDERT
                                f"        --> Mitarbeiter extrahiert (aus '{raw_value_text[:50]}...'): '{value_found}'"
                            )
                        elif target == 'sitz': # Wir nennen es intern 'sitz', um den ganzen String zu bekommen
                            # Logik zum Extrahieren des gesamten Sitz-Strings (weitgehend wie zuvor für Branche)
                            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']) ):
                                    sup.decompose()
                            
                            raw_sitz_text = value_cell.get_text(separator=' ', strip=True)
                            cleaned_sitz_text = clean_text(raw_sitz_text) # clean_text anwenden
                            
                            # Manchmal gibt es mehrere Zeilen, nur die erste nehmen oder intelligent verbinden
                            cleaned_sitz_text = cleaned_sitz_text.split('\n')[0].strip()
                            
                            value_found = cleaned_sitz_text if cleaned_sitz_text else "k.A."
                            self.logger.info(f"        --> Roher Sitz-Text extrahiert (aus '{raw_value_text[:50]}...'): '{value_found}'")
                            # Die Aufteilung in Stadt und Land erfolgt in extract_company_data            
                        break

            if value_found != "k.A.":
                self.logger.debug(f"  -> Finaler Wert fuer '{target}' gefunden: '{value_found}'") # <<< GEÄNDERT
            else:
                self.logger.debug( # <<< GEÄNDERT
                    f"  -> Kein passender Eintrag fuer '{target}' in der gesamten Infobox gefunden."
                )
        except Exception as e:
            self.logger.exception( # <<< GEÄNDERT
                f"Fehler beim Durchlaufen der Infobox-Zeilen fuer '{target}': {e}"
            )
            return "k.A. (Fehler Extraktion)"

        return value_found

    def _parse_sitz_string_detailed(self, raw_sitz_string_input):
        """
        Versucht, aus einem rohen Sitz-String Stadt und Land detailliert zu extrahieren.
        Nutzt erweiterte Länderlisten und Heuristiken.

        Args:
            raw_sitz_string_input (str): Der zu parsende String.

        Returns:
            dict: {'sitz_stadt': '...', 'sitz_land': '...'}
        """
        sitz_stadt_val = "k.A."
        sitz_land_val = "k.A."

        if not raw_sitz_string_input or not isinstance(raw_sitz_string_input, str):
            return {'sitz_stadt': sitz_stadt_val, 'sitz_land': sitz_land_val}

        temp_sitz = raw_sitz_string_input.strip()
        if not temp_sitz or temp_sitz.lower() == "k.a.":
            return {'sitz_stadt': sitz_stadt_val, 'sitz_land': sitz_land_val}

        # --- Definitionen (könnten für Performance auch Klassenattribute sein) ---
        known_countries_detailed = {
            "deutschland": "Deutschland", "germany": "Deutschland", "de": "Deutschland", "brd": "Deutschland", "d-": "Deutschland",
            "österreich": "Österreich", "austria": "Österreich", "at": "Österreich", "a-": "Österreich",
            "schweiz": "Schweiz", "switzerland": "Schweiz", "ch": "Schweiz", "suisse": "Schweiz", "svizzera": "Schweiz", "ch-": "Schweiz",
            "usa": "USA", "u.s.": "USA", "u.s.a.": "USA", "united states": "USA", "vereinigte staaten": "USA",
            "vereinigtes königreich": "Vereinigtes Königreich", "united kingdom": "Vereinigtes Königreich", "uk": "Vereinigtes Königreich", "gb": "Vereinigtes Königreich", "england": "Vereinigtes Königreich",
            "frankreich": "Frankreich", "france": "Frankreich", "fr": "Frankreich", "f-": "Frankreich",
            "niederlande": "Niederlande", "netherlands": "Niederlande", "nl": "Niederlande", "holland": "Niederlande",
            "belgien": "Belgien", "belgium": "Belgien", "be": "Belgien",
            "luxemburg": "Luxemburg", "luxembourg": "Luxemburg", "lu": "Luxemburg",
            "italien": "Italien", "italy": "Italien", "it": "Italien", "i-": "Italien",
            "spanien": "Spanien", "spain": "Spanien", "es": "Spanien", "españa": "Spanien",
            "polen": "Polen", "poland": "Polen", "pl": "Polen",
            "japan": "Japan", "jp": "Japan",
            "kanada": "Kanada", "canada": "Kanada", # "ca" ist hier wegen US-Staaten problematisch als alleiniger Key
            "taiwan": "Taiwan",
            "dänemark": "Dänemark", "denmark": "Dänemark", "dk": "Dänemark",
            "schweden": "Schweden", "sweden": "Schweden", "se": "Schweden",
            "norwegen": "Norwegen", "norway": "Norwegen", "no": "Norwegen",
            "finnland": "Finnland", "finland": "Finnland", "fi": "Finnland",
            "irland": "Irland", "ireland": "Irland", "ie": "Irland",
            "litauen": "Litauen", "lithuania": "Litauen", "lt": "Litauen",
            # ... (Weitere nach Bedarf aus Ihrer CSV und Beobachtungen ergänzen)
        }
        
        region_to_country = {
            "nrw": "Deutschland", "nordrhein-westfalen": "Deutschland", "hessen": "Deutschland",
            "bayern": "Deutschland", "bavaria": "Deutschland", "baden-württemberg": "Deutschland", "bw": "Deutschland",
            "zg": "Schweiz", "zug": "Schweiz", "zh": "Schweiz", "zürich": "Schweiz", "be": "Schweiz", "bern": "Schweiz",
            "ag": "Schweiz", "aargau": "Schweiz", "sg": "Schweiz", "st. gallen": "Schweiz",
            "va": "USA", "virginia": "USA", "ca": "USA", "california": "USA", 
            "ny": "USA", "new york": "USA", "il": "USA", "illinois": "USA",
            "tx": "USA", "texas": "USA", "fl": "USA", "florida": "USA",
            "pa": "USA", "pennsylvania": "USA", "oh": "USA", "ohio": "USA",
            "ma": "USA", "massachusetts": "USA", "nj": "USA", "new jersey": "USA",
            "on": "Kanada", "ontario": "Kanada", # Beispiel für Kanada
            # ... (Weitere nach Bedarf)
        }
        # --- Ende Definitionen ---

        extracted_country = ""
        original_temp_sitz = temp_sitz # Für späteren Abgleich

        # 1. Land in Klammern am Ende: Stadt (Land) oder Stadt (Region)
        klammer_match = re.search(r'\(([^)]+)\)$', temp_sitz)
        if klammer_match:
            potential_suffix_in_klammer = klammer_match.group(1).strip().lower()
            if potential_suffix_in_klammer in known_countries_detailed:
                extracted_country = known_countries_detailed[potential_suffix_in_klammer]
                temp_sitz = temp_sitz[:klammer_match.start()].strip(" ,")
            elif potential_suffix_in_klammer in region_to_country:
                extracted_country = region_to_country[potential_suffix_in_klammer]
                temp_sitz = temp_sitz[:klammer_match.start()].strip(" ,")

        # 2. Ländercode-Präfix (z.B. D-PLZ, CH-PLZ)
        if not extracted_country:
            prefix_match = re.match(r'^([A-Za-z]{1,3})\s*-\s*(\d{4,}[\w\s-]*)$', temp_sitz, re.IGNORECASE)
            if prefix_match:
                code, rest_nach_plz = prefix_match.group(1).lower(), prefix_match.group(2)
                if code in known_countries_detailed:
                    extracted_country = known_countries_detailed[code]
                    temp_sitz = rest_nach_plz.strip() # Der Rest nach dem Präfix und PLZ ist die Stadt
                # Fallback für US-Staaten Codes (z.B. VA, U.S.)
                elif code in region_to_country and region_to_country[code] == "USA":
                     extracted_country = "USA"
                     temp_sitz = rest_nach_plz.strip()


        # 3. Komma-getrennte Liste: Land oder Region am Ende
        if not extracted_country and ',' in temp_sitz:
            parts = [p.strip() for p in temp_sitz.split(',')]
            if len(parts) > 1:
                # Prüfe die letzten Teile auf bekannte Länder oder Regionen
                # Prüfe von längeren Suffixen zu kürzeren
                for num_suffix_parts in range(min(3, len(parts)-1 ), 0, -1): # max 3 Teile als Suffix, min 1
                    potential_suffix = ", ".join(parts[-(num_suffix_parts):]).lower()
                    if potential_suffix in known_countries_detailed:
                        extracted_country = known_countries_detailed[potential_suffix]
                        temp_sitz = ", ".join(parts[:-(num_suffix_parts)]).strip(" ,")
                        break
                    elif potential_suffix in region_to_country:
                        extracted_country = region_to_country[potential_suffix]
                        temp_sitz = ", ".join(parts[:-(num_suffix_parts)]).strip(" ,")
                        break
                if not extracted_country: # Fallback, falls oben nichts passte, nur den letzten Teil prüfen
                    last_part_lower = parts[-1].lower()
                    if last_part_lower in known_countries_detailed:
                        extracted_country = known_countries_detailed[last_part_lower]
                        temp_sitz = ", ".join(parts[:-1]).strip(" ,")
                    elif last_part_lower in region_to_country:
                        extracted_country = region_to_country[last_part_lower]
                        temp_sitz = ", ".join(parts[:-1]).strip(" ,")
        
        # 4. Land steht direkt am Ende des (Rest-)Strings (ohne Komma davor)
        if not extracted_country:
            # Sortiere Länder nach Länge absteigend, um spezifischere Übereinstimmungen zuerst zu finden
            sorted_countries = sorted(known_countries_detailed.keys(), key=len, reverse=True)
            for country_key in sorted_countries:
                # Suche nach " Stadt Land" oder nur "Land"
                if temp_sitz.lower().endswith(f" {country_key}"):
                    extracted_country = known_countries_detailed[country_key]
                    temp_sitz = temp_sitz[:-len(f" {country_key}")].strip(" ,")
                    break
                elif temp_sitz.lower() == country_key: # Der ganze String ist das Land
                    extracted_country = known_countries_detailed[country_key]
                    temp_sitz = ""
                    break
        
        # 5. Gesamter (verbleibender) String ist ein bekanntes Land
        if not extracted_country and temp_sitz.lower() in known_countries_detailed:
            extracted_country = known_countries_detailed[temp_sitz.lower()]
            temp_sitz = ""

        sitz_land_val = extracted_country if extracted_country else "k.A."
        
        # Stadt ist der Rest, PLZ entfernen
        sitz_stadt_val = re.sub(r'^\d{4,8}\s*', '', temp_sitz).strip(" ,")
        if not sitz_stadt_val: # Wenn nach allem die Stadt leer ist
            if original_temp_sitz.lower() != "k.a." and sitz_land_val == "k.A.":
                # Wenn Original was hatte und kein Land gefunden wurde, nimm Original als Stadt
                sitz_stadt_val = re.sub(r'^\d{4,8}\s*', '', original_temp_sitz).strip(" ,")
            else:
                sitz_stadt_val = "k.A."
        
        # Finale Bereinigung der Stadt, falls das Land fälschlicherweise noch drin ist
        if sitz_land_val != "k.A." and sitz_land_val in sitz_stadt_val:
            sitz_stadt_val = sitz_stadt_val.replace(sitz_land_val, "").strip(" ,")
            if not sitz_stadt_val : sitz_stadt_val = "k.A."


        return {'sitz_stadt': sitz_stadt_val, 'sitz_land': sitz_land_val}

    # Die Methode extract_company_data muss jetzt _parse_sitz_string_detailed verwenden:
    @retry_on_failure
    def extract_company_data(self, page_url):
        # ... (Anfang der Methode bleibt gleich: default_result, URL-Prüfung, soup holen) ...
        # ... (Extraktion von first_paragraph, categories_val, branche_val, umsatz_val, mitarbeiter_val bleibt gleich) ...

        self.logger.debug("  -> Extrahiere Sitz aus Infobox...")
        raw_sitz_string = self._extract_infobox_value(soup, 'sitz') # Holt den gesamten Sitz-String

        # NEU: Aufruf der detaillierten Parsing-Methode
        parsed_sitz = self._parse_sitz_string_detailed(raw_sitz_string)
        sitz_stadt_val = parsed_sitz['sitz_stadt']
        sitz_land_val = parsed_sitz['sitz_land']

        result = {
            'url': page_url,
            'sitz_stadt': sitz_stadt_val,
            'sitz_land': sitz_land_val,
            'first_paragraph': first_paragraph,
            'branche': branche_val,
            'umsatz': umsatz_val,
            'mitarbeiter': mitarbeiter_val,
            'categories': categories_val
        }
        # ... (Rest der Methode mit Logging bleibt gleich) ...
        self.logger.info(
            f"  -> Extrahierte Daten: Sitz Stadt='{sitz_stadt_val}', Sitz Land='{sitz_land_val}', P='{first_paragraph[:30]}...', "
            f"B='{branche_val}', U='{umsatz_val}', M='{mitarbeiter_val}', "
            f"C='{categories_val[:50]}...'"
        )
        return result

    @retry_on_failure
    def search_company_article(self, company_name, website=None, max_recursion_depth=1): # max_recursion_depth hinzugefügt
        """
        Sucht einen passenden Wikipedia-Artikel fuer das Unternehmen und gibt das
        wikipedia.WikipediaPage Objekt zurueck, wenn ein relevanter und validierter
        Artikel gefunden wird. Behandelt explizit Begriffsklaerungsseiten.
        """
        if not company_name or str(company_name).strip() == "":
            self.logger.warning("Wikipedia search skipped: No company name provided.")
            raise ValueError("Kein Firmenname fuer Wikipedia Suche angegeben.")

        search_terms = self._generate_search_terms(company_name, website)
        if not search_terms:
            self.logger.warning(f"Keine Suchbegriffe fuer '{company_name[:100]}...' generiert.")
            return None

        self.logger.info(
            f"Starte Wikipedia-Suche fuer '{company_name[:100]}...' "
            f"(Website: {website[:100]}...) mit Begriffen: {search_terms}"
        )
        processed_titles = set() 
        original_search_name_norm = normalize_company_name(company_name) # Einmal normalisieren
        processed_titles = set()




    def check_page_recursive(title_to_check, original_search_name_for_validation, current_depth_val):
        # Beachten Sie die Parameternamen hier. 
        # original_search_name_for_validation wird für die Ähnlichkeitsprüfung mit Optionen verwendet.
        # company_name (aus der äußeren Funktion) wird für self._validate_article verwendet.
        
        if title_to_check in processed_titles:
            self.logger.debug(f"      -> Titel '{title_to_check[:100]}...' bereits verarbeitet. Übersprungen.")
            return None
        if current_depth_val > max_recursion_depth:
            self.logger.debug(f"      -> Maximale Rekursionstiefe ({max_recursion_depth}) für '{title_to_check[:100]}...' erreicht. Stoppe.")
            return None
        
        processed_titles.add(title_to_check)
        self.logger.debug(f"      -> Pruefe potenziellen Artikel: '{title_to_check[:100]}...' (Tiefe: {current_depth_val})")

        normalized_option_title_local = normalize_company_name(title_to_check) # Lokale Variable
        title_similarity_to_original_local = fuzzy_similarity(normalized_option_title_local, original_search_name_for_validation)

        if current_depth_val > 0 and title_similarity_to_original_local < 0.3:
            self.logger.debug(f"      -> Option '{title_to_check[:100]}' hat zu geringe Ähnlichkeit ({title_similarity_to_original_local:.2f}) zum Original-Suchbegriff '{original_search_name_for_validation}'. Übersprungen.")
            return None
        
        page = None
        try:
            page = wikipedia.page(title_to_check, auto_suggest=False, preload=False, redirect=True)
            if self._validate_article(page, company_name, website): # Hier wird der company_name der äußeren Funktion verwendet
                self.logger.info(f"      -> Titel '{page.title[:100]}...' erfolgreich validiert!")
                return page # Gibt das Page-Objekt zurück
            else:
                self.logger.debug(f"      -> Titel '{page.title[:100]}...' nicht validiert.")
                return None # Nicht validiert
        
        except wikipedia.exceptions.DisambiguationError as e_disamb:
            self.logger.info(
                f"  -> Begriffsklaerung '{e_disamb.title}' gefunden für '{title_to_check[:100]}...' (Tiefe {current_depth_val}). "
                f"Pruefe Optionen (max 3 nach Filter): {str(e_disamb.options[:10])[:200]}..."
            )
            if current_depth_val >= max_recursion_depth:
                self.logger.warning(f"  -> Max Rekursionstiefe für Begriffsklärung '{e_disamb.title}' erreicht.")
                return None

            relevant_options = []
            # ... (Filterlogik für Optionen)
            for option_title_iter in e_disamb.options: # Iterationsvariable umbenannt
                # ... (Ihre Filterlogik hier, die relevant_options füllt)
                option_lower_iter = option_title_iter.lower() # Lokale Variable
                if not any(ex in option_lower_iter for ex in [
                    "(person)", "(familienname)", # etc.
                ]) and len(option_title_iter) < 80:
                    normalized_option_iter = normalize_company_name(option_title_iter)
                    if fuzzy_similarity(normalized_option_iter, original_search_name_for_validation) > 0.3:
                        relevant_options.append(option_title_iter)
            
            for option_to_check_further_iter in relevant_options[:3]:
                # Wichtig: original_search_name_for_validation weitergeben
                validated_page_from_option_iter = check_page_recursive(option_to_check_further_iter, original_search_name_for_validation, current_depth_val + 1)
                if validated_page_from_option_iter:
                    return validated_page_from_option_iter
            return None
        # ... (andere except-Blöcke) ...
        except Exception as e_page: # Allgemeinerer Fehler
            if page and hasattr(page, 'title') and page.title: # Sicherstellen, dass page.title existiert
                title_for_log = page.title[:100]
            else:
                title_for_log = title_to_check[:100]

            if "extlinks" in str(e_page).lower(): 
                self.logger.warning(f"      -> KeyError ('extlinks') beim Verarbeiten von Titel '{title_for_log}...'. Übersprungen.")
                return None
            self.logger.error(
                f"      -> Unerwarteter Fehler bei Verarbeitung von Seite '{title_for_log}...': "
                f"{type(e_page).__name__} - {e_page}"
            )
            self.logger.debug(traceback.format_exc())
            return None
        return None # Fallback, falls kein Pfad ein Ergebnis liefert
                
        return page_object_to_return # Gibt validierte Seite oder None zurück

        # --- Hauptlogik von search_company_article ---
        # Direkter Match mit dem ursprünglichen Firmennamen
        self.logger.debug(f"  -> Versuche direkten Match fuer '{company_name[:100]}...' mit check_page_recursive...")
        page_found = check_page_recursive(company_name, original_search_name_norm, 0) 
        page_found = check_page_recursive(term, original_search_name_norm, 0)
        validated_option_page = check_page_recursive(option, original_search_name_norm, current_depth + 1)
        if page_found:
            return page_found

        # Suche mit generierten Begriffen
        self.logger.debug(f"  -> Kein direkter Treffer/validiert. Starte Suche mit generierten Begriffen via check_page_recursive: {search_terms}")
        for term in search_terms:
            if term == company_name: continue # Bereits oben geprüft
            self.logger.debug(f"    -> Versuche Suchbegriff: '{term[:100]}...' mit check_page_recursive...")
            page_found = check_page_recursive(term, original_search_name_norm, current_depth=0) # Starte Rekursion für den Term
            if page_found:
                return page_found
            
            # Die wikipedia.search() wird hier nicht mehr primär genutzt, 
            # da check_page_recursive schon die wikipedia.page()-Suche macht.
            # Falls check_page_recursive(term) nichts findet, könnten wir hier optional
            # noch wikipedia.search() als Fallback nutzen, aber mit Vorsicht.
            # Fürs Erste lassen wir es weg, um die API-Calls zu reduzieren.
            #
            # try:
            #     search_results = wikipedia.search(term, results=getattr(Config, 'WIKIPEDIA_SEARCH_RESULTS', 2)) # Weniger Ergebnisse
            #     if search_results:
            #         self.logger.debug(f"    -> Fallback wikipedia.search für '{term[:100]}...' ergab: {search_results}")
            #         for title in search_results:
            #             page_found = check_page_recursive(title, original_search_name_norm, current_depth=0)
            #             if page_found:
            #                 return page_found
            # except Exception as e_search:
            #     self.logger.error(f"Fehler waehrend Fallback wikipedia.search() fuer '{term[:100]}...': {e_search}")

        self.logger.warning(f"Kein passender & validierter Wikipedia-Artikel fuer '{company_name[:100]}...' gefunden nach allen Versuchen.")
        return None

        # Direkter Match mit dem ursprünglichen Firmennamen
        self.logger.debug(f"  -> Versuche direkten Match fuer '{company_name[:100]}...'...")
        validated_page = check_page_recursive(company_name, current_depth=0) # Starte mit Tiefe 0
        if validated_page:
            return validated_page

        # Suche mit generierten Begriffen
        self.logger.debug(
            f"  -> Kein direkter Treffer/validiert. Starte Suche mit generierten Begriffen: {search_terms}"
        )
        for term in search_terms:
            if term == company_name: continue # Bereits oben geprüft

            try:
                self.logger.debug(f"    -> Suche mit Begriff: '{term[:100]}...'...")
                search_results = wikipedia.search(term, results=getattr(Config, 'WIKIPEDIA_SEARCH_RESULTS', 3)) # Weniger Ergebnisse pro Term
                self.logger.debug(f"    -> Suchergebnisse fuer '{term[:100]}...': {search_results}")
                if not search_results:
                    self.logger.debug(f"    -> Keine Suchergebnisse fuer '{term[:100]}...'.")
                    continue
                for title in search_results:
                    validated_page = check_page_recursive(title, current_depth=0) # Starte mit Tiefe 0 für jeden neuen Suchtreffer
                    if validated_page:
                        return validated_page
            except Exception as e_search:
                # Fehler von wikipedia.search() selbst, nicht von check_page_recursive
                self.logger.error(
                    f"Fehler waehrend Wikipedia-Suche (wikipedia.search) fuer '{term[:100]}...': "
                    f"{type(e_search).__name__} - {e_search}"
                )
                # Hier nicht direkt 'raise e_search', da der Retry-Decorator auf search_company_article liegt.
                # Wenn dies ein wiederholbarer Fehler der wikipedia-Bibliothek ist, sollte der Decorator greifen.
                # Für den Moment loggen wir und machen mit dem nächsten Suchbegriff weiter.
                # Bei einem persistenten API-Problem wird der äußere Decorator nach mehreren Versuchen abbrechen.


        self.logger.warning(
            f"Kein passender & validierter Wikipedia-Artikel fuer '{company_name[:100]}...' gefunden nach Pruefung aller Begriffe und Optionen."
        )
        return None

    @retry_on_failure
    def extract_company_data(self, page_url):
        """
        Extrahiert Firmendaten (erster Absatz, Infobox-Werte, Kategorien)
        von einer gegebenen Wikipedia-Artikel-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/wiki/" not in page_url.lower():
            self.logger.warning( # <<< GEÄNDERT
                f"extract_company_data: Ungueltige oder keine Wikipedia-URL '{page_url[:100]}...'."
            )
            return default_result

        self.logger.info(f"Extrahiere Daten fuer Wiki-URL: {page_url[:100]}...") # <<< GEÄNDERT
        soup = self._get_page_soup(page_url)
        if not soup:
            self.logger.error(f"  -> Fehler: Konnte Seite {page_url[:100]}... nicht laden oder parsen.") # <<< GEÄNDERT
            return default_result

        self.logger.debug("  -> Extrahiere erster Absatz...") # <<< GEÄNDERT
        first_paragraph = self._extract_first_paragraph_from_soup(soup)

        self.logger.debug("  -> Extrahiere Kategorien...") # <<< GEÄNDERT
        categories_val = self.extract_categories(soup)

        self.logger.debug("  -> Extrahiere Branche aus Infobox...") # <<< GEÄNDERT
        branche_val = self._extract_infobox_value(soup, 'branche')

        self.logger.debug("  -> Extrahiere Umsatz aus Infobox...") # <<< GEÄNDERT
        umsatz_val = self._extract_infobox_value(soup, 'umsatz')

        self.logger.debug("  -> Extrahiere Mitarbeiter aus Infobox...") # <<< GEÄNDERT
        mitarbeiter_val = self._extract_infobox_value(soup, 'mitarbeiter')

        self.logger.debug("  -> Extrahiere Sitz aus Infobox...")
        raw_sitz_string = self._extract_infobox_value(soup, 'sitz') # Holt den gesamten Sitz-String

        sitz_stadt_val = "k.A."
        sitz_land_val = "k.A."

        if raw_sitz_string and raw_sitz_string.lower() != "k.a.":
            temp_sitz = raw_sitz_string.strip() # Am Anfang strippen
            
            # Erweiterte Ländererkennung (Beispiele, muss ausgebaut werden)
            # Key: Kleingeschriebene Variante, Value: Standardisierter Ländername
            known_countries_detailed = {
                # Deutschland
                "deutschland": "Deutschland", "germany": "Deutschland", "de": "Deutschland", "brd": "Deutschland",
                "bundesrepublik deutschland": "Deutschland", "d-": "Deutschland", # Für "D-PLZ"
                # Österreich
                "österreich": "Österreich", "austria": "Österreich", "at": "Österreich", "a-": "Österreich",
                # Schweiz
                "schweiz": "Schweiz", "switzerland": "Schweiz", "ch": "Schweiz", "suisse": "Schweiz", 
                "svizzera": "Schweiz", "ch-": "Schweiz", "confœderatio helvetica": "Schweiz",
                # USA
                "usa": "USA", "u.s.": "USA", "u.s.a.": "USA", "united states": "USA", 
                "vereinigte staaten": "USA", "vereinigte staaten von amerika": "USA",
                # Vereinigtes Königreich
                "vereinigtes königreich": "Vereinigtes Königreich", "united kingdom": "Vereinigtes Königreich",
                "uk": "Vereinigtes Königreich", "gb": "Vereinigtes Königreich", "great britain": "Vereinigtes Königreich",
                "england": "Vereinigtes Königreich", "schottland": "Vereinigtes Königreich", # Wenn spezifisch genannt
                "wales": "Vereinigtes Königreich", "nordirland": "Vereinigtes Königreich",
                # Frankreich
                "frankreich": "Frankreich", "france": "Frankreich", "fr": "Frankreich", "f-": "Frankreich",
                # Niederlande
                "niederlande": "Niederlande", "netherlands": "Niederlande", "nl": "Niederlande", "holland": "Niederlande",
                # Belgien
                "belgien": "Belgien", "belgium": "Belgien", "be": "Belgien", "belgique": "Belgien", "belgië": "Belgien",
                # Luxemburg
                "luxemburg": "Luxemburg", "luxembourg": "Luxemburg", "lu": "Luxemburg",
                # Italien
                "italien": "Italien", "italy": "Italien", "it": "Italien", "i-": "Italien",
                # Spanien
                "spanien": "Spanien", "spain": "Spanien", "es": "Spanien", "españa": "Spanien",
                # Polen
                "polen": "Polen", "poland": "Polen", "pl": "Polen",
                # Weitere häufige
                "japan": "Japan", "jp": "Japan", "nippon": "Japan", "nihon": "Japan",
                "kanada": "Kanada", "canada": "Kanada", "ca": "Kanada",
                "china": "China", "cn": "China",
                "indien": "Indien", "in": "Indien",
                "russland": "Russland", "russian federation": "Russland", "ru": "Russland",
                "brasilien": "Brasilien", "brazil": "Brasilien", "br": "Brasilien",
                "australien": "Australien", "australia": "Australien", "au": "Australien",
                "dänemark": "Dänemark", "denmark": "Dänemark", "dk": "Dänemark",
                "schweden": "Schweden", "sweden": "Schweden", "se": "Schweden",
                "norwegen": "Norwegen", "norway": "Norwegen", "no": "Norwegen",
                "finnland": "Finnland", "finland": "Finnland", "fi": "Finnland",
                "irland": "Irland", "ireland": "Irland", "ie": "Irland",
                "taiwan": "Taiwan", "tw": "Taiwan",
                "südkorea": "Südkorea", "south korea": "Südkorea", "kr": "Südkorea", "republic of korea": "Südkorea",
                "tschechien": "Tschechien", "czech republic": "Tschechien", "cz": "Tschechien",
                "ungarn": "Ungarn", "hungary": "Ungarn", "hu": "Ungarn",
                "litauen": "Litauen", "lithuania": "Litauen", "lt": "Litauen",
                 # ... diese Liste kann und sollte basierend auf Ihren Daten erweitert werden ...
            }
            
            # Mapping von Bundesländern/Kantonen zu Ländern (Beispiele)
            region_to_country = {
                "nrw": "Deutschland", "nordrhein-westfalen": "Deutschland",
                "bayern": "Deutschland", "bavaria": "Deutschland",
                "hessen": "Deutschland",
                "baden-württemberg": "Deutschland", "bw": "Deutschland",
                "niedersachsen": "Deutschland",
                "berlin": "Deutschland", # Berlin ist auch eine Stadt, aber wenn es isoliert steht als Region
                "hamburg": "Deutschland", # dito
                "sachsen": "Deutschland",
                "sachsen-anhalt": "Deutschland",
                "thüringen": "Deutschland",
                "brandenburg": "Deutschland",
                "mecklenburg-vorpommern": "Deutschland",
                "rheinland-pfalz": "Deutschland", "rlp": "Deutschland",
                "saarland": "Deutschland",
                "schleswig-holstein": "Deutschland", "sh": "Deutschland",
                "bremen": "Deutschland",
                "zg": "Schweiz", "zug": "Schweiz", # Zug ist auch eine Stadt
                "zh": "Schweiz", "zürich": "Schweiz", # Zürich ist auch eine Stadt
                "be": "Schweiz", "bern": "Schweiz", # Bern ist auch eine Stadt
                "ag": "Schweiz", "aargau": "Schweiz",
                "sg": "Schweiz", "st. gallen": "Schweiz",
                "va": "USA", "virginia": "USA",
                "ca": "USA", "california": "USA", # Konflikt mit Kanada, wenn nur "CA"
                "ny": "USA", "new york": "USA", # New York ist auch eine Stadt
                "il": "USA", "illinois": "USA",
                "tx": "USA", "texas": "USA",
                "fl": "USA", "florida": "USA",
                "pa": "USA", "pennsylvania": "USA",
                "oh": "USA", "ohio": "USA",
                "ma": "USA", "massachusetts": "USA",
                # ... erweitern ...
            }

            extracted_country = ""

            # Priorität 1: Land in Klammern am Ende: Stadt (Land)
            klammer_match = re.search(r'\(([^)]+)\)$', temp_sitz)
            if klammer_match:
                potential_land_in_klammer = klammer_match.group(1).strip().lower()
                if potential_land_in_klammer in known_countries_detailed:
                    extracted_country = known_countries_detailed[potential_land_in_klammer]
                    temp_sitz = temp_sitz[:klammer_match.start()].strip() # Rest ist Stadt
                elif potential_land_in_klammer in region_to_country:
                    extracted_country = region_to_country[potential_land_in_klammer]
                    temp_sitz = temp_sitz[:klammer_match.start()].strip()

            # Priorität 2: Ländercode-Präfix wie D-PLZ, CH-PLZ
            if not extracted_country:
                prefix_match = re.match(r'^([A-Za-z]{1,3})\s*-\s*\d{4,}', temp_sitz) # z.B. D-12345 oder CH-1234
                if prefix_match:
                    potential_country_code = prefix_match.group(1).lower()
                    if potential_country_code in known_countries_detailed:
                        extracted_country = known_countries_detailed[potential_country_code]
                        # Entferne den Präfix und die PLZ vom temp_sitz
                        temp_sitz = re.sub(r'^[A-Za-z]{1,3}\s*-\s*\d{4,}\s*', '', temp_sitz).strip()

            # Priorität 3: Komma-getrennte Liste, Land oder Region am Ende
            if not extracted_country and ',' in temp_sitz:
                parts = [p.strip() for p in temp_sitz.split(',')]
                if len(parts) > 1:
                    # Prüfe die letzten Teile auf bekannte Länder oder Regionen
                    for k in range(len(parts) - 1, 0, -1): # Rückwärts prüfen für längere Übereinstimmungen
                        potential_suffix = ", ".join(parts[k:]).lower() # z.B. "New York, U.S." oder nur "U.S."
                        if potential_suffix in known_countries_detailed:
                            extracted_country = known_countries_detailed[potential_suffix]
                            temp_sitz = ", ".join(parts[:k]).strip()
                            break
                        elif potential_suffix in region_to_country:
                            extracted_country = region_to_country[potential_suffix]
                            temp_sitz = ", ".join(parts[:k]).strip()
                            break
                    # Wenn nur der letzte Teil geprüft wird (wie vorher)
                    if not extracted_country:
                        last_part = parts[-1].lower()
                        if last_part in known_countries_detailed:
                            extracted_country = known_countries_detailed[last_part]
                            temp_sitz = ", ".join(parts[:-1]).strip()
                        elif last_part in region_to_country:
                            extracted_country = region_to_country[last_part]
                            temp_sitz = ", ".join(parts[:-1]).strip()
            
            # Priorität 4: Gesamter String ist ein bekanntes Land (selten, aber möglich)
            if not extracted_country and temp_sitz.lower() in known_countries_detailed:
                extracted_country = known_countries_detailed[temp_sitz.lower()]
                temp_sitz = "" # Ganzer String war das Land

            # Priorität 5: Teilstring am Ende ist ein Land (z.B. "München Deutschland")
            if not extracted_country:
                for country_key, country_name in known_countries_detailed.items():
                    # Suche nach dem Land am Ende des Strings (ganzes Wort)
                    if temp_sitz.lower().endswith(f" {country_key}") or temp_sitz.lower() == country_key:
                        extracted_country = country_name
                        # Entferne das Land vom Ende des temp_sitz
                        if temp_sitz.lower().endswith(f" {country_key}"):
                            temp_sitz = temp_sitz[:-len(f" {country_key}")].strip()
                        elif temp_sitz.lower() == country_key:
                            temp_sitz = ""
                        break
            
            sitz_land_val = extracted_country if extracted_country else "k.A."
            
            # Was übrig bleibt, ist die Stadt (nach Entfernung von PLZ, Kommas am Ende)
            # Entferne führende/trailing Kommas und Leerzeichen, die durch die Länderentfernung entstehen könnten
            temp_sitz = temp_sitz.strip(',').strip()
            # Entferne erneut PLZ, falls sie jetzt am Anfang stehen
            sitz_stadt_val = re.sub(r'^\d{4,8}\s*', '', temp_sitz).strip()
            
            if not sitz_stadt_val and raw_sitz_string.lower() != "k.a." and sitz_land_val == "k.A.":
                sitz_stadt_val = raw_sitz_string.strip() 
            elif not sitz_stadt_val:
                sitz_stadt_val = "k.A."
            
            # Spezifische Korrektur für Fälle wie "München Deutschland" wo Land schon weg ist
            if sitz_stadt_val.lower() == sitz_land_val.lower() and sitz_land_val != "k.A.":
                # Wenn Stadt und Land identisch sind (z.B. nach Entfernung von "Deutschland" aus "München Deutschland")
                # und raw_sitz_string mehr enthielt
                if raw_sitz_string.lower() != sitz_land_val.lower():
                    sitz_stadt_val = raw_sitz_string.replace(sitz_land_val, '').replace(sitz_land_val.lower(), '').strip(',').strip()
                    if not sitz_stadt_val: sitz_stadt_val = "k.A."


        result = {
            'url': page_url,
            'sitz_stadt': sitz_stadt_val,
            'sitz_land': sitz_land_val,
            'first_paragraph': first_paragraph,
            'branche': branche_val,
            'umsatz': umsatz_val,
            'mitarbeiter': mitarbeiter_val,
            'categories': categories_val
        }

        self.logger.info(
            f"  -> Extrahierte Daten: Sitz Stadt='{sitz_stadt_val}', Sitz Land='{sitz_land_val}', P='{first_paragraph[:30]}...', "
            f"B='{branche_val}', U='{umsatz_val}', M='{mitarbeiter_val}', "
            f"C='{categories_val[:50]}...'"
        )
        return result

# ==============================================================================
# Ende Handler Klassen Block
# ==============================================================================

# ==============================================================================
# 5. DATA PROCESSOR CLASS (PART 1: Start & Init & Basis Status-Checker)
# ==============================================================================

class DataProcessor:
    """
    Zentrale Klasse zur Orchestrierung und Verarbeitung von Unternehmensdaten
    aus dem Google Sheet. Enthält die Logik fuer die Verarbeitung einzelner
    Zeilen sowie die Steuerung verschiedener Batch-Modi und Dienstprogramme.
    Nutzt Instanzen von Handler-Klassen (Sheet, Wiki etc.) als Worker.
    """
    def __init__(self, sheet_handler, wiki_scraper):
        """
        Initialisiert den DataProcessor mit Instanzen von Handler-Klassen.

        Args:
            sheet_handler (GoogleSheetHandler): Eine initialisierte Instanz.
            wiki_scraper (WikipediaScraper): Eine initialisierte Instanz.
            # Fuegen Sie hier weitere benoetigte Handler/Worker hinzu, falls noetig
            # (z.B. OpenAIHandler, SerpAPIHandler), falls diese als eigene Klassen ausgelagert werden.
        """
        # Erhalten Sie eine Logger-Instanz fuer diese Klasse
        self.logger = logging.getLogger(__name__ + ".DataProcessor") # <<< HINZUGEFÜGT
        self.logger.info("Initialisiere DataProcessor...") # <<< GEÄNDERT

        # Ueberpruefen Sie, ob gueltige Handler-Instanzen uebergeben wurden
        if not isinstance(sheet_handler, GoogleSheetHandler):
            # Logge einen kritischen Fehler und werfe eine Exception
            self.logger.critical("DataProcessor Init FEHLER: Kein gueltiger GoogleSheetHandler uebergeben!") # <<< GEÄNDERT
            raise ValueError("DataProcessor benoetigt eine gueltige GoogleSheetHandler Instanz.")
        if not isinstance(wiki_scraper, WikipediaScraper):
            # Logge einen kritischen Fehler und werfe eine Exception
            self.logger.critical("DataProcessor Init FEHLER: Kein gueltiger WikipediaScraper uebergeben!") # <<< GEÄNDERT
            raise ValueError("DataProcessor benoetigt eine gueltige WikipediaScraper Instanz.")

        # Speichern Sie die Handler-Instanzen als Attribute der Instanz
        self.sheet_handler = sheet_handler
        self.wiki_scraper = wiki_scraper
        # self.openai_handler = openai_handler # Beispiel, falls ausgelagert
        # self.serpapi_handler = serpapi_handler # Beispiel, falls ausgelagert

        # Attribute fuer ML-Modellierung (werden beim ersten Bedarf geladen in _load_ml_model)
        # Initialisieren Sie diese mit None
        self.model = None
        self.imputer = None
        self._expected_features = None  # Liste der erwarteten Feature-Spalten fuer Vorhersage

        self.logger.info("DataProcessor initialisiert mit Handlern.") # <<< GEÄNDERT

        # Definieren Sie hier (oder als Klassenattribut) die Zuordnung von Schritt-Typen
        # zu den relevanten Spaltenschluesseln fuer die Statuspruefung.
        # Diese werden von _should_run_based_on_status (Block 18) verwendet.
        self._step_status_map = {
            'wiki_verify': "Wiki Verif. Timestamp",
            'website_scrape': "Website Scrape Timestamp",
            'summarize_website': "Website Scrape Timestamp",
            'branch_eval': "Timestamp letzte Pruefung",
            'find_wiki_serp': "SerpAPI Wiki Search Timestamp",
            'contact_search': "Contact Search Timestamp",
            'wiki_updates_from_chatgpt': "Chat Wiki Konsistenzpruefung",
            # 'wiki_extract': "Wikipedia Timestamp",  # Speziell in _process_single_row behandelt
            # 'ml_predict': "Geschaetzter Techniker Bucket"  # Speziell in eigener Methode behandelt
        }

    # --- Interne Hilfsmethode zur Statuspruefung einer Zelle ---
    # Dient zum sicheren Abrufen von Werten aus einer Zeile unter Verwendung von COLUMN_MAP.
    # Nutzt globale Helfer: COLUMN_MAP, logger.
    def _get_cell_value_safe(self, row, column_key):
        """
        Hilfsfunktion fuer sicheren Zellenzugriff anhand des COLUMN_MAP Schluessels.
        Gibt leeren String zurueck, wenn Index nicht existiert oder Zeile zu kurz ist.

        Args:
            row (list): Die Listendaten fuer die Zeile.
            column_key (str): Der Schluessel in COLUMN_MAP fuer die zu pruefende Spalte.

        Returns:
            str: Der Wert der Zelle als String, oder '' wenn nicht verfuegbar.
        """
        # Ermitteln Sie den Index der Spalte aus COLUMN_MAP (Block 1)
        idx = COLUMN_MAP.get(column_key)

        # Pruefen Sie, ob der Schluessel in COLUMN_MAP gefunden wurde
        if idx is None:
            # Logge einen Fehler, aber gebe einen leeren String zurueck
            self.logger.error(f"_get_cell_value_safe: Schluessel '{column_key}' nicht in COLUMN_MAP gefunden.") # <<< GEÄNDERT
            return ''  # Gebe leeren String zurueck, wenn Schluessel fehlt

        # Pruefen Sie, ob die Zeile lang genug ist, um auf diesen Index zuzugreifen
        if len(row) > idx:
            # Rueckgabe des Wertes, sicherstellen, dass es nicht None ist
            return row[idx] if row[idx] is not None else ''
        else:
            # Logge auf Debug-Level, wenn der Index existiert, aber die Zeile zu kurz ist.
            self.logger.debug( # <<< GEÄNDERT
                f"_get_cell_value_safe: Index {idx} fuer '{column_key}' ist gueltig, "
                f"aber Zeile ist zu kurz (Laenge {len(row)}). Gebe leeren String zurueck."
            )
            return ''  # Gebe leeren String zurueck, wenn die Zeile zu kurz ist

        # Der Code sollte niemals hierher gelangen.
        # return '' # Fallback Rueckgabe


    # ==============================================================================
    # Ende DataProcessor Klasse Start & Init & Basis Status-Checker Block
    # ==============================================================================


    # --- Interne Hilfsmethoden zur Pruefung, ob ein Schritt ausgefuehrt werden soll ---
    # Diese Methoden kapseln die Logik zur Entscheidung, ob ein Schritt basierend
    # auf dem Zeilenstatus (Timestamps, Flags) und dem force_reeval Flag ausgefuehrt werden soll.
    # Sie werden von _process_single_row (Block 19) aufgerufen.
    # Nutzt interne Methode: _get_cell_value_safe.
    # Nutzt globale Helfer: COLUMN_MAP, logger.

    # HINWEIS: Die Logik hier bezieht sich auf die "Gruppen" von Schritten,
    # die in _process_single_row zusammengefasst sind ('web', 'wiki', 'chat').
    # Spezifischere Batch-Modi (Block 26-32) haben oft ihre eigene Zeilenauswahl-Logik,
    # die nicht unbedingt diese Methoden verwendet.


    def _needs_website_processing(self, row_data, force_reeval):
        """
        Prueft, ob Website-Scraping/Summarization fuer diese Zeile noetig ist.
        Nötig, wenn force_reeval True ist ODER wenn der Website Scrape Timestamp (AT) leer ist.

        Args:
            row_data (list): Die Listendaten fuer die Zeile.
            force_reeval (bool): True, wenn eine Re-Evaluation erzwungen wird (ignoriert Timestamps).

        Returns:
            bool: True, wenn Website-Verarbeitung noetig ist.
        """
        # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
        if force_reeval:
            # self.logger.debug("  -> Website-Schritt noetig (force_reeval=True)") # Zu viel Laerm im Debug
            return True # Wenn Re-Eval erzwungen wird, ist der Schritt noetig


        # Pruefe, ob der Website Scrape Timestamp (AT) leer ist (nutzt interne Helfer)
        at_value = self._get_cell_value_safe(row_data, "Website Scrape Timestamp").strip()
        # Wenn der Timestamp leer ist, ist der Schritt noetig
        if not at_value:
            # self.logger.debug("  -> Website-Schritt noetig (AT leer)") # Zu viel Laerm im Debug
            return True

        # Wenn der Timestamp gesetzt ist und kein Re-Eval erzwungen wird, ist der Schritt nicht noetig
        # self.logger.debug(f"  -> Website-Schritt nicht noetig (AT='{at_value}')") # Zu viel Laerm im Debug
        return False


    def _needs_wiki_processing(self, row_data, force_reeval):
        """
        Prueft, ob Wikipedia-Suche/Extraktion fuer diese Zeile noetig ist.
        Nötig, wenn force_reeval True ist ODER wenn der Wikipedia Timestamp (AN)
        leer ist ODER wenn Status S 'X (URL Copied)' ist (signalisiert neue URL).

        Args:
            row_data (list): Die Listendaten fuer die Zeile.
            force_reeval (bool): True, wenn eine Re-Evaluation erzwungen wird.

        Returns:
            bool: True, wenn Wiki-Suche/Extraktion noetig ist.
        """
        # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
        if force_reeval:
            # self.logger.debug("  -> Wiki-Extraktion/-Suche noetig (force_reeval=True)") # Zu viel Laerm im Debug
            return True # Wenn Re-Eval erzwungen wird, ist der Schritt noetig


        # Pruefe, ob der Wikipedia Timestamp (AN) leer ist (nutzt interne Helfer)
        an_value = self._get_cell_value_safe(row_data, "Wikipedia Timestamp").strip()
        # Wenn der Timestamp leer ist, ist der Schritt noetig
        if not an_value:
            # self.logger.debug("  -> Wiki-Extraktion/-Suche noetig (AN leer)") # Zu viel Laerm im Debug
            return True

        # Pruefe, ob Status S 'X (URL Copied)' ist (signalisiert, dass eine neue URL durch den Update-Modus kopiert wurde und neu extrahiert werden muss)
        s_value = self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzpruefung").strip().upper()
        # Wenn Status S "X (URL COPIED)" ist, ist der Schritt noetig
        if s_value == "X (URL COPIED)":
             # self.logger.debug("  -> Wiki-Extraktion/-Suche noetig (S='X (URL Copied)')") # Zu viel Laerm im Debug
             return True


        # Wenn AN gesetzt ist, S nicht "X (URL COPIED)" ist und kein Re-Eval erzwungen wird, ist der Schritt nicht noetig
        # self.logger.debug(f"  -> Wiki-Extraktion/-Suche nicht noetig (AN='{an_value}', S='{s_value}')") # Zu viel Laerm im Debug
        return False


    def _needs_wiki_verification(self, row_data, force_reeval):
         """
         Prueft, ob Wikipedia-Verifizierung (S-U) fuer diese Zeile noetig ist.
         Nötig, wenn force_reeval True ist ODER wenn der Wiki Verif. Timestamp (AX) leer ist
         UND eine Wiki URL (M) vorhanden ist.

         Args:
            row_data (list): Die Listendaten fuer die Zeile.
            force_reeval (bool): True, wenn eine Re-Evaluation erzwungen wird.

         Returns:
            bool: True, wenn Wiki-Verifizierung noetig ist.
         """
         # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
         if force_reeval:
              # self.logger.debug("  -> Wiki-Verifizierung noetig (force_reeval=True)") # Zu viel Laerm im Debug
              return True # Wenn Re-Eval erzwungen wird, ist der Schritt noetig


         # Pruefe, ob der Wiki Verif. Timestamp (AX) leer ist (nutzt interne Helfer)
         ax_value = self._get_cell_value_safe(row_data, "Wiki Verif. Timestamp").strip()
         # Wenn der Timestamp leer ist
         if not ax_value:
              # Pruefe ZUSAETZLICH, ob eine Wiki URL (M) vorhanden ist und gueltig aussieht, da Verifizierung sonst sinnlos ist.
              # Nutzt interne Helfer
              m_value = self._get_cell_value_safe(row_data, "Wiki URL").strip()
              # Wenn M vorhanden ist und nicht "k.A." oder ein Fehlereintrag
              if m_value and m_value.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"]: # Fuege "http:" hinzu
                   # self.logger.debug("  -> Wiki-Verifizierung noetig (AX leer UND M gueltig)") # Zu viel Laerm im Debug
                   return True # Wenn AX leer und M gueltig, ist der Schritt noetig
              # else: self.logger.debug("  -> Wiki-Verifizierung nicht noetig (AX leer, aber M leer/k.A.)") # Zu viel Laerm im Debug

         # Wenn AX gesetzt ist und kein Re-Eval erzwungen wird, ist der Schritt nicht noetig
         # self.logger.debug(f"  -> Wiki-Verifizierung nicht noetig (AX='{ax_value}')") # Zu viel Laerm im Debug
         return False


    def _needs_chat_evaluations(self, row_data, force_reeval, wiki_data_just_updated):
        """
        Prueft, ob ChatGPT-Evaluationen (Branch, FSM etc.) fuer diese Zeile noetig sind.
        Nötig, wenn force_reeval True ist ODER wenn der Timestamp letzte Pruefung (AO)
        leer ist ODER wenn Wiki-Daten in diesem Lauf gerade aktualisiert wurden (Flag von _process_single_row).

        Args:
            row_data (list): Die Listendaten fuer die Zeile.
            force_reeval (bool): True, wenn eine Re-Evaluation erzwungen wird.
            wiki_data_just_updated (bool): True, wenn die Wiki-Daten (M-R, AN) gerade in diesem Lauf aktualisiert wurden.

        Returns:
            bool: True, wenn Chat-Evaluationen noetig sind.
        """
        # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
        if force_reeval:
            # self.logger.debug("  -> Chat-Evaluationen noetig (force_reeval=True)") # Zu viel Laerm im Debug
            return True # Wenn Re-Eval erzwungen wird, ist der Schritt noetig


        # Pruefe, ob der Timestamp letzte Pruefung (AO) leer ist (nutzt interne Helfer)
        ao_value = self._get_cell_value_safe(row_data, "Timestamp letzte Pruefung").strip()
        # Wenn der Timestamp leer ist, ist der Schritt noetig
        if not ao_value:
            # self.logger.debug("  -> Chat-Evaluationen noetig (AO leer)") # Zu viel Laerm im Debug
            return True

        # Pruefe, ob Wiki-Daten in diesem Lauf gerade aktualisiert wurden (Flag aus _process_single_row)
        # Wenn Wiki-Daten aktualisiert wurden, sollen die Chat-Evaluationen erneut laufen, auch wenn AO gesetzt ist.
        if wiki_data_just_updated:
            # self.logger.debug("  -> Chat-Evaluationen noetig (Wiki-Daten gerade aktualisiert)") # Zu viel Laerm im Debug
            return True


        # Wenn AO gesetzt ist, Wiki-Daten nicht aktualisiert wurden und kein Re-Eval erzwungen wird, ist der Schritt nicht noetig
        # self.logger.debug(f"  -> Chat-Evaluationen nicht noetig (AO='{ao_value}' und Wiki-Daten nicht aktualisiert)") # Zu viel Laerm im Debug
        return False


    def _needs_ml_prediction(self, row_data, force_reeval, chat_eval_just_ran):
         """
         Prueft, ob die ML-Schaetzung (AU) fuer diese Zeile noetig ist.
         Nötig, wenn force_reeval True ist ODER wenn der AU Bucket leer ist
         UND (entweder AO gerade gesetzt wurde ODER konsolidierter Umsatz/Mitarbeiter vorhanden ist).

         Args:
            row_data (list): Die Listendaten fuer die Zeile.
            force_reeval (bool): True, wenn eine Re-Evaluation erzwungen wird.
            chat_eval_just_ran (bool): True, wenn die ChatGPT-Evaluationen (AO) gerade in diesem Lauf liefen.

         Returns:
            bool: True, wenn ML-Schaetzung noetig ist.
         """
         # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
         if force_reeval:
              # self.logger.debug("  -> ML-Schaetzung noetig (force_reeval=True)") # Zu viel Laerm im Debug
              return True # Wenn Re-Eval erzwungen wird, ist der Schritt noetig


         # Pruefe, ob der Geschaetzte Techniker Bucket (AU) leer ist (nutzt interne Helfer)
         au_value = self._get_cell_value_safe(row_data, "Geschaetzter Techniker Bucket").strip()
         # Wenn der Bucket leer ist oder einen Fehlerwert enthaelt
         if not au_value or au_value.lower() in ["k.a.", "fehler schaetzung"]:
              # self.logger.debug(f"  -> ML-Schaetzung noetig (AU leer/Fehler)") # Zu viel Laerm im Debug

              # Pruefe ZUSAETZLICH, ob die Daten fuer die Schaetzung vorhanden/aktualisiert sind.
              # Die Schaetzung ist sinnvoll, wenn A) Chat-Evaluationen gerade liefen (setzen AO und Konsolidierung AV/AW)
              # ODER B) Konsolidierter Umsatz/Mitarbeiter (AV/AW) explizit vorhanden ist (auch wenn AO alt ist).

              # Pruefe, ob Chat-Evaluationen gerade liefen
              if chat_eval_just_ran:
                  # self.logger.debug("    -> ML-Schaetzung noetig (Chat-Evaluationen gerade liefen)") # Zu viel Laerm im Debug
                  return True # Wenn Chat-Evaluationen gerade liefen, ist der Schritt noetig


              # Pruefe, ob Konsolidierter Umsatz/Mitarbeiter (AV/AW) vorhanden ist (nutzt interne Helfer)
              # Diese Werte werden normalerweise von _process_single_row gesetzt, wenn Chat lief, oder manuell gepflegt.
              av_value = self._get_cell_value_safe(row_data, "Finaler Umsatz (Wiki>CRM)").strip()
              aw_value = self._get_cell_value_safe(row_data, "Finaler Mitarbeiter (Wiki>CRM)").strip()

              # Wenn AV und AW vorhanden sind (nicht "k.A." oder Fehlerwerte)
              if av_value != "k.A." and not av_value.startswith("FEHLER") and aw_value != "k.A." and not aw_value.startswith("FEHLER"):
                   # self.logger.debug("    -> ML-Schaetzung noetig (AV/AW vorhanden)") # Zu viel Laerm im Debug
                   return True # Wenn AV/AW vorhanden, ist der Schritt noetig
              # else: self.logger.debug("    -> ML-Schaetzung nicht noetig (AV/AW fehlen)") # Zu viel Laerm im Debug


         # Wenn AU gesetzt ist oder einen gueltigen Wert enthaelt, und kein Re-Eval erzwungen wird, ist der Schritt nicht noetig
         # self.logger.debug(f"  -> ML-Schaetzung nicht noetig (AU='{au_value}')") # Zu viel Laerm im Debug
         return False


    # ==============================================================================
    # Ende DataProcessor Klasse Status-Checker Helpers Block
    # ==============================================================================

    # --- Methode: Verarbeitung einer einzelnen Zeile ---
    # Diese Methode ist das Herzstueck der Zeilen-basierten Verarbeitung.
    # Sie fuehrt die einzelnen Anreicherungs- und Evaluationsschritte durch,
    # basierend auf ausgewaehlten Schritten, Timestamps/Status, und dem force_reeval Flag.
    # Nutzt interne Helfer: _get_cell_value_safe, _needs_... Methoden.
    # Nutzt globale Helfer: COLUMN_MAP, logger, datetime, time, traceback.
    # @retry_on_failure # Nicht sinnvoll auf dieser Orchestrierungsebene. Retries sind in den einzelnen Schritten.
    def _process_single_row(self, row_num_in_sheet, row_data,
                            steps_to_run, force_reeval=False, clear_x_flag=False):
        """
        Verarbeitet die Daten fuer eine einzelne Zeile im Sheet. Fuehrt ausgewaehlte
        Anreicherungs- und Analyseprozesse durch, basierend auf Timestamps/Status
        oder dem force_reeval Flag. Sammelt und schreibt Ergebnisse zurueck.

        Args:
            row_num_in_sheet (int): Die 1-basierte Zeilennummer im Google Sheet.
            row_data (list): Die rohen Listendaten fuer diese Zeile (Liste von Strings).
            steps_to_run (set/list): Menge oder Liste von Schluesseln der Schritt-Gruppen,
                                     die in diesem Lauf beruecksichtigt werden sollen
                                     (z.B. {'wiki', 'web', 'chat', 'ml_predict'}).
            force_reeval (bool, optional): Wenn True, werden Timestamps/Status ignoriert
                                           und die Ausfuehrung der in steps_to_run
                                           enthaltenen Schritte erzwungen. Defaults to False.
            clear_x_flag (bool, optional): Wenn True UND force_reeval ist True, wird das 'x'-Flag
                                           in Spalte A geloescht, wenn die Zeile verarbeitet wurde.
                                           Defaults to False.
        """
        self.logger.info(f"--- Starte Verarbeitung fuer Zeile {row_num_in_sheet} {'(Re-Eval)' if force_reeval else ''} (Schritte: {', '.join(steps_to_run) if steps_to_run else 'Keine ausgewählt'}) ---")
        updates = []
        now_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        any_processing_done = False
        wiki_data_updated_in_this_run = False
        chat_eval_just_ran = False

        # --- Initiale Werte lesen ---
        company_name = self._get_cell_value_safe(row_data, "CRM Name").strip()
        website_url = self._get_cell_value_safe(row_data, "CRM Website").strip()
        original_website_url_in_sheet = website_url
        crm_kurzform = self._get_cell_value_safe(row_data, "CRM Kurzform").strip()
        crm_branche = self._get_cell_value_safe(row_data, "CRM Branche").strip()
        crm_beschreibung = self._get_cell_value_safe(row_data, "CRM Beschreibung").strip()
        
        # NEU: Wert aus Spalte D (Parent Account Name) hier lesen
        parent_account_name_d = self._get_cell_value_safe(row_data, "Parent Account Name").strip() 
        self.logger.debug(f"  Zeile {row_num_in_sheet}: Gelesener Parent Account (D): '{parent_account_name_d}'")

        # Vorgeschlagener Parent aus Spalte O (wird für Wiki-Suche und Parent-Vorschlagslogik benötigt)
        system_suggested_parent_name_o = self._get_cell_value_safe(row_data, "System Vorschlag Parent Account").strip() # Spalte O
        
        current_wiki_data = {
            'url': self._get_cell_value_safe(row_data, "Wiki URL") or 'k.A.',
            'sitz_stadt': self._get_cell_value_safe(row_data, "Wiki Sitz Stadt") or 'k.A.',
            'sitz_land': self._get_cell_value_safe(row_data, "Wiki Sitz Land") or 'k.A.',
            'first_paragraph': self._get_cell_value_safe(row_data, "Wiki Absatz") or 'k.A.',
            'branche': self._get_cell_value_safe(row_data, "Wiki Branche") or 'k.A.',
            'umsatz': self._get_cell_value_safe(row_data, "Wiki Umsatz") or 'k.A.',
            'mitarbeiter': self._get_cell_value_safe(row_data, "Wiki Mitarbeiter") or 'k.A.',
            'categories': self._get_cell_value_safe(row_data, "Wiki Kategorien") or 'k.A.'
        }
        final_wiki_data = current_wiki_data.copy()

        website_raw = self._get_cell_value_safe(row_data, "Website Rohtext") or 'k.A.'
        website_summary = self._get_cell_value_safe(row_data, "Website Zusammenfassung") or 'k.A.'
        website_meta_details = self._get_cell_value_safe(row_data, "Website Meta-Details") or 'k.A.' # Spalte AI
        url_pruefstatus = self._get_cell_value_safe(row_data, "URL Prüfstatus") or '' # Spalte AK

        # --- 1. Website Handling (Lookup, Scraping, Summarization, Meta) ---
        run_website_step = 'web' in steps_to_run
        website_processing_needed_based_on_status = self._needs_website_processing(row_data, force_reeval)

        if run_website_step and website_processing_needed_based_on_status:
            any_processing_done = True
            grund_message_parts_web = []
            if force_reeval: grund_message_parts_web.append('Re-Eval')
            if not self._get_cell_value_safe(row_data, "Website Scrape Timestamp").strip(): grund_message_parts_web.append('AJ (Website Scrape Timestamp) leer') # Angepasster Key
            grund_message_web = ", ".join(filter(None, grund_message_parts_web)) or "Unbekannter Grund"
            self.logger.info(f"Zeile {row_num_in_sheet}: Fuehre WEBSITE Schritte aus (Grund: {grund_message_web})...")

            # a) Website Lookup (nur wenn D leer)
            if not website_url or website_url.lower() == "k.a.":
                self.logger.debug("  -> Website URL (E) leer oder k.A., suche ueber SERP...")
                try:
                    new_website = serp_website_lookup(company_name)
                    if new_website and new_website.lower() != "k.a." and not new_website.startswith("k.A. (Fehler"):
                        website_url = new_website # Aktualisiere Arbeitskopie
                        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 E:{row_num_in_sheet} vorgemerkt: {website_url[:100]}...")
                        url_pruefstatus = "URL_OK_SERP" # Status aktualisieren
                    else:
                        self.logger.warning(f"  -> Keine neue Website ueber SERP gefunden für '{company_name[:100]}...'.")
                        url_pruefstatus = "URL_SERP_FAILED" if not website_url or website_url.lower() == "k.a." else url_pruefstatus # Nur setzen, wenn vorher keine URL da war
                except Exception as e_serp_lookup_web:
                    self.logger.error(f"FEHLER bei SERP Website Lookup für '{company_name[:100]}...': {e_serp_lookup_web}")
                    url_pruefstatus = "URL_SERP_ERROR"

            # b) Scrape Rohtext, Meta-Details und c) Zusammenfassung (nur wenn URL vorhanden)
            if website_url and website_url.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"]:
                self.logger.debug(f"  -> Scrape Rohtext & Meta von {website_url[:100]}...")
                new_website_raw_temp = "k.A." # Temporär für diesen Scope
                try:
                    new_website_raw_temp = get_website_raw(website_url) # Nutzt globale Funktion
                    website_raw = new_website_raw_temp # Update der Hauptvariable
                    
                    # Wenn get_website_raw einen URL_CHECK_MARKER zurückgibt
                    if website_raw == URL_CHECK_MARKER:
                        url_pruefstatus = URL_CHECK_MARKER
                        self.logger.warning(f"  -> get_website_raw markierte URL {website_url} als '{URL_CHECK_MARKER}'. Überspringe weitere Web-Verarbeitung.")
                        website_summary = "k.A. (URL prüfen)" # Verhindere alte Zusammenfassung
                        website_meta_details = "k.A. (URL prüfen)"
                    elif website_raw and str(website_raw).strip().lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]:
                        url_pruefstatus = "URL_OK_SCRAPED" # Wenn Scraping (auch nur teilweise) erfolgreich war
                        
                        # Meta-Details scrapen (experimentell)
                        try:
                            meta_temp = scrape_website_details(website_url)
                            website_meta_details = meta_temp if meta_temp else "k.A. (Keine Meta-Details)"
                        except Exception as e_meta:
                            self.logger.error(f"FEHLER bei Meta-Detail Scraping für '{website_url[:100]}...': {e_meta}")
                            website_meta_details = f"k.A. (Fehler Meta: {str(e_meta)[:50]})"
                        updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Meta-Details"] + 1)}{row_num_in_sheet}', 'values': [[website_meta_details]]}) # Spalte AI

                        # Zusammenfassung
                        self.logger.debug(f"  -> Fasse Rohtext zusammen (Laenge: {len(str(website_raw))})...")
                        try:
                            summary_temp = summarize_website_content(website_raw)
                            website_summary = summary_temp if summary_temp and summary_temp.strip() else "k.A. (Keine Zusammenfassung erhalten)"
                        except Exception as e_summary_web:
                            self.logger.error(f"FEHLER bei Website Zusammenfassung für '{company_name[:100]}...': {e_summary_web}")
                            website_summary = f"k.A. (Fehler Zusammenfassung: {str(e_summary_web)[:50]})"
                        updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[website_summary]]}) # Spalte AH
                    else: # Fehler beim Scraping oder kein sinnvoller Text
                        if not website_raw.startswith("k.A. (Fehler"): # Wenn es kein expliziter Fehler war, sondern z.B. nur Cookie-Banner
                            url_pruefstatus = "URL_SCRAPE_EMPTY_OR_BANNER"
                        # website_raw enthält bereits den Fehler oder "k.A. (nur Cookie...)"
                        website_summary = "k.A." # Keine Zusammenfassung bei fehlerhaftem/leerem Rohtext
                        website_meta_details = "k.A."
                        updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[website_summary]]})
                        updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Meta-Details"] + 1)}{row_num_in_sheet}', 'values': [[website_meta_details]]})


                except Exception as e_scrape_web:
                    self.logger.error(f"FEHLER beim Website Scraping für '{company_name[:100]}' ({website_url[:100]}...): {e_scrape_web}")
                    website_raw = f"k.A. (Fehler Scraping: {str(e_scrape_web)[:50]})"
                    website_summary = "k.A. (Scraping fehlgeschlagen)"
                    website_meta_details = "k.A. (Scraping fehlgeschlagen)"
                    url_pruefstatus = "URL_SCRAPE_ERROR"
                    updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[website_summary]]})
                    updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Meta-Details"] + 1)}{row_num_in_sheet}', 'values': [[website_meta_details]]})
                
                updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [[website_raw]]}) # Spalte AG
            
            else: # Keine gültige URL für Scraping
                self.logger.debug(f"  -> Keine gültige Website URL für '{company_name[:100]}...' vorhanden. Web-Verarbeitung übersprungen.")
                website_raw = "k.A. (Keine URL)"
                website_summary = "k.A."
                website_meta_details = "k.A."
                # Wenn url_pruefstatus noch leer ist (weil keine URL-Suche stattfand), setze es.
                if not url_pruefstatus : url_pruefstatus = "URL_MISSING"
                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]]})
                updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Meta-Details"] + 1)}{row_num_in_sheet}', 'values': [[website_meta_details]]})
            
            updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["URL Prüfstatus"] + 1)}{row_num_in_sheet}', 'values': [[url_pruefstatus]]}) # Spalte AK
            updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Scrape Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # Spalte AJ

        # ======================================================================
        # === 2. Wikipedia Handling (Search, Extraction, Status Reset) ==========
        # ======================================================================
        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
            
            grund_message_parts_wiki_list = [] 
            if force_reeval: 
                grund_message_parts_wiki_list.append('Re-Eval')
            if not self._get_cell_value_safe(row_data, "Wikipedia Timestamp").strip(): 
                grund_message_parts_wiki_list.append('Z (Wikipedia Timestamp) leer')
            if self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzpruefung").strip().upper() == "X (URL COPIED)": 
                grund_message_parts_wiki_list.append("AC (Chat Wiki Konsistenzpruefung)='X (URL COPIED)'")
            
            grund_message_wiki_str = ", ".join(filter(None, grund_message_parts_wiki_list)) 
            if not grund_message_wiki_str: 
                 grund_message_wiki_str = "Bedingung für Wiki-Bearbeitung erfüllt (Standard)"
            
            # ----- KORREKTUR HIER -----
            self.logger.info(f"Zeile {row_num_in_sheet}: Fuehre WIKI Schritte aus (Grund: {grund_message_wiki_str})...")
            # ----- ENDE KORREKTUR -----

            current_wiki_url_r = self._get_cell_value_safe(row_data, "Wiki URL").strip() 
            system_suggested_parent_o = self._get_cell_value_safe(row_data, "System Vorschlag Parent Account").strip()
            # parent_account_name_d ist bereits oben in _process_single_row gelesen worden
            
            url_for_extraction = None 
            source_of_wiki_data_origin_log_msg = "Tochter (Initial)"
            additional_info_for_af_col = ""
            self.logger.debug(f"  Zeile {row_num_in_sheet}: Inhalt von current_wiki_url_r (Spalte R) VOR Parent-Check: '{current_wiki_url_r}' (Typ: {type(current_wiki_url_r)})") # NEUE DEBUG-ZEILE
            
            # WICHTIG: Wenn R leer ist, IMMER ZUERST versuchen, Parent-URLs (D dann O) zu finden
            if not current_wiki_url_r or current_wiki_url_r.lower() == 'k.a.':
                # Priorität 1: Manuell gepflegter Parent in D
                if parent_account_name_d and parent_account_name_d.lower() != 'k.a.':
                    self.logger.info(f"  Zeile {row_num_in_sheet}: R leer, D ('{parent_account_name_d}') gesetzt. Suche Wiki für Parent D via SerpAPI.")
                    try:
                        potential_url = serp_wikipedia_lookup(parent_account_name_d, website=None) # SERP API KEY BENÖTIGT!
                        if potential_url and potential_url.lower() not in ["k.a.", "kein artikel gefunden"] and not potential_url.startswith("FEHLER"):
                            url_for_extraction = potential_url
                            source_of_wiki_data_origin_log_msg = f"Parent D ('{parent_account_name_d}')"
                            updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Wiki Konsistenzpruefung"] + 1)}{row_num_in_sheet}', 'values': [["INFO_PARENT_AUS_D"]]})
                            additional_info_for_af_col = f"INFO: Wiki-URL von Parent (aus D): {parent_account_name_d} via SerpAPI. "
                        else:
                            self.logger.warning(f"  -> Kein Wiki-Artikel für Parent D '{parent_account_name_d}' gefunden.")
                            additional_info_for_af_col = f"WARN: Kein Wiki für Parent D '{parent_account_name_d}' gefunden. "
                    except Exception as e_d_lookup_re:
                        self.logger.error(f"Fehler bei Wiki-Suche für Parent D '{parent_account_name_d}': {e_d_lookup_re}")
                        additional_info_for_af_col = f"ERR: Suche Parent D '{parent_account_name_d}' fehlgeschlagen. "

                # Priorität 2: Systemvorschlag Parent O (nur wenn D nicht erfolgreich war)
                if url_for_extraction is None and \
                   (system_suggested_parent_o and system_suggested_parent_o.lower() != 'k.a.'):
                    self.logger.info(f"  Zeile {row_num_in_sheet}: R leer, D nicht genutzt/erfolglos. O ('{system_suggested_parent_o}') gesetzt. Suche Wiki für Parent O via SerpAPI.")
                    try:
                        potential_url = serp_wikipedia_lookup(system_suggested_parent_o, website=None) # SERP API KEY BENÖTIGT!
                        if potential_url and potential_url.lower() not in ["k.a.", "kein artikel gefunden"] and not potential_url.startswith("FEHLER"):
                            url_for_extraction = potential_url
                            source_of_wiki_data_origin_log_msg = f"Parent O ('{system_suggested_parent_o}')"
                            updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Wiki Konsistenzpruefung"] + 1)}{row_num_in_sheet}', 'values': [["INFO_PARENT_AUS_O"]]})
                            additional_info_for_af_col += f"INFO: Wiki-URL von Parent (aus O): {system_suggested_parent_o} via SerpAPI. "
                        else:
                            self.logger.warning(f"  -> Kein Wiki-Artikel für Parent O '{system_suggested_parent_o}' gefunden.")
                            additional_info_for_af_col += f"WARN: Kein Wiki für Parent O '{system_suggested_parent_o}' gefunden. "
                    except Exception as e_o_lookup_re:
                        self.logger.error(f"Fehler bei Wiki-Suche für Parent O '{system_suggested_parent_o}': {e_o_lookup_re}")
                        additional_info_for_af_col += f"ERR: Suche Parent O '{system_suggested_parent_o}' fehlgeschlagen. "
            
            # Priorität 3: Tochter-Wiki-Verarbeitung (wenn keine Parent-URL gefunden wurde oder R bereits gefüllt war und Re-Eval/Reparse ansteht)
            if url_for_extraction is None: # Wenn nach Parent-Suchen immer noch keine URL da ist
                search_for_daughter_wiki_needed_local = False
                status_ac_indicates_reparse_local = self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzpruefung").strip().upper() == "X (URL COPIED)"
                timestamp_z_is_empty_local = not self._get_cell_value_safe(row_data, "Wikipedia Timestamp").strip()
                r_url_is_valid_looking_local = current_wiki_url_r and \
                                               "wikipedia.org/wiki/" in current_wiki_url_r.lower() and \
                                               current_wiki_url_r.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche"]

                if status_ac_indicates_reparse_local:
                    self.logger.info(f"  Zeile {row_num_in_sheet}: Status AC ist 'X (URL COPIED)'. Starte neue Suche für Tochter '{company_name}'.")
                    search_for_daughter_wiki_needed_local = True
                elif force_reeval:
                    if r_url_is_valid_looking_local:
                        self.logger.info(f"  Zeile {row_num_in_sheet}: Re-Eval Modus. Nutze vorhandene Tochter-URL (R): {current_wiki_url_r}")
                        url_for_extraction = current_wiki_url_r
                        source_of_wiki_data_origin_log_msg = "Tochter (aus R, Re-Eval)"
                    else:
                        self.logger.info(f"  Zeile {row_num_in_sheet}: Re-Eval Modus. Tochter-URL (R) leer/ungültig. Starte neue Suche für Tochter '{company_name}'.")
                        search_for_daughter_wiki_needed_local = True
                elif timestamp_z_is_empty_local:
                    if r_url_is_valid_looking_local:
                        self.logger.info(f"  Zeile {row_num_in_sheet}: Wikipedia Timestamp (Z) fehlt. Nutze vorhandene Tochter-URL (R): {current_wiki_url_r}")
                        url_for_extraction = current_wiki_url_r
                        source_of_wiki_data_origin_log_msg = "Tochter (aus R, Z leer)"
                    else:
                        self.logger.info(f"  Zeile {row_num_in_sheet}: Wikipedia Timestamp (Z) fehlt und Tochter-URL (R) leer/ungültig. Starte neue Suche für Tochter '{company_name}'.")
                        search_for_daughter_wiki_needed_local = True
                elif not r_url_is_valid_looking_local: # Fallback, wenn Z nicht leer, aber R schlecht
                    self.logger.info(f"  Zeile {row_num_in_sheet}: Tochter-URL (R) ist ungültig ('{current_wiki_url_r}'). Starte neue Suche für Tochter '{company_name}'.")
                    search_for_daughter_wiki_needed_local = True
                else: # R ist vorhanden und Timestamp Z ist gesetzt, kein Re-Eval/Reparse -> keine Wiki-Aktion für Tochter nötig
                    self.logger.debug(f"  Zeile {row_num_in_sheet}: Wiki-Verarbeitung für Tochter nicht nötig (Timestamp Z gesetzt, R gültig, kein Re-Eval/Reparse, kein Parent-Fall).")

                if search_for_daughter_wiki_needed_local:
                    self.logger.info(f"  -> Suche nach Wikipedia-Artikel für Tochter '{company_name}'...")
                    try:
                        page_obj_tochter = self.wiki_scraper.search_company_article(company_name, website_url) # SERP API KEY BENÖTIGT!
                        if page_obj_tochter:
                            url_for_extraction = page_obj_tochter.url
                            source_of_wiki_data_origin_log_msg = "Tochter (Suche erfolgreich)"
                            self.logger.info(f"  -> Suche für Tochter '{company_name}' erfolgreich, URL: {url_for_extraction}")
                        else:
                            url_for_extraction = "Kein Artikel gefunden"
                            self.logger.warning(f"  -> Kein Wiki-Artikel für Tochter '{company_name}' gefunden.")
                    except Exception as e_tochter_suche_psr:
                        self.logger.error(f"Fehler bei Wiki-Suche für Tochter '{company_name}': {e_tochter_suche_psr}")
                        url_for_extraction = f"Fehler Suche Tochter: {str(e_tochter_suche_psr)[:50]}"
            
            elif current_wiki_url_r and current_wiki_url_r.lower() != 'k.a.' and not current_wiki_url_r.startswith("FEHLER") and \
                 (force_reeval or self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzpruefung").strip().upper() == "X (URL COPIED)"):
                # Dieser Fall tritt ein, wenn R eine URL enthält, aber force_reeval oder "X (URL COPIED)" aktiv ist
                # und url_for_extraction bereits durch eine Parent-Suche gesetzt wurde.
                # In diesem Szenario hat die Parent-URL Vorrang, aber wir loggen, dass die Tochter-URL auch da war.
                if url_for_extraction: # Wenn schon eine Parent-URL gefunden wurde
                    self.logger.info(f"  Zeile {row_num_in_sheet}: Parent-URL '{url_for_extraction}' hat Vorrang. Tochter-URL in R ('{current_wiki_url_r}') wird nicht für Extraktion verwendet, obwohl Re-Eval/Reparse aktiv.")
                else: # Sollte nicht passieren, wenn die obere Logik korrekt ist, aber als Sicherheitsnetz
                    self.logger.info(f"  Zeile {row_num_in_sheet}: Re-Eval/Reparse aktiv. Nutze bestehende Tochter-URL aus R: {current_wiki_url_r}")
                    url_for_extraction = current_wiki_url_r
                    source_of_wiki_data_origin_log_msg = "Tochter (aus R, Re-Eval/Reparse)"



            # --- DATENEXTRAKTION ---
            if url_for_extraction and isinstance(url_for_extraction, str) and \
               url_for_extraction.lower() not in ["k.a.", "kein artikel gefunden"] and \
               not url_for_extraction.startswith("FEHLER"):
                
                self.logger.info(f"  -> Extrahiere Wiki-Daten von URL ({source_of_wiki_data_origin_log_msg}): {url_for_extraction[:100]}...")
                try:
                    extracted_data = self.wiki_scraper.extract_company_data(url_for_extraction)
                    
                    if extracted_data and isinstance(extracted_data, dict) and extracted_data.get('url') != 'k.A.':
                        final_wiki_data = extracted_data 
                        wiki_data_updated_in_this_run = True
                        self.logger.info(f"    -> Datenextraktion von {url_for_extraction[:100]}... erfolgreich.")
                        
                        # AC und abhängige Spalten zurücksetzen/anpassen
                        current_ac_val = self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzpruefung").strip()
                        
                        if source_of_wiki_data_origin_log_msg.startswith("Parent"):
                             # AC wurde schon auf INFO_PARENT_AUS_D/O gesetzt
                             # Hier sicherstellen, dass abhängige Spalten geleert werden, falls AC neu ist
                            if current_ac_val not in ["INFO_PARENT_AUS_D", "INFO_PARENT_AUS_O"]:
                                updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Verif. Timestamp"] + 1)}{row_num_in_sheet}', 'values': [['']]})
                                updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung Wiki Inkonsistenz"] + 1)}{row_num_in_sheet}', 'values': [['']]})
                                updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Wiki Artikel"] + 1)}{row_num_in_sheet}', 'values': [['']]})
                        elif force_reeval or current_wiki_url_r != url_for_extraction or current_ac_val.upper() == "X (URL COPIED)":
                            # Tochter-URL wurde neu gefunden oder reevaluiert -> AC auf '?' setzen für ChatGPT Verif.
                            self.logger.info(f"    -> Setze AC auf '?' und leere AA, AD, AE für Tochter-Wiki-Update.")
                            updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Wiki Konsistenzpruefung"] + 1)}{row_num_in_sheet}', 'values': [['?']]})
                            updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Verif. Timestamp"] + 1)}{row_num_in_sheet}', 'values': [['']]})
                            updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung Wiki Inkonsistenz"] + 1)}{row_num_in_sheet}', 'values': [['']]})
                            updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Wiki Artikel"] + 1)}{row_num_in_sheet}', 'values': [['']]})
                    else:
                        self.logger.error(f"    -> Fehler bei Datenextraktion von {url_for_extraction[:100]} (Extraktion leer/ungültig).")
                        final_wiki_data['url'] = url_for_extraction 
                        for key_to_clear in ['sitz_stadt', 'sitz_land', 'first_paragraph', 'branche', 'umsatz', 'mitarbeiter', 'categories']:
                            final_wiki_data[key_to_clear] = 'k.A. (Extraktion fehlgeschlagen)'
                        wiki_data_updated_in_this_run = True
                except Exception as e_wiki_extract_final_psr:
                    self.logger.error(f"FEHLER bei Wikipedia Datenextraktion von {url_for_extraction[:100]}...: {e_wiki_extract_final_psr}")
                    self.logger.debug(traceback.format_exc())
                    final_wiki_data['url'] = url_for_extraction 
                    for key_to_clear in ['sitz_stadt', 'sitz_land', 'first_paragraph', 'branche', 'umsatz', 'mitarbeiter', 'categories']:
                        final_wiki_data[key_to_clear] = f'k.A. (FEHLER Extr.)'
                    wiki_data_updated_in_this_run = True
            
            elif url_for_extraction: 
                self.logger.info(f"  -> Keine valide Wiki-URL zur Extraktion vorhanden: '{url_for_extraction}'. Setze Wiki-Felder auf k.A.")
                final_wiki_data['url'] = url_for_extraction
                for key_to_clear in ['sitz_stadt', 'sitz_land', 'first_paragraph', 'branche', 'umsatz', 'mitarbeiter', 'categories']:
                    final_wiki_data[key_to_clear] = 'k.A.'
                wiki_data_updated_in_this_run = True

            if wiki_data_updated_in_this_run:
                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 Sitz Stadt"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('sitz_stadt', 'k.A.')]]})
                updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Sitz Land"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('sitz_land', '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]]}) 
                
                if additional_info_for_af_col:
                    current_af_val = self._get_cell_value_safe(row_data, "Begruendung bei Abweichung").strip()
                    new_af_val = (current_af_val + "; " + additional_info_for_af_col if current_af_val else additional_info_for_af_col).strip()
                    updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Begruendung bei Abweichung"] + 1)}{row_num_in_sheet}', 'values': [[new_af_val]]})

                if source_of_wiki_data_origin_log_msg.startswith("Parent D") or source_of_wiki_data_origin_log_msg.startswith("Parent O"):
                    updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["SerpAPI Wiki Search Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]})



        # --- Ende des Wikipedia-Blocks ---
        # Der nächste Block wäre ChatGPT-Evaluationen

        # --- 3. ChatGPT Evaluationen (Branch, FSM, Emp, Umsatz Schaetzungen etc.) ---
        run_chat_step = 'chat' in steps_to_run
        # wiki_data_updated_in_this_run wurde im Wikipedia-Block oben gesetzt
        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 
            chat_eval_just_ran = True # Wichtig für ML-Schritt später

            # ... (Grund Message für Log erstellen) ...
            grund_message_parts_chat = []
            if force_reeval: grund_message_parts_chat.append('Re-Eval')
            if not self._get_cell_value_safe(row_data, "Timestamp letzte Pruefung").strip(): grund_message_parts_chat.append('BN (Timestamp letzte Pruefung) leer')
            if wiki_data_updated_in_this_run: grund_message_parts_chat.append('Wiki Daten gerade aktualisiert')
            grund_message_chat = ", ".join(filter(None, grund_message_parts_chat)) or "Unbekannter Grund (Chat)"
            self.logger.info(f"Zeile {row_num_in_sheet}: Fuehre CHATGPT Evaluationen & Plausi aus (Grund: {grund_message_chat})...")

            # --- 3a. Branchen-Einstufung (AL, AM, AN, AO) ---
            # ... (Dieser Teil bleibt wie er war, nutzt final_wiki_data, website_summary) ...
            # ... (Logik für evaluate_branche_chatgpt und Updates für AL, AM, AN, AO) ...
            self.logger.info(f"Zeile {row_num_in_sheet}: Starte Branchen-Einstufung ueber ChatGPT...")
            try:
                # final_wiki_data enthält hier ggf. die Daten des Parents
                branch_result = evaluate_branche_chatgpt( 
                    crm_branche, 
                    crm_beschreibung, 
                    final_wiki_data.get('branche', 'k.A.'), 
                    final_wiki_data.get('categories', 'k.A.'), 
                    website_summary 
                )
                # ... (Updates für Spalten AL, AM, AN, AO)
                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 BRANCH")]]})
                updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Branche Konfidenz"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get("confidence", "N/A CONF")]]})
                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 CONS")]]})
                updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung Abweichung Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get("justification", "No JUST")]]})
            except Exception as e_branch_eval_row:
                self.logger.error(f"FEHLER bei Branchen-Einstufung für Zeile {row_num_in_sheet}: {e_branch_eval_row}")
                # ... (Fehlerwerte schreiben) ...

            # --- 3b, 3c, 3d: Weitere ChatGPT Evaluationen (FSM, Mitarbeiter, Umsatz) ---
            # ... (Diese bleiben strukturell gleich, nutzen aber das ggf. aktualisierte final_wiki_data) ...
            # Zum Beispiel für Mitarbeiterschätzung:
            # if not final_wiki_data.get('mitarbeiter') or final_wiki_data.get('mitarbeiter', 'k.A.').lower() == 'k.a.':
            #     # ... call_openai_chat für Mitarbeiterschätzung ...
            # ... etc. für andere ChatGPT-Schritte ...
            
            # HIER BEGINNT DIE WICHTIGE ÄNDERUNG FÜR KONSOLIDIERUNG UND PLAUSI
            # --- 3e. Konsolidierung Umsatz/Mitarbeiter (BD, BE) ---
            self.logger.debug(f"  Zeile {row_num_in_sheet}: Konsolidiere Umsatz (BD) und Mitarbeiter (BE) unter Berücksichtigung von Parent (D)...")
            final_umsatz_str_konsolidiert = "k.A."
            final_ma_str_konsolidiert = "k.A."
            try:
                crm_umsatz_val_str = self._get_cell_value_safe(row_data, "CRM Umsatz") # Spalte L
                wiki_umsatz_val_str = final_wiki_data.get('umsatz', 'k.A.') # Aus final_wiki_data (kann Parent sein), Spalte W
                crm_ma_val_str = self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter") # Spalte M
                wiki_ma_val_str = final_wiki_data.get('mitarbeiter', 'k.A.') # Aus final_wiki_data, Spalte X
                
                # Numerische Werte für die Logik holen
                num_crm_umsatz = get_numeric_filter_value(crm_umsatz_val_str, is_umsatz=True)
                num_wiki_umsatz = get_numeric_filter_value(wiki_umsatz_val_str, is_umsatz=True)
                num_crm_ma = get_numeric_filter_value(crm_ma_val_str, is_umsatz=False)
                num_wiki_ma = get_numeric_filter_value(wiki_ma_val_str, is_umsatz=False)

                wiki_source_log_info = "Unbekannt/Sheet (Wiki-Block nicht gelaufen)" 
                if 'source_of_wiki_data_origin_log_msg' in locals() and source_of_wiki_data_origin_log_msg:
                    wiki_source_log_info = source_of_wiki_data_origin_log_msg
                elif 'source_of_wiki_data_origin' in locals() and source_of_wiki_data_origin: # Fallback, falls die Variable anders hieß
                    wiki_source_log_info = source_of_wiki_data_origin

                self.logger.debug(f"    Konsolidierung Input: CRM_U(L)='{num_crm_umsatz}', Wiki_U(W)='{num_wiki_umsatz}' (aus {wiki_source_log_info}), CRM_M(M)='{num_crm_ma}', Wiki_M(X)='{num_wiki_ma}' (aus {wiki_source_log_info}), Parent_D='{parent_account_name_d}'")

                
                if parent_account_name_d and parent_account_name_d.lower() != 'k.a.':
                    # Parent-Account (D) ist GEFÜLLT: Primär CRM-Daten der Tochter verwenden.
                    self.logger.info(f"    -> Parent D ('{parent_account_name_d}') ist gesetzt. Konsolidiere primär mit CRM-Daten der Tochter für BD/BE.")
                    final_num_umsatz = num_crm_umsatz if num_crm_umsatz > 0 else num_wiki_umsatz # Wiki als Fallback, falls CRM der Tochter fehlt/0 ist
                    final_num_ma = num_crm_ma if num_crm_ma > 0 else num_wiki_ma # Wiki als Fallback
                else:
                    # Parent-Account (D) ist LEER: Standardlogik Wiki > CRM.
                    self.logger.debug(f"    -> Parent D leer. Standardkonsolidierung Wiki > CRM für BD/BE.")
                    final_num_umsatz = num_wiki_umsatz if num_wiki_umsatz > 0 else num_crm_umsatz
                    final_num_ma = num_wiki_ma if num_wiki_ma > 0 else num_crm_ma
                
                final_umsatz_str_konsolidiert = str(int(round(final_num_umsatz))) if final_num_umsatz > 0 else 'k.A.'
                final_ma_str_konsolidiert = str(int(round(final_num_ma))) if final_num_ma > 0 else 'k.A.'
                 
                updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Umsatz (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [[final_umsatz_str_konsolidiert]]})
                updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Mitarbeiter (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [[final_ma_str_konsolidiert]]})
                self.logger.debug(f"    -> Konsolidiert für BD/BE (Sheet-Werte): Umsatz='{final_umsatz_str_konsolidiert}', MA='{final_ma_str_konsolidiert}'")
            except Exception as e_consolidate_row_final:
                self.logger.error(f"FEHLER bei Konsolidierung Umsatz/Mitarbeiter (BD/BE) für Zeile {row_num_in_sheet}: {e_consolidate_row_final}")
                updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Umsatz (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER_KONSO']]})
                updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Mitarbeiter (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER_KONSO']]})
                final_umsatz_str_konsolidiert = "FEHLER_KONSO" # Für Plausi-Input
                final_ma_str_konsolidiert = "FEHLER_KONSO"

            # --- 3f. Plausibilitäts-Checks durchführen (BG-BM) ---
            # Der Aufruf von _check_financial_plausibility muss jetzt parent_account_name_d (Spalte D) übergeben bekommen.
            self.logger.debug(f"  Zeile {row_num_in_sheet}: Führe Plausibilitäts-Checks durch (Parent D: '{parent_account_name_d}')...")
            try:
                plausi_input_data = {
                    "Finaler Umsatz (Wiki>CRM)": final_umsatz_str_konsolidiert,
                    "Finaler Mitarbeiter (Wiki>CRM)": final_ma_str_konsolidiert,
                    "CRM Umsatz": self._get_cell_value_safe(row_data, "CRM Umsatz"),
                    "Wiki Umsatz": final_wiki_data.get('umsatz', 'k.A.'), # final_wiki_data ist hier verfügbar
                    "CRM Anzahl Mitarbeiter": self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter"),
                    "Wiki Mitarbeiter": final_wiki_data.get('mitarbeiter', 'k.A.'), # final_wiki_data ist hier verfügbar
                    "Parent Account Name": parent_account_name_d, # Ist am Anfang von _process_single_row gelesen
                    # NEU: Werte für O und P direkt aus row_data holen
                    "System Vorschlag Parent Account": self._get_cell_value_safe(row_data, "System Vorschlag Parent Account"),
                    "Parent Vorschlag Status": self._get_cell_value_safe(row_data, "Parent Vorschlag Status")
                }
                plausi_results = self._check_financial_plausibility(plausi_input_data)


                updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[plausi_results.get("plaus_umsatz_flag", "ERR_FLAG")]]})
                updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[plausi_results.get("plaus_ma_flag", "ERR_FLAG")]]})
                updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Umsatz/MA Ratio"] + 1)}{row_num_in_sheet}', 'values': [[plausi_results.get("plaus_ratio_flag", "ERR_FLAG")]]})
                updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Abweichung Umsatz CRM/Wiki"] + 1)}{row_num_in_sheet}', 'values': [[plausi_results.get("abweichung_umsatz_flag", "ERR_FLAG")]]}) # BJ
                updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Abweichung MA CRM/Wiki"] + 1)}{row_num_in_sheet}', 'values': [[plausi_results.get("abweichung_ma_flag", "ERR_FLAG")]]})    # BK
                updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Begründung"] + 1)}{row_num_in_sheet}', 'values': [[plausi_results.get("plausi_begruendung_final", "Fehler Begr.")]]})
                
            except Exception as e_plausi_in_single_row_chat:
                self.logger.error(f"FEHLER bei Plausibilitäts-Checks in _process_single_row (Chat-Block) für Zeile {row_num_in_sheet}: {e_plausi_in_single_row_chat}")
                # ... (Fehler-Flags für Plausi-Spalten setzen) ...
            
            # Plausi-Timestamp (BM) und Haupt-Timestamp (BN) setzen
            updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Prüfdatum"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]})
            updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Timestamp letzte Pruefung"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]})


        # --- Der Code fuer den naechsten Verarbeitungsschritt (ML Prediction) folgt im naechsten Block ---
        # Definition der Methode _process_single_row wird in der naechsten Nachricht fortgesetzt.

        # --- 4. Servicetechniker Schaetzung (ML Modell) (AU) ---
        # Dieser Schritt wird ausgefuehrt, wenn 'ml_predict' in steps_to_run enthalten ist UND
        # (_needs_ml_prediction True ist ODER force_reeval True ist).
        # _needs_ml_prediction (Block 16) prueft AU, AO und Konsolidierte Werte (AV/AW).
        # Nutzt interne Helfer: _needs_ml_prediction, _predict_technician_bucket, _get_cell_value_safe.
        # Nutzt globale Helfer: COLUMN_MAP, logger.
        # Nutzt lokale Variablen: force_reeval, chat_eval_just_ran.

        # Pruefen Sie, ob der ML-Schritt im aktuellen Lauf angefordert wurde
        run_ml_step = 'ml_predict' in steps_to_run
        # Pruefen Sie, ob der ML-Schritt laut Status, Re-Eval oder Chat-Evaluation (Trigger) noetig ist.
        # chat_eval_just_ran ist ein Flag aus dem vorherigen Chat-Schritt (Block 20).
        ml_processing_needed_based_on_status = self._needs_ml_prediction(row_data, force_reeval, chat_eval_just_ran)


        # Wenn der ML-Schritt angefordert wurde UND laut Status/Re-Eval noetig ist
        if run_ml_step and ml_processing_needed_based_on_status:
            any_processing_done = True # Markiere, dass in dieser Zeile etwas getan wird

            # Bestimme den Grund fuer die Ausfuehrung dieses Schritts fuer das Logging
            grund_message_parts = []
            if force_reeval: grund_message_parts.append('Re-Eval')
            # Wenn nicht Re-Eval, dann liegt es an _needs_ml_prediction. Logge den Grund von dort auf Debug.
            if not force_reeval:
                 self.logger.debug("  -> ML-Schaetzung noetig (Grund laut _needs_ml_prediction).") # <<< GEÄNDERT
                 pass # Der spezifische Grund wird bereits in _needs_ml_prediction geloggt (auf Debug).

            self.logger.info(f"Zeile {row_num_in_sheet}: Fuehre ML-Schaetzung aus...") # <<< GEÄNDERT

            # Die ML-Schaetzung benoetigt die vorbereiteten Daten (konsolidierter Umsatz/Mitarbeiter und Branche).
            # Diese Werte sind bereits in der Zeile im Sheet verfuegbar (Spalten AV, AW)
            # oder wurden gerade in den vorherigen Schritten (Block 20) aktualisiert.
            # Die _predict_technician_bucket Methode muss diese Werte aus den row_data holen.

            try:
                 # Annahme: _predict_technician_bucket Methode existiert in DataProcessor (Block 31).
                 # Diese Methode muss das geladene Modell/Imputer nutzen (Attribute der Klasse),
                 # die benoetigten Features aus den row_data extrahieren, vorbereiten und vorhersagen.
                 # _predict_technician_bucket wirft Exception bei Fehlern.
                 predicted_bucket = self._predict_technician_bucket(row_data) # Nutzt row_data fuer Feature-Extraktion (Block 17)

                 # Wenn die Vorhersage erfolgreich war und ein Bucket-Label zurueckgegeben wurde
                 if predicted_bucket and isinstance(predicted_bucket, str) and not predicted_bucket.startswith("FEHLER"):
                      # Sammle Update fuer den AU Bucket (Geschaetzter Techniker Bucket) (nutzt interne Helfer)
                      updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Geschaetzter Techniker Bucket"] + 1)}{row_num_in_sheet}', 'values': [[predicted_bucket]]}) # Block 1 Column Map
                      self.logger.info(f"  -> ML-Schaetzung erfolgreich: Bucket '{predicted_bucket}'.") # <<< GEÄNDERT
                 else:
                      # Wenn die Vorhersage fehlschlug oder kein Ergebnis lieferte
                      self.logger.warning(f"  -> ML-Schaetzung lieferte kein gueltiges Ergebnis: '{predicted_bucket}'.") # <<< GEÄNDERT
                      # Setze einen Fehlerwert im Sheet
                      updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Geschaetzter Techniker Bucket"] + 1)}{row_num_in_sheet}', 'values': [['k.A. (Schaetzung fehlgeschlagen)']]}) # Block 1 Column Map

            except Exception as e_ml:
                 # Wenn _predict_technician_bucket eine Exception wirft
                 self.logger.error(f"FEHLER bei ML-Schaetzung fuer Zeile {row_num_in_sheet}: {e_ml}") # <<< GEÄNDERT
                 # Logge den Traceback
                 self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
                 # Fuege Fehler-Update hinzu, um den Fehler im Sheet zu dokumentieren
                 updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Geschaetzter Techniker Bucket"] + 1)}{row_num_in_sheet}', 'values': [[f'FEHLER Schaetzung: {str(e_ml)[:50]}...']]}) # Block 1 Column Map
                 pass # Faert fort


        # else if run_ml_step:
             # Der ML-Schritt war angefordert, aber nicht noetig basierend auf Status/Re-Eval/Chat-Evaluation.
             # self.logger.debug(f"Zeile {row_num_in_sheet}: Ueberspringe ML-Schaetzung (AU gesetzt oder Daten/Trigger fehlen).") # Zu viel Laerm im Debug


        # ======================================================================
        # === Abschluss der _process_single_row Verarbeitung ===================
        # ======================================================================

        # --- 5. Abschliessende Updates (Version, Tokens) ---

        # Version (AP) wird gesetzt, wenn IRGENDEINE Verarbeitung in dieser Zeile stattgefunden hat.
        if any_processing_done:
            # Nutzt Config.VERSION (Block 1) und interne Helfer.
            version_col_idx = COLUMN_MAP.get("Version") # Block 1 Column Map
            if version_col_idx is not None:
                 # Fuege das Update fuer die Version zur Liste hinzu
                 updates.append({'range': f'{self.sheet_handler._get_col_letter(version_col_idx + 1)}{row_num_in_sheet}', 'values': [[getattr(Config, 'VERSION', 'unknown')]]}) # Block 1 Column Map
            else:
                 self.logger.error("FEHLER: Spaltenschluessel 'Version' nicht in COLUMN_MAP gefunden.") # <<< GEÄNDERT

            # Tokens (AQ) - Hier ist die Zaehlung komplex, da mehrere OpenAI-Calls passiert sein koennten.
            # Eine einfache Loesung ist, die Token-Zahl der letzten relevanten Antwort zu speichern
            # oder die Token-Zahl der Prompts/Antworten waehrend des Laufs zu aggregieren.
            # Eine Aggregation in den einzelnen Schritten (Web Summary, Branch Eval etc.) waere genauer.
            # Wenn der Token-Count in den einzelnen OpenAI-Call-Methoden implementiert wird,
            # muss er dort gesammelt und dann HIER in _process_single_row ins Update eingefuegt werden.
            # Beispiel: Sie koennten ein Attribut self.current_row_token_count am Anfang von _process_single_row auf 0 setzen,
            # und in jeder Methode (call_openai_chat, summarize_batch_openai), die Token nutzt, diesen Zaehler erhoehen.
            # Dann hier:
            # tokens_col_idx = COLUMN_MAP.get("Tokens") # Block 1 Column Map
            # if tokens_col_idx is not None and hasattr(self, 'current_row_token_count') and self.current_row_token_count > 0:
            #      updates.append({'range': f'{self.sheet_handler._get_col_letter(tokens_col_idx + 1)}{row_num_in_sheet}', 'values': [[str(self.current_row_token_count)]]})
            # else: # logger.debug("Keine Tokens zu speichern fuer diese Zeile."); # Zu viel Laerm im Debug
            #      if tokens_col_idx is None: self.logger.error("FEHLER: Spaltenschluessel 'Tokens' nicht in COLUMN_MAP gefunden."); # Block 1 Column Map
            pass # Token-Zaehlung Implementierung erfordert Aenderungen in OpenAI Helpers und _process_single_row Init.


        # --- 5b. ReEval Flag (A) loeschen (nur wenn im Re-Eval Modus und gewuenscht) ---
        # Dieses Update wird am Ende der _process_single_row Methode hinzugefuegt,
        # wenn der Aufruf aus process_reevaluation_rows (Block 25) mit clear_x_flag=True kam.
        if force_reeval and clear_x_flag:
             # Ermitteln Sie den Index der ReEval Flag Spalte
             reeval_col_idx = COLUMN_MAP.get("ReEval Flag") # Block 1 Column Map
             if reeval_col_idx is not None:
                  # Ermitteln Sie den Spaltenbuchstaben
                  flag_col_letter = self.sheet_handler._get_col_letter(reeval_col_idx + 1)
                  if flag_col_letter:
                       # Fuegen Sie das Update zum Loeschen des 'x'-Flags zur Liste hinzu.
                       # Es wird nur geloescht, wenn die Zeile ansonsten erfolgreich bis hierhin kam und Updates gesammelt wurden.
                       # Wenn eine schwere Exception in _process_single_row auftrat, wird dieser Block nicht erreicht.
                       updates.append({'range': f'{flag_col_letter}{row_num_in_sheet}', 'values': [['']]})
                       self.logger.debug(f"  -> Update zum Loeschen des ReEval-Flags (A{row_num_in_sheet}) vorgemerkt.") # <<< GEÄNDERT
                  else:
                       # Logge Fehler, wenn Spaltenbuchstaben nicht ermittelt werden konnten
                       self.logger.error(f"FEHLER: Konnte Spaltenbuchstaben fuer 'ReEval Flag' ({reeval_col_idx+1}) nicht ermitteln. Flag kann nicht geloescht werden.") # <<< GEÄNDERT
             else:
                  # Logge Fehler, wenn Spaltenindex fehlt
                  self.logger.error("FEHLER: 'ReEval Flag' Spaltenindex nicht in COLUMN_MAP gefunden. Flag kann nicht geloescht werden.") # <<< GEÄNDERT


        # --- 6. Batch Update fuer diese Zeile ---
        # Fuehren Sie das Batch-Update fuer ALLE gesammelten Aenderungen dieser EINEN Zeile durch.
        if updates:
             # Info-Log ueber die Anzahl der Updates fuer diese spezifische Zeile
             self.logger.info(f"Zeile {row_num_in_sheet}: Sende Batch-Update mit {len(updates)} Operationen fuer diese Zeile...") # <<< GEÄNDERT
             # Rufe die batch_update_cells Methode des Sheet Handlers auf.
             # batch_update_cells ist mit retry_on_failure dekoriert und loggt intern.
             success = self.sheet_handler.batch_update_cells(updates) # Nutzt die uebergeordnete Instanz

             # Wenn der Batch-Update fehlschlaegt (nach Retries)
             if not success:
                  # Logge einen Error
                  self.logger.error(f"Zeile {row_num_in_sheet}: ENDGUELTIGER FEHLER beim Batch-Update nach Retries.") # <<< GEÄNDERT
                  # Hier koennen Sie z.B. einen Fehlerindikator in eine spezielle Spalte schreiben
                  # (Dieses Update muesste separat oder im naechsten Lauf behandelt werden)

        else:
             # Info-Log, wenn nichts zu tun war in dieser Zeile
             if not any_processing_done:
                  self.logger.debug(f"Zeile {row_num_in_sheet}: Keine Updates zum Schreiben (alle relevanten Schritte uebersprungen oder nicht angefordert).") # <<< GEÄNDERT
             # else:
                  # Dieser Fall sollte nicht eintreten, wenn updates nicht leer ist, aber any_processing_done True ist.
                  # self.logger.warning(f"Zeile {row_num_in_sheet}: Updates Liste war leer, aber any_processing_done=True. Pruefen Sie die Logik.") # <<< GEÄNDERT


        # Kleine Pause nach der Verarbeitung jeder Zeile, um API-Limits zu respektieren
        # und die Belastung fuer das Google Sheet zu reduzieren.
        # Der Wert sollte in Config (Block 1) angepasst werden. Eine kurze Pause ist auch bei Batch-Modi sinnvoll,
        # wenn _process_single_row von dort aufgerufen wird (z.B. fuer Re-Eval).
        # Nutzt Config.RETRY_DELAY (Block 1), ggf. kuerzer.
        pause_duration = max(0.05, getattr(Config, 'RETRY_DELAY', 5) / 20.0) # Mindestens 50ms Wartezeit
        # self.logger.debug(f"Wartezeit nach Zeile {row_num_in_sheet}: {pause_duration:.2f}s") # Zu viel Laerm im Debug
        time.sleep(pause_duration)

        # Logge den Abschluss der Verarbeitung fuer diese Zeile
        self.logger.info(f"--- Verarbeitung fuer Zeile {row_num_in_sheet} abgeschlossen ---") # <<< GEÄNDERT

    # --- Ende der _process_single_row Methode ---


    # Die naechste Methode der DataProcessor Klasse folgt im naechsten Block.
    # Dies ist die process_rows_sequentially Methode (Block 24).

    # ==========================================================================
    # === Prozess Methoden (Sequentiell) =======================================
    # ==========================================================================

    # --- Methode fuer sequentielle Verarbeitung (Modus full_run) ---
    # Diese Methode verarbeitet Zeilen einzeln, nacheinander, im Gegensatz zu Batch-Modi.
    # Sie ruft _process_single_row fuer jede Zeile im definierten Bereich auf.
    # Nutzt interne Helfer: _process_single_row, _get_cell_value_safe.
    # Nutzt globale Helfer: COLUMN_MAP, logger.
    # Nutzt die uebergeordnete sheet_handler Instanz.
    def process_rows_sequentially(self, start_sheet_row, num_to_process,
                                  process_wiki_steps=True,
                                  process_chatgpt_steps=True,
                                  process_website_steps=True,
                                  process_ml_steps=True, # Neues Flag fuer ML-Schritt
                                  # Fuegen Sie hier ggf. weitere boolsche Flags fuer andere Schrittgruppen hinzu
                                  force_reeval_in_single_row=False): # Optionale Steuerung fuer _process_single_row
        """
        Verarbeitet eine feste Anzahl von Zeilen beginnend bei einer bestimmten
        Sheet-Zeilennummer sequentiell, eine nach der anderen, unter Verwendung
        von _process_single_row.

        Args:
            start_sheet_row (int): Die 1-basierte Zeilennummer im Sheet, ab der gestartet werden soll.
            num_to_process (int): Die maximale Anzahl der zu verarbeitenden Zeilen.
            process_wiki_steps (bool, optional): Soll der Wiki-Schritt in _process_single_row ausgefuehrt werden?. Defaults to True.
            process_chatgpt_steps (bool, optional): Sollen ChatGPT-Schritte in _process_single_row ausgefuehrt werden?. Defaults to True.
            process_website_steps (bool, optional): Soll der Website-Schritt in _process_single_row ausgefuehrt werden?. Defaults to True.
            process_ml_steps (bool, optional): Soll der ML-Schritt in _process_single_row ausgefuehrt werden?. Defaults to True. # Neues Flag
            # Fuegen Sie hier ggf. weitere boolsche Flags fuer andere Schrittgruppen hinzu.
            force_reeval_in_single_row (bool, optional): Wenn True, wird force_reeval=True in _process_single_row
                                                         fuer alle verarbeiteten Zeilen in diesem Lauf gesetzt. Defaults to False.
        """
        # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
        # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar.
        header_rows = self.sheet_handler._header_rows


        # Pruefen Sie, ob num_to_process gueltig ist
        if num_to_process is None or not isinstance(num_to_process, int) or num_to_process <= 0:
             self.logger.info("Sequentielle Verarbeitung uebersprungen: num_to_process ist ungueltig oder <= 0.") # <<< GEÄNDERT
             return

        # Logge die Konfiguration des sequentiellen Laufs
        self.logger.info(f"Starte sequentielle Verarbeitung von {num_to_process} Zeilen ab Sheet-Zeile {start_sheet_row}...") # <<< GEÄNDERT
        # Logge die ausgewaehlten Schritte fuer diesen Lauf
        selected_steps_log = []
        if process_wiki_steps: selected_steps_log.append("Wiki (wiki)")
        if process_chatgpt_steps: selected_steps_log.append("ChatGPT (chat)")
        if process_website_steps: selected_steps_log.append("Website (web)")
        if process_ml_steps: selected_steps_log.append("ML Predict (ml_predict)") # Neues Flag
        # Fuegen Sie hier weitere Schritte hinzu, wenn neue Flags existieren
        self.logger.info(f"  Ausgewaehlte Schritte fuer sequentiellen Lauf: {', '.join(selected_steps_log) if selected_steps_log else 'Keine ausgewählt!'}") # <<< GEÄNDERT

        # Logge, ob force_reeval in _process_single_row gesetzt wird
        if force_reeval_in_single_row:
             self.logger.warning("  !!! force_reeval=True wird fuer alle Zeilen in _process_single_row gesetzt !!!") # <<< GEÄNDERT

        # Erstelle das Set der Schluessel fuer die Schritte, die an _process_single_row uebergeben werden
        steps_to_run_set = set()
        if process_wiki_steps: steps_to_run_set.add('wiki')
        if process_chatgpt_steps: steps_to_run_set.add('chat') # Annahme: 'chat' triggert alle ChatGPT Schritte in _process_single_row (Block 20)
        if process_website_steps: steps_to_run_set.add('web')
        if process_ml_steps: steps_to_run_set.add('ml_predict') # Neues Flag
        # Fuegen Sie hier weitere Schluessel hinzu, wenn neue Flags verwendet werden


        # Wenn keine Schritte ausgewaehlt wurden (trotz gueltigem num_to_process)
        if not steps_to_run_set:
            self.logger.warning("Keine Verarbeitungsschritte fuer sequentiellen Lauf ausgewaehlt (steps_to_run_set ist leer). Modus wird uebersprungen.") # <<< GEÄNDERT
            return


        # Lade Daten einmalig vor der Verarbeitung (nutzt die uebergeordnete Instanz)
        # Der load_data Aufruf ist mit retry_on_failure dekoriert.
        if not self.sheet_handler.load_data():
            self.logger.error("Fehler beim Laden der Daten fuer sequentielle Verarbeitung.") # <<< GEÄNDERT
            return # Beende die Methode, wenn das Laden fehlschlaegt


        # Holen Sie die gesamte Datenliste (inklusive Header)
        all_data = self.sheet_handler.get_all_data_with_headers()
        total_sheet_rows = len(all_data)


        # Berechnen Sie den tatsaechlichen Start-Index in der all_data Liste (0-basiert)
        start_index_in_all_data = start_sheet_row - 1

        # Pruefen Sie, ob der angegebene Startindex gueltig ist
        if start_index_in_all_data >= total_sheet_rows:
             self.logger.warning(f"Start-Sheet-Zeile {start_sheet_row} (Index {start_index_in_all_data}) liegt ausserhalb der verfuegbaren Daten ({total_sheet_rows} Zeilen insgesamt). Keine Verarbeitung.") # <<< GEÄNDERT
             return # Beende die Methode, wenn der Startindex ungueltig ist
        if start_index_in_all_data < header_rows:
             # Wenn der Startindex innerhalb der Header liegt, beginnen Sie nach den Headern.
             self.logger.warning(f"Start-Sheet-Zeile {start_sheet_row} liegt innerhalb der Header-Zeilen ({header_rows} Header). Verarbeitung startet ab Sheet-Zeile {header_rows + 1}.") # <<< GEÄNDERT
             start_index_in_all_data = header_rows # Beginnen Sie direkt nach den Headern


        # Berechne den tatsaechlichen End-Index in der all_data Liste (exklusiv)
        # Der Endindex ist der Startindex + die Anzahl der zu verarbeitenden Zeilen.
        # Stellen Sie sicher, dass der Endindex die Gesamtanzahl der Zeilen nicht ueberschreitet.
        end_index_in_all_data = min(start_index_in_all_data + num_to_process, total_sheet_rows)


        # Logge den Bereich der tatsaechlich zu verarbeitenden Zeilen
        self.logger.info(f"Sequentielle Verarbeitung: Verarbeitungsbereich (0-basiert Index) [{start_index_in_all_data}, {end_index_in_all_data}). Entsprechende Sheet-Zeilen (1-basiert): {start_index_in_all_data + 1} bis {end_index_in_all_data}.") # <<< GEÄNDERT

        # Pruefen Sie, ob es ueberhaupt Zeilen im berechneten Bereich gibt
        if start_index_in_all_data >= end_index_in_all_data:
             self.logger.info(f"Berechneter Startindex ({start_index_in_all_data}) liegt bei oder nach dem berechneten Endindex ({end_index_in_all_data}). Keine Zeilen im definierten Bereich zu verarbeiten.") # <<< GEÄNDERT
             return # Beende die Methode, wenn der Bereich leer ist


        processed_count = 0 # Zaehlt Zeilen, fuer die _process_single_row aufgerufen wurde.
        # Iteriere ueber die Zeilen im definierten Bereich (0-basierter Index in all_data)
        for i in range(start_index_in_all_data, end_index_in_all_data):
            row_num_in_sheet = i + 1 # 1-basierte Zeilennummer im Sheet fuer _process_single_row
            row_data = all_data[i] # Tatsaechliche Zeilendaten aus der Gesamtliste (0-basierter Index)

            # Ueberspringen Sie Header-Zeilen explizit, falls der Startindex faelschlicherweise <= header_rows war
            if row_num_in_sheet <= header_rows:
                 self.logger.debug(f"Ueberspringe Header-Zeile {row_num_in_sheet}.") # <<< GEÄNDERT
                 continue # Springe zur naechsten Iteration


            # Stellen Sie sicher, dass die Zeile nicht leer ist oder nur aus leeren Strings besteht
            # Nutzt die interne Helferfunktion _get_cell_value_safe implizit durch Iteration oder prueft direkt
            # Eine einfache Pruefung: Ist irgendeine Zelle in der Zeile nicht leer oder None?
            if not any(cell and isinstance(cell, str) and cell.strip() for cell in row_data):
                 self.logger.debug(f"Ueberspringe scheinbar leere Zeile {row_num_in_sheet}.") # <<< GEÄNDERT
                 continue # Springe zur naechsten Iteration


            try:
                # Rufe die Methode zur Verarbeitung einer einzelnen Zeile auf.
                # _process_single_row wird intern die Timestamps pruefen (ausser wenn force_reeval=True).
                # Uebergeben Sie die ausgewaehlten Schritte und das force_reeval Flag.
                self._process_single_row(
                    row_num_in_sheet = row_num_in_sheet,
                    row_data = row_data, # Uebergibt die aktuellen Rohdaten der Zeile
                    steps_to_run = steps_to_run_set, # <-- Uebergibt die aus CLI/Menue ausgewaehlten Schritte
                    force_reeval = force_reeval_in_single_row, # <-- Steuert force_reeval in _process_single_row
                    clear_x_flag = False # Im sequentiellen Lauf wird das 'x'-Flag normalerweise NICHT geloescht
                                         # (Dies wird nur im 'reeval' Modus (Block 25) benoetigt)
                )

                # Zaehlen, wenn _process_single_row erfolgreich aufgerufen wurde (unabhaengig von internen Ueberspringungen in _process_single_row).
                processed_count += 1

            except Exception as e_proc:
                # Wenn _process_single_row einen Fehler wirft (nachdem interne Retries aufgaben),
                # fangen wir ihn hier, loggen ihn und fahren mit der naechsten Zeile fort.
                self.logger.exception(f"FEHLER bei sequentieller Verarbeitung von Zeile {row_num_in_sheet}: {e_proc}") # <<< GEÄNDERT
                # Hier koennen Sie z.B. einen Fehlerindikator in eine spezielle Spalte im Sheet schreiben lassen.
                # Dieses Update muesste dann separat oder im naechsten Lauf behandelt werden.

            # _process_single_row beinhaltet bereits eine kleine Pause am Ende.
            # Hier ist keine zusaetzliche Pause noetig, wenn _process_single_row erfolgreich war.
            # Wenn _process_single_row eine Exception wirft, kann hier eine kurze Pause sinnvoll sein.
            # time.sleep(0.1) # Optional: Kurze Pause bei Fehler

        # Logge den Abschluss der sequentiellen Verarbeitung
        self.logger.info(f"Sequentielle Verarbeitung abgeschlossen. {processed_count} Zeilen im Bereich [{start_sheet_row}, {end_index_in_all_data}] bearbeitet.") # <<< GEÄNDERT


    # ==============================================================================
    # Ende DataProcessor Klasse Prozess: Sequenziell Block
    # ==============================================================================

    # ==========================================================================
    # === Prozess Methoden (Re-Evaluation) =====================================
    # ==========================================================================

    # --- Methode fuer den Re-Eval Modus (Spalte A = 'x') ---
    # Diese Methode verarbeitet nur Zeilen, die in Spalte A mit 'x' markiert sind.
    # Sie ruft _process_single_row fuer jede dieser Zeilen auf mit force_reeval=True
    # und uebergibt die Auswahl der Schritte und das Flag zum Loeschen des 'x'-Flags.
    # Nutzt interne Helfer: _process_single_row, _get_cell_value_safe.
    # Nutzt globale Helfer: COLUMN_MAP, logger.
    # Nutzt die uebergeordnete sheet_handler Instanz.
    def process_reevaluation_rows(self, row_limit=None, clear_flag=True,
                                  process_wiki_steps=True,
                                  process_chatgpt_steps=True,
                                  process_website_steps=True,
                                  process_ml_steps=True # Neues Flag fuer ML-Schritt
                                  # Fuegen Sie hier ggf. weitere boolsche Flags fuer andere Schrittgruppen hinzu
                                 ):
        """
        Verarbeitet nur Zeilen, die in Spalte A mit 'x' markiert sind.
        Ruft _process_single_row fuer jede dieser Zeilen auf mit force_reeval=True.
        Verarbeitet maximal row_limit Zeilen.
        Loescht optional das 'x'-Flag nach erfolgreicher Verarbeitung (innerhalb von _process_single_row).
        Erlaubt die Auswahl spezifischer Verarbeitungsschritte.

        Args:
            row_limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None (Unbegrenzt).
            clear_flag (bool, optional): Wenn True, wird das Flag 'x' in Spalte A
                                         nach erfolgreicher Verarbeitung durch _process_single_row geloescht.
                                         Defaults to True.
            process_wiki_steps (bool, optional): Soll der Wiki-Schritt in _process_single_row ausgefuehrt werden?. Defaults to True.
            process_chatgpt_steps (bool, optional): Sollen ChatGPT-Schritte in _process_single_row ausgefuehrt werden?. Defaults to True.
            process_website_steps (bool, optional): Soll der Website-Schritt in _process_single_row ausgefuehrt werden?. Defaults to True.
            process_ml_steps (bool, optional): Soll der ML-Schritt in _process_single_row ausgefuehrt werden?. Defaults to True. # Neues Flag
            # Fuegen Sie hier ggf. weitere boolsche Flags fuer andere Schrittgruppen hinzu.
        """
        # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
        # Logge die Konfiguration des Re-Eval Laufs
        self.logger.info(f"Starte Re-Evaluierungsmodus (Spalte A = 'x'). Max. Zeilen: {row_limit if row_limit is not None else 'Unbegrenzt'}") # <<< GEÄNDERT
        # Logge die ausgewaehlten Schritte fuer diesen Lauf
        selected_steps_log = []
        if process_wiki_steps: selected_steps_log.append("Wiki (wiki)")
        if process_chatgpt_steps: selected_steps_log.append("ChatGPT (chat)")
        if process_website_steps: selected_steps_log.append("Website (web)")
        if process_ml_steps: selected_steps_log.append("ML Predict (ml_predict)") # Neues Flag
        # Fuegen Sie hier weitere Schritte hinzu, wenn neue Flags existieren
        self.logger.info(f"Ausgewaehlte Schritte fuer Re-Eval: {', '.join(selected_steps_log) if selected_steps_log else 'Keine ausgewählt!'}") # <<< GEÄNDERT


        # Erstelle das Set der Schluessel fuer die Schritte, die an _process_single_row uebergeben werden
        steps_to_run_set = set()
        if process_wiki_steps: steps_to_run_set.add('wiki')
        if process_chatgpt_steps: steps_to_run_set.add('chat') # Annahme: 'chat' triggert alle ChatGPT Schritte in _process_single_row (Block 20)
        if process_website_steps: steps_to_run_set.add('web')
        if process_ml_steps: steps_to_run_set.add('ml_predict') # Neues Flag
        # Fuegen Sie hier weitere Schluessel hinzu, wenn neue Flags verwendet werden


        # Wenn keine Schritte ausgewaehlt wurden
        if not steps_to_run_set:
            self.logger.warning("Keine Verarbeitungsschritte fuer Re-Eval ausgewaehlt (steps_to_run_set ist leer). Modus wird uebersprungen.") # <<< GEÄNDERT
            return


        # Daten neu laden vor der Verarbeitung (nutzt die uebergeordnete Instanz)
        # Der load_data Aufruf ist mit retry_on_failure dekoriert.
        if not self.sheet_handler.load_data():
            self.logger.error("Fehler beim Laden der Daten fuer Re-Evaluation.") # <<< GEÄNDERT
            return # Beende die Methode, wenn das Laden fehlschlaegt


        # Holen Sie die gesamte Datenliste (inklusive Header)
        all_data = self.sheet_handler.get_all_data_with_headers()
        # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar.
        header_rows = self.sheet_handler._header_rows
        # Wenn keine Daten da sind oder nur Header
        if not all_data or len(all_data) <= header_rows:
             self.logger.warning("Keine Datenzeilen fuer Re-Evaluation gefunden.") # <<< GEÄNDERT
             return # Beende die Methode


        # Ermitteln Sie den Index der ReEval Flag Spalte aus COLUMN_MAP (Block 1)
        reeval_col_idx = COLUMN_MAP.get("ReEval Flag")
        if reeval_col_idx is None:
            self.logger.critical("FEHLER: 'ReEval Flag' Spaltenindex nicht in COLUMN_MAP gefunden. Kann Zeilen mit 'x' nicht finden. Breche ab.") # <<< GEÄNDERT
            return # Beende die Methode bei kritischem Fehler


        # Sammeln Sie die Zeilen, die in Spalte A mit 'x' markiert sind.
        # Sammeln Sie die Zeilen, die in Spalte A 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 
             cell_a_value = self._get_cell_value_safe(row_data, "ReEval Flag").strip().lower()
             if cell_a_value == "x":
                  rows_to_process.append({'row_num': row_num_in_sheet, 'data': row_data})

        found_count = len(rows_to_process) 
        self.logger.info(f"{found_count} Zeilen mit ReEval-Flag 'x' gefunden.") 
        if found_count == 0:
            self.logger.info("Keine Zeilen zur Re-Evaluation markiert.") 
            return 

        # ----- KORREKTUR DER EINRÜCKUNG HIER -----
        processed_count_actual = 0 # Neuer Zähler für tatsächlich verarbeitete Zeilen
        # ----- ENDE KORREKTUR -----
        
        for task in rows_to_process:
            row_num = task['row_num']
            row_data = task['data']

            # Limit-Prüfung direkt hier, bevor eine Zeile bearbeitet wird
            if row_limit is not None and isinstance(row_limit, int) and row_limit > 0 and processed_count_actual >= row_limit:
                self.logger.info(f"Zeilenlimit ({row_limit}) fuer Re-Evaluation erreicht. Breche weitere Verarbeitung ab. Bereits verarbeitet: {processed_count_actual}")
                break 
            
            self.logger.info(f"Bearbeite Re-Eval Zeile {row_num}...")
            processed_count_actual += 1 # Inkrementiere, da diese Zeile jetzt verarbeitet wird
            try:
                self._process_single_row( 
                    row_num_in_sheet = row_num,
                    row_data = row_data,
                    steps_to_run = steps_to_run_set,
                    force_reeval = True, 
                    clear_x_flag = clear_flag
                )
            except Exception as e_proc_reval_main:
                self.logger.exception(f"FEHLER bei Re-Evaluation von Zeile {row_num} in process_reevaluation_rows: {e_proc_reval_main}")
        
        self.logger.info(f"Re-Evaluierung abgeschlossen. {processed_count_actual} Zeilen verarbeitet (Gefunden mit 'x': {found_count}, CLI-Limit war: {row_limit}).")


    # ==============================================================================
    # Ende DataProcessor Klasse Prozess: Re-Evaluation Block
    # ==============================================================================

    # ==========================================================================
    # === Batch Processing Methods =============================================
    # ==========================================================================

    # --- Interne Hilfsfunktion fuer Wiki-Verifizierungs-Batch (OpenAI Call) ---
    # Diese Funktion verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen ueber OpenAI.
    # Sie wird von process_verification_batch (dieselben Block) aufgerufen.
    # Nutzt globale Helfer: call_openai_chat (Block 8), logger, token_count (optional Block 3), retry_on_failure (Block 2), re.
    @retry_on_failure # Wende den Decorator auf den gesamten Batch-API Call an
    def _process_verification_openai_batch(self, batch_data):
        """
        Verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen ueber OpenAI.
        Sammelt die Ergebnisse und gibt sie zurueck. Aktualisiert NICHT das Sheet direkt.

        Args:
            batch_data (list): Liste von Dictionaries, jedes enthaelt:
                               {'row_num': int, 'company_name': str, 'crm_desc': str,
                                'wiki_url': str, 'wiki_paragraph': str, 'wiki_categories': str}

        Returns:
            dict: Ein Dictionary, das Zeilennummern auf die rohe ChatGPT-Antwort mappt.
                  z.B. {2122: "OK", 2123: "X | ..."}
                  Bei Fehlern oder fehlenden Antworten wird ein Fehlerstring verwendet.
            Wirft Exception bei endgueltigen API-Fehlern nach Retries.
        """
        # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
        if not batch_data:
            return {} # Gebe leeres Dictionary zurueck, wenn keine Tasks da sind

        self.logger.debug(f"Sende OpenAI-Batch fuer Wiki-Verifizierung ({len(batch_data)} Zeilen, Start: {batch_data[0]['row_num'] if batch_data else 'N/A'})...") # <<< GEÄNDERT

        # --- Prompt Erstellung ---
        # Verwenden Sie klare Anweisungen und das definierte Antwortformat.
        # Vermeiden Sie Umlaute im Prompt, um Encoding-Probleme zu minimieren.
        aggregated_prompt = (
            "Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln fuer Unternehmen. "
            "Fuer jeden der folgenden Eintraege pruefe, ob der vorhandene Wikipedia-Artikel (URL, Absatz, Kategorien) plausibel zum Firmennamen und zur Beschreibung passt. "
            "Gib das Ergebnis fuer jeden Eintrag ausschliesslich im folgenden Format auf einer neuen Zeile aus:\n"
            "Eintrag <Zeilennummer>: <Antwort>\n\n"
            "Moegliche Antworten:\n"
            "- 'OK' (wenn der Artikel gut passt)\n"
            "- 'X | Alternativer Artikel: <URL> | Begruendung: <Kurze Begruendung>' (wenn der Artikel nicht passt, aber ein besserer gefunden wurde)\n"
            "- 'X | Kein passender Artikel gefunden | Begruendung: <Kurze Begruendung>' (wenn der Artikel nicht passt und kein besserer gefunden wurde)\n"
            # Der Fall "Kein Wikipedia-Eintrag vorhanden" wird vom Skript VOR diesem Call behandelt
            # und sollte hier nicht vom KI-Modell generiert werden.
            "Stelle sicher, dass du nur EINE Zeile pro Eintrag im Format 'Eintrag X: Antwort' ausgibst.\n\n"
            "Eintraege zur Pruefung:\n"
            "--------------------\n"
        )

        # Fuegen Sie die Daten fuer jeden Eintrag im Batch hinzu.
        # Kuerzen Sie die Beschreibungen und Kategorien, um das Prompt-Limit zu reduzieren.
        # Stellen Sie sicher, dass die Werte Strings sind und "k.A." richtig behandelt wird.
        max_desc_length = 200 # Maximale Laenge fuer Beschreibungsteile im Prompt
        for item in batch_data:
            row_num = item['row_num']
            # Holen und Kuerzen Sie die Werte sicher. Ersetzen Sie None durch "k.A.".
            company_name = str(item.get('company_name', 'k.A.'))
            crm_desc = str(item.get('crm_desc', 'k.A.'))
            wiki_url = str(item.get('wiki_url', 'k.A.'))
            wiki_paragraph = str(item.get('wiki_paragraph', 'k.A.'))
            wiki_categories = str(item.get('wiki_categories', 'k.A.'))

            # Kuerzen Sie die Laengen und fuegen Sie "..." hinzu, wenn gekuerzt wurde.
            crm_desc_short = crm_desc[:max_desc_length] + '...' if len(crm_desc) > max_desc_length else crm_desc
            wiki_paragraph_short = wiki_paragraph[:max_desc_length] + '...' if len(wiki_paragraph) > max_desc_length else wiki_paragraph
            wiki_categories_short = wiki_categories[:max_desc_length] + '...' if len(wiki_categories) > max_desc_length else wiki_categories


            entry_text = (
                f"Eintrag {row_num}:\n"
                f"  Firmenname: {company_name}\n"
                f"  CRM-Beschreibung: {crm_desc_short}\n"
                f"  Wikipedia-URL: {wiki_url}\n"
                f"  Wiki-Absatz: {wiki_paragraph_short}\n"
                f"  Wiki-Kategorien: {wiki_categories_short}\n"
                f"----\n"
            )
            aggregated_prompt += entry_text


        # Fuegen Sie den Abschluss des Prompts hinzu.
        aggregated_prompt += "--------------------\nBitte nur die 'Eintrag X: Antwort'-Zeilen ausgeben."

        # Optional: Token zaehlen fuer den Prompt.
        # try: prompt_tokens = token_count(aggregated_prompt, model=getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo')); self.logger.debug(f"Geschaetzt Prompt-Tokens fuer Batch: {prompt_tokens}.");
        # except Exception as e_tc: self.logger.debug(f"Fehler beim Token-Zaehlen: {e_tc}");


        # --- ChatGPT Aufruf ---
        # call_openai_chat (Block 8) nutzt den retry_on_failure Decorator und wirft bei endgueltigem Fehler eine Exception.
        # Der retry_on_failure Decorator auf dieser Funktion faengt die Exception
        # von call_openai_chat und fuehrt die Retries fuer die GESAMTE Batch-Funktion durch.
        chat_response = None
        try:
            # Rufe die zentrale OpenAI Chat API Funktion auf (Block 8).
            # Standard Temperatur 0.0 fuer Klassifizierung/Verifizierung.
            chat_response = call_openai_chat(aggregated_prompt, temperature=0.0)
            # Wenn call_openai_chat erfolgreich ist, gibt es den String zurueck.
            # Exceptions werden nach Retries von call_openai_chat geworfen und vom aeusseren retry_on_failure dieser Funktion gefangen.

            if not chat_response:
                 # Dieser Fall sollte nach der Aenderung in call_openai_chat (wirft Exception) nicht mehr auftreten.
                 self.logger.error("call_openai_chat gab unerwarteterweise None zurueck fuer Wiki-Verifizierungs-Batch.") # <<< GEÄNDERT
                 # Werfen Sie eine spezifische Exception, damit der aeussere Decorator sie faengt.
                 raise openai.error.APIError("Keine Antwort von OpenAI erhalten fuer Wiki-Verifizierungs-Batch.")


        except Exception as e:
            # Wenn call_openai_chat oder der aeussere retry_on_failure eine Exception wirft (nach Retries)
            # Die Exception wird hier gefangen, bevor sie an den Aufrufer (process_verification_batch) weitergeleitet wird.
            self.logger.error(f"Endgueltiger FEHLER beim OpenAI-Batch-Aufruf fuer Wiki-Verifizierung (innerhalb Batch Decorator): {e}") # <<< GEÄNDERT
            # Logge den Traceback
            self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
            # Geben Sie ein Dictionary zurueck, das signalisiert, dass fuer alle Zeilen im Batch ein Fehler aufgetreten ist
            return {item['row_num']: f"FEHLER API: {str(e)[:100]}" for item in batch_data}


        # --- Antwort parsen ---
        answers = {} # Initialisieren Sie das Ergebnis-Dictionary
        # Liste der Zeilennummern, die im ursprünglichen Batch angefragt wurden
        original_batch_row_nums = {item['row_num'] for item in batch_data}
        lines = chat_response.strip().split('\n')
        parsed_count = 0
        for line in lines:
            # Matcht "Eintrag <Zeilennummer>:" und den Rest der Zeile
            match = re.match(r"Eintrag (\d+): (.*)", line.strip())
            if match:
                row_num = int(match.group(1))
                answer_text = match.group(2).strip()
                # Pruefen Sie, ob die Zeilennummer im urspruenglichen Batch angefragt wurde
                if row_num in original_batch_row_nums:
                     answers[row_num] = answer_text
                     parsed_count += 1
                # else: self.logger.debug(f"Warnung: Antwort fuer unerwartete Zeilennummer {row_num} im Batch erhalten: {answer_text[:100]}...") # Zu viel Laerm (gekuerzt loggen)

        # Logge das Ergebnis des Parsens
        self.logger.debug(f"OpenAI-Batch-Antwort geparst: {parsed_count} von {len(original_batch_row_nums)} Zeilen erfolgreich zugeordnet.") # <<< GEÄNDERT

        # Fuegen Sie einen Fehlerwert fuer Zeilen hinzu, die nicht geparst werden konnten (z.B. falsches Antwortformat)
        if parsed_count < len(original_batch_row_nums):
             self.logger.warning(f"Nicht alle Zeilen aus dem Batch ({len(original_batch_row_nums)}) konnten in der OpenAI-Antwort ({len(lines)} Zeilen) geparst werden.") # <<< GEÄNDERT
             # Logge den Anfang der unvollstaendigen Antwort auf Debug
             self.logger.debug(f"Unerwartete Antwortteile (erste 500 Zeichen): {chat_response[:500]}") # <<< GEÄNDERT
             for row_num in original_batch_row_nums:
                 if row_num not in answers:
                      answers[row_num] = "FEHLER: Antwort nicht geparst"


        # Die 'answers' Dictionary enthaelt nun Ergebnisse fuer alle Zeilen, entweder geparst oder mit einem Fehlerstring.
        return answers # Rueckgabe des Dictionarys mit Ergebnissen oder Fehlern


    # --- Methode fuer den Wiki-Verifizierungs-Batchmodus (AX) ---
    # Diese Methode koordiniert die Auswahl der Zeilen, die Batch-Verarbeitung durch OpenAI,
    # und das Schreiben der Ergebnisse (S, T, U, V-Y, AX, AP) ins Sheet.
    # Basierend auf process_verification_only und _process_batch aus Teil 8.
    # Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter, _process_verification_openai_batch (derselben Block).
    # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), datetime, time.
    # Nutzt die uebergeordnete sheet_handler Instanz (Block 14).
    def process_verification_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None):
        """
        Batch-Prozess nur fuer Wikipedia-Verifizierung (Spalten S-U, V-Y werden geleert).
        Laedt Daten neu, prueft fuer jede Zeile im Bereich, ob Timestamp AX (Wiki Verif.)
        bereits gesetzt ist, ob eine Wiki URL (M) vorhanden ist und ob Status S
        nicht bereits 'OK', 'X (URL Copied)' oder 'X (Invalid Suggestion)' ist.
        Setzt AX + AP fuer bearbeitete Zeilen und schreibt S-U in Batches.

        Args:
            start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AX).
            end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
            limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht uebersprungener) Zeilen. Defaults to None (Unbegrenzt).
        """
        # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
        # Logge die Konfiguration des Batch-Laufs
        self.logger.info(f"Starte Wikipedia-Verifizierungsmodus (Batch S-U, AX). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT


        # --- Daten laden und Startzeile ermitteln ---
        # Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt
        if start_sheet_row is None:
             self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AX...") # <<< GEÄNDERT
             # Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AX (Block 1 Column Map).
             # Standardmaessig ab Zeile 7
             start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Wiki Verif. Timestamp", min_sheet_row=7)

             # Wenn get_start_row_index -1 zurueckgibt (Fehler)
             if start_data_index_no_header == -1:
                  self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche Batch ab.") # <<< GEÄNDERT
                  return # Beende die Methode

             # Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten Daten-Index
             start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut
             self.logger.info(f"Automatisch ermittelte Startzeile (erste leere AX Zelle): {start_sheet_row}") # <<< GEÄNDERT
        else:
            # Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein.
            # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2).
            if not self.sheet_handler.load_data():
                 self.logger.error("FEHLER beim Laden der Daten fuer process_verification_batch.") # <<< GEÄNDERT
                 return # Beende die Methode, wenn das Laden fehlschlaegt


        # Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler.
        all_data = self.sheet_handler.get_all_data_with_headers()
        # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14).
        header_rows = self.sheet_handler._header_rows
        total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet

        # Berechne Endzeile, wenn nicht manuell gesetzt
        if end_sheet_row is None:
             end_sheet_row = total_sheet_rows # Bis zur letzten Zeile

        # Logge den verarbeitungsbereich
        self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT

        # Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen)
        if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
             self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
             return # Beende die Methode, wenn der Bereich leer ist


        # --- Indizes und Buchstaben ---
        # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind
        required_keys = [
            "Wiki Verif. Timestamp", "Wiki URL", "Chat Wiki Konsistenzpruefung", # Pruefkriterien / Timestamp (AX, M, S)
            "CRM Name", "CRM Beschreibung", "Wiki Absatz", "Wiki Kategorien",   # Daten fuer Prompt (B, F, N, R)
            "Chat Begruendung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", # Ergebnisspalten (T, U)
            "Begruendung bei Abweichung", "Chat Begruendung Abweichung Branche", # Spalten V-Y zum Leeren
            "Wikipedia Timestamp", "Timestamp letzte Pruefung", # Spalten AN, AO zum Leeren
            "Version", "SerpAPI Wiki Search Timestamp" # Spalten AP, AY zum Leeren
        ]
        # Erstellen Sie ein Dictionary mit Schluesseln und Indizes
        col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}

        # Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden
        if None in col_indices.values():
             missing = [k for k, v in col_indices.items() if v is None]
             self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_verification_batch: {missing}. Breche ab.") # <<< GEÄNDERT
             return # Beende die Methode bei kritischem Fehler


        # Ermitteln Sie die Spaltenbuchstaben fuer Updates und Leerung (nutzt interne Helfer _get_col_letter Block 14)
        ts_ax_letter = self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"] + 1) # Timestamp zu setzen (AX)
        s_letter = self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzpruefung"] + 1) # Status S
        t_letter = self.sheet_handler._get_col_letter(col_indices["Chat Begruendung Wiki Inkonsistenz"] + 1) # Begruendung T
        u_letter = self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"] + 1) # Vorschlag U

        # Spalten V-Y leeren (werden in diesem Modus nicht neu befuellt).
        # V ist Begruendung bei Abweichung (von Wiki-URL Pruefung CRM vs Wiki).
        # Y ist Begruendung Abweichung Branche (von Chat).
        v_idx = col_indices["Begruendung bei Abweichung"]
        y_idx = col_indices["Chat Begruendung Abweichung Branche"] # Block 1 Column Map
        # Erstellen Sie den Bereichsnamen (z.B. "V:Y")
        v_letter = self.sheet_handler._get_col_letter(v_idx + 1)
        y_letter = self.sheet_handler._get_col_letter(y_idx + 1)
        v_y_range_letter = f'{v_letter}:{y_letter}' # z.B. V:Y
        # Erstellen Sie eine Liste von leeren Strings fuer diesen Bereich
        empty_vy_values = [''] * (y_idx - v_idx + 1) # Anzahl der Spalten = Y_Index - V_Index + 1


        # Timestamps AN, AO, AP, AY leeren.
        # Diese werden von anderen Schritten gesetzt und sollen hier zurueckgesetzt werden,
        # um sicherzustellen, dass die Zeile bei Bedarf von diesen anderen Schritten erneut bearbeitet wird.
        an_letter = self.sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"] + 1) # AN (Wiki Extraction TS)
        ao_letter = self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Pruefung"] + 1) # AO (Chat Evaluation TS)
        ap_letter = self.sheet_handler._get_col_letter(col_indices["Version"] + 1) # AP (Version)
        ay_letter = self.sheet_handler._get_col_letter(col_indices["SerpAPI Wiki Search Timestamp"] + 1) # AY (SerpAPI Wiki TS)


        # --- Verarbeitung ---
        # Holen Sie die Batch-Groesse fuer OpenAI-Aufrufe aus Config (Block 1)
        openai_batch_size = getattr(Config, 'PROCESSING_BATCH_SIZE', 20) # Nutzt dieselbe Batch-Groesse wie Scraping/Summarization
        # Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1)
        update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)


        current_openai_batch_data = [] # Daten fuer den aktuellen OpenAI Batch (Liste von Dicts)
        rows_in_current_openai_batch = [] # 1-basierte Zeilennummern im aktuellen OpenAI Batch
        all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts)


        processed_count = 0 # Zaehlt Zeilen, die fuer die Verarbeitung in Frage kommen und in den Batch aufgenommen werden (im Rahmen des Limits).
        skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (wegen Status, fehlender Daten etc.).
        skipped_no_wiki_url = 0 # Zaehlt Zeilen, die speziell wegen fehlender M-URL uebersprungen wurden.


        # Iteriere ueber die Sheet-Zeilen im definierten Bereich (1-basierte Sheet-Zeilennummer)
        for i in range(start_sheet_row, end_sheet_row + 1):
            row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
            # Pruefen Sie, ob das Ende des Sheets erreicht wurde
            if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht


            row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile


            # Stellen Sie sicher, dass die Zeile nicht leer ist (mindestens Name vorhanden)
            # Nutzt interne Helfer _get_cell_value_safe
            company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map
            if not company_name:
                self.logger.debug(f"Zeile {i}: Uebersprungen (Kein Firmenname in Spalte B).") # <<< GEÄNDERT
                skipped_count += 1 # Zaehlen als uebersprungen
                continue # Springe zur naechsten Zeile

            # --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
            # Kriterium: Wiki Verif. Timestamp (AX) ist leer
            # UND Wiki URL (M) ist gefuellt und gueltig aussehend (nicht k.A., Fehler etc.)
            # UND Status S ist NICHT bereits in einem Endzustand (OK, X (UPDATED/COPIED/INVALID)).

            # Holen Sie die Werte aus den entsprechenden Spalten (nutzt interne Helfer)
            ax_value = self._get_cell_value_safe(row, "Wiki Verif. Timestamp").strip() # Block 1 Column Map
            m_value = self._get_cell_value_safe(row, "Wiki URL").strip() # Block 1 Column Map
            s_value_upper = self._get_cell_value_safe(row, "Chat Wiki Konsistenzpruefung").strip().upper() # Block 1 Column Map

            # Pruefen Sie, ob die Wiki URL (M) gueltig aussieht
            is_wiki_url_valid_looking = m_value and isinstance(m_value, str) and "wikipedia.org/wiki/" in m_value.lower() and m_value.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"] # Fuege "http:" hinzu basierend auf Log


            # Definieren Sie die Endzustaende von Status S (Grossbuchstaben)
            s_end_states = ["OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)"]
            # Pruefen Sie, ob Status S in einem Endzustand ist
            is_s_in_endstate = s_value_upper in s_end_states # Bugfix: Korrekte Zuweisung


            # Verarbeitung ist noetig, wenn AX leer UND M gefuellt/gueltig aussieht UND S NICHT im Endzustand ist.
            processing_needed_for_row = not ax_value and is_wiki_url_valid_looking and not is_s_in_endstate


            # Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
            log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
            if log_check:
                 self.logger.debug(f"Zeile {i} ({company_name[:50]}... Wiki Verif. Check): AX leer? {not ax_value}, M gueltig? {is_wiki_url_valid_looking}, S ('{s_value_upper}') Endzustand? {is_s_in_endstate}. Benötigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT


            # Wenn die Verarbeitung fuer diese Zeile nicht noetig ist
            if not processing_needed_for_row:
                skipped_count += 1 # Zaehlen als uebersprungene Zeile
                # Zaehlen Sie separat, wenn die Zeile speziell wegen fehlender M-URL uebersprungen wurde
                if not is_wiki_url_valid_looking: skipped_no_wiki_url += 1
                continue # Springe zur naechsten Zeile


            # --- Wenn Verarbeitung noetig: Fuege zur Batch-Liste fuer OpenAI hinzu ---
            processed_count += 1 # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im Rahmen des Limits zaehlen)

            # Pruefe das Limit fuer verarbeitete Zeilen
            if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit:
                 # Wenn das Limit erreicht ist und es ein positives Limit gibt
                 self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_verification_batch erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
                 break # Brich die Schleife ab


            # Sammle die benoetigten Daten fuer den OpenAI Prompt (Block 26 - _process_verification_openai_batch).
            # Diese Daten werden in einem Dictionary fuer den Batch gesammelt.
            crm_desc = self._get_cell_value_safe(row, "CRM Beschreibung") # Block 1 Column Map
            wiki_paragraph = self._get_cell_value_safe(row, "Wiki Absatz") # Block 1 Column Map
            wiki_categories = self._get_cell_value_safe(row, "Wiki Kategorien") # Block 1 Column Map


            # Fuege die Daten dieser Zeile zur aktuellen Batch-Liste fuer OpenAI hinzu
            current_openai_batch_data.append({
                'row_num': i, # Die 1-basierte Sheet-Zeilennummer
                'company_name': company_name, # Nutzt den initial geladenen Namen
                'crm_desc': crm_desc,
                'wiki_url': m_value, # Nutzt die M-URL aus dem Sheet
                'wiki_paragraph': wiki_paragraph,
                'wiki_categories': wiki_categories
            })
            # Fuege die Zeilennummer zur Liste der Zeilennummern im Batch hinzu
            rows_in_current_openai_batch.append(i)


            # --- Verarbeite den Batch, wenn voll ---
            # Pruefe, ob die aktuelle Batch-Liste die definierte Groesse erreicht hat.
            # openai_batch_size wird aus Config geholt (Block 1).
            if len(current_openai_batch_data) >= openai_batch_size:
                # Logge den Start der Batch-Verarbeitung
                batch_start_row = current_openai_batch_data[0]['row_num']
                batch_end_row = current_openai_batch_data[-1]['row_num']
                self.logger.debug(f"\n--- Starte Wiki-Verifizierungs-Batch ({len(current_openai_batch_data)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") # <<< GEÄNDERT


                # Rufe die interne Methode auf, die den OpenAI Call fuer den Batch macht.
                # _process_verification_openai_batch (Block 26) ist mit retry_on_failure dekoriert.
                # Wenn _process_verification_openai_batch eine Exception wirft (nach Retries), wird diese hier gefangen.
                batch_results = self._process_verification_openai_batch(current_openai_batch_data)
                # Ergebnisse sollten ein Dictionary {row_num: raw_chatgpt_answer} sein, auch bei Fehlern.


                # Sammle Sheet Updates basierend auf den Batch-Ergebnissen.
                # Setze immer den Timestamp AX und die Werte in S, T, U und V-Y.
                # Der aktuelle Zeitstempel fuer den Batch
                current_batch_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                batch_sheet_updates = [] # Updates fuer DIESEN spezifischen Batch von Zeilen


                # Iteriere ueber die Zeilennummern, die in DIESEM OpenAI Batch waren
                for row_num in rows_in_current_openai_batch:
                    # Hole das Ergebnis fuer diese Zeile aus dem Ergebnis-Dictionary.
                    # Fallback auf einen Fehlerstring, wenn das Ergebnis fehlt (sollte nicht passieren, wenn _process_verification_openai_batch korrekt ist).
                    answer = batch_results.get(row_num, "FEHLER: Batch-Ergebnis fehlt")
                    # self.logger.debug(f"Zeile {row_num} Verifizierungsantwort: '{answer[:100]}...'") # Zu viel Laerm (gekuerzt)


                    # Logik zur Bestimmung der Werte fuer S, T, U basierend auf 'answer' (aehnlich wie in altem _process_batch)
                    wiki_confirm, alt_article, wiki_explanation = "", "", "" # Initialisiere mit leeren Strings

                    # Pruefe auf Standard-Antworten und Fehler-Antworten
                    if isinstance(answer, str) and answer.upper() == "OK":
                        wiki_confirm = "OK"
                        wiki_explanation = "Passt laut KI zur Firma." # Standard Begruendung bei OK
                    elif isinstance(answer, str) and answer.startswith("X |"):
                         # Parse die Antwort im Format "X | <Detail> | <Begruendung>"
                         parts = answer.split("|", 2) # Teile maximal in 3 Teile
                         wiki_confirm = "X" # Status ist X
                         if len(parts) > 1:
                              detail = parts[1].strip() # Zweiter Teil ist Detail (Alternative URL oder "Kein passender Artikel gefunden")
                              if detail.lower().startswith("alternativer artikel:"):
                                   alt_article = detail.split(":", 1)[1].strip() # Extrahiere URL
                              elif detail.lower() == "kein passender artikel gefunden":
                                   alt_article = detail # Text "Kein passender Artikel gefunden"
                              else:
                                   alt_article = detail # Unbekanntes Detail

                         if len(parts) > 2:
                              reason_part = parts[2].strip() # Dritter Teil ist Begruendung
                              if reason_part.lower().startswith("begruendung:"):
                                   wiki_explanation = reason_part.split(":", 1)[1].strip() # Extrahiere Begruendungstext
                              else:
                                   wiki_explanation = reason_part # Unbekannte Begruendung

                         # Fuege ggf. den rohen Antworttext zur Begruendung hinzu, wenn Parsing unvollstaendig war
                         if not alt_article or not wiki_explanation:
                              wiki_explanation += f" (Rohantwort: {answer[:100]}...)"


                    elif isinstance(answer, str) and answer.startswith("FEHLER"):
                         # Wenn die Batch-Verarbeitung einen Fehler zurueckgegeben hat
                         wiki_confirm = "FEHLER"
                         wiki_explanation = answer # Fehlermeldung in Begruendung schreiben
                         alt_article = "Siehe Begruendung" # Verweis auf Begruendung

                    else: # Unerwartetes Format der Antwort (weder OK noch X | noch FEHLER)
                        wiki_confirm = "?" # Setze Status auf unbekannt
                        wiki_explanation = f"Unerwartetes Format: {str(answer)[:100]}..." # Speichere Anfang der Antwort in Begruendung (gekuerzt)
                        alt_article = "Siehe Begruendung" # Verweis auf Begruendung

                    # Spalten V-Y (Begruendung bei Abweichung etc.) werden in diesem Modus geleert
                    # Fuer jede Zeile im Batch fuegen wir das Update hinzu.
                    # empty_vy_values wurde oben vorbereitet.
                    v_y_values = empty_vy_values # Liste von leeren Strings
                    # Fuege Update zum Leeren von V-Y hinzu, falls Index gefunden wurde
                    if v_y_range_letter: # Pruefe, ob der Bereichsname ermittelt werden konnte
                         batch_sheet_updates.append({'range': f'{v_letter}{row_num}:{y_letter}{row_num}', 'values': [v_y_values]}) # Block 1 Column Map, interne Helfer


                    # Fuege Updates fuer S, T, U und AX hinzu (nutzt interne Helfer)
                    batch_sheet_updates.append({'range': f'{s_letter}{row_num}', 'values': [[wiki_confirm]]}) # Block 1 Column Map
                    batch_sheet_updates.append({'range': f'{t_letter}{row_num}', 'values': [[alt_article]]}) # Block 1 Column Map
                    batch_sheet_updates.append({'range': f'{u_letter}{row_num}', 'values': [[wiki_explanation]]}) # Block 1 Column Map
                    # Setze AX Timestamp fuer diese Zeile
                    batch_sheet_updates.append({'range': f'{ts_ax_letter}{row_num}', 'values': [[current_batch_timestamp]]}) # Block 1 Column Map


                # --- Sende gesammelte Updates fuer diesen Batch ---
                # Sammle die Updates fuer diesen Batch in der globalen Liste.
                # all_sheet_updates.extend(batch_sheet_updates) # Nicht hier sammeln, sondern direkt senden

                # Sende die gesammelten Updates fuer DIESEN Batch sofort.
                if batch_sheet_updates:
                    self.logger.debug(f"  Sende Sheet-Update fuer {len(rows_in_current_openai_batch)} Zeilen ({len(batch_sheet_updates)} Zellen) dieses Batches...") # <<< GEÄNDERT
                    # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
                    # Wenn es fehlschlaegt, wird es intern geloggt.
                    success = self.sheet_handler.batch_update_cells(batch_sheet_updates)
                    if success:
                        self.logger.info(f"  Sheet-Update fuer Wiki-Verifizierungs-Batch Zeilen {batch_start_row}-{batch_end_row} erfolgreich.") # <<< GEÄNDERT
                    # Der Fehlerfall wird von batch_update_cells geloggt

                # Setze Batch-Listen zurueck fuer die naechste Iteration
                current_openai_batch_data = []
                rows_in_current_openai_batch = []

                # Pause nach jedem OpenAI Batch (nutzt Config Block 1).
                # Dies ist wichtig, um Rate Limits zu vermeiden.
                # Nutze Config.RETRY_DELAY, ggf. kuerzer, da es ein Batch war
                pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.5 # 50% der Retry-Wartezeit
                self.logger.debug(f"--- Batch Zeilen {batch_start_row}-{batch_end_row} abgeschlossen. Warte {pause_duration:.2f}s vor naechstem Batch ---") # <<< GEÄNDERT
                time.sleep(pause_duration)


        # --- Verarbeitung des letzten unvollstaendigen Batches nach der Schleife ---
        # Wenn nach der Hauptschleife noch Tasks in der Batch-Liste sind
        if current_openai_batch_data:
             # Logge den Start des finalen Batches
             batch_start_row = current_openai_batch_data[0]['row_num']
             batch_end_row = current_openai_batch_data[-1]['row_num']
             self.logger.debug(f"\n--- Starte FINALEN Wiki-Verifizierungs-Batch ({len(current_openai_batch_data)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") # <<< GEÄNDERT

             # Rufe die interne Methode auf, die den OpenAI Call macht
             batch_results = self._process_verification_openai_batch(current_openai_batch_data)
             # Ergebnisse sollten ein Dictionary {row_num: raw_chatgpt_answer} sein, auch bei Fehlern.

             # Sammle Sheet Updates (S, T, U, V-Y, AX) fuer diesen finalen Batch
             current_batch_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
             batch_sheet_updates = [] # Updates fuer DIESEN spezifischen Batch von Zeilen

             # Iteriere ueber die Zeilennummern, die in DIESEM finalen OpenAI Batch waren
             for row_num in rows_in_current_openai_batch:
                 # Hole das Ergebnis fuer diese Zeile aus dem Ergebnis-Dictionary.
                 answer = batch_results.get(row_num, "FEHLER: Batch-Ergebnis fehlt") # Fallback

                 # Logik zur Bestimmung der Werte fuer S, T, U basierend auf 'answer'
                 wiki_confirm, alt_article, wiki_explanation = "", "", ""
                 # Leere V-Y Spalten
                 v_y_values = empty_vy_values # Liste von leeren Strings
                 if isinstance(answer, str) and answer.upper() == "OK": wiki_confirm = "OK"; wiki_explanation = "Passt laut KI zur Firma."
                 elif isinstance(answer, str) and answer.startswith("X |"):
                      parts = answer.split("|", 2); wiki_confirm = "X"
                      if len(parts) > 1: detail = parts[1].strip(); alt_article = detail.split(":", 1)[1].strip() if detail.lower().startswith("alternativer artikel:") else detail
                      if len(parts) > 2: reason_part = parts[2].strip(); wiki_explanation = reason_part.split(":", 1)[1].strip() if reason_part.lower().startswith("begruendung:") else reason_part
                      if not alt_article or not wiki_explanation: wiki_explanation += f" (Rohantwort: {answer[:100]}...)"
                 elif isinstance(answer, str) and answer.startswith("FEHLER"): wiki_confirm = "FEHLER"; wiki_explanation = answer; alt_article = "Siehe Begruendung"
                 else: wiki_confirm = "?"; wiki_explanation = f"Unerwartetes Format: {str(answer)[:100]}..."; alt_article = "Siehe Begruendung"


                 # Fuege Updates fuer S, T, U und AX hinzu
                 batch_sheet_updates.append({'range': f'{s_letter}{row_num}', 'values': [[wiki_confirm]]}) # Block 1 Column Map
                 batch_sheet_updates.append({'range': f'{t_letter}{row_num}', 'values': [[alt_article]]}) # Block 1 Column Map
                 batch_sheet_updates.append({'range': f'{u_letter}{row_num}', 'values': [[wiki_explanation]]}) # Block 1 Column Map
                 # Setze AX Timestamp
                 batch_sheet_updates.append({'range': f'{ts_ax_letter}{row_num}', 'values': [[current_batch_timestamp]]}) # Block 1 Column Map

                 # Fuege Update zum Leeren von V-Y hinzu, falls Index gefunden wurde
                 if v_y_range_letter:
                      batch_sheet_updates.append({'range': f'{v_letter}{row_num}:{y_letter}{row_num}', 'values': [v_y_values]}) # Block 1 Column Map, interne Helfer


             # Sende die gesammelten Updates fuer DIESEN finalen Batch.
             if batch_sheet_updates:
                  self.logger.debug(f"  Sende FINALES Sheet-Update fuer {len(rows_in_current_openai_batch)} Zeilen ({len(batch_sheet_updates)} Zellen)...") # <<< GEÄNDERT
                  # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
                  success = self.sheet_handler.batch_update_cells(batch_sheet_updates)
                  if success:
                       self.logger.info(f"  FINALES Sheet-Update fuer Wiki-Verifizierungs-Batch erfolgreich.") # <<< GEÄNDERT
                   # Der Fehlerfall wird von batch_update_cells geloggt


        # Logge den Abschluss des Modus
        self.logger.info(f"Wikipedia-Verifizierungs-Batch abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {skipped_count} Zeilen uebersprungen ({skipped_no_wiki_url} wegen fehlender M-URL).") # <<< GEÄNDERT
        # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt.


    # ==============================================================================
    # Ende DataProcessor Klasse Batch: Wiki Verification Block
    # ==============================================================================

    # ==========================================================================
    # === Batch Processing Methods =============================================
    # ==========================================================================

    # --- Worker Funktion für paralleles Website Scraping (intern) ---
    # Wird von process_website_scraping_batch aufgerufen
    def _scrape_raw_text_task(self, task_info, get_website_raw_func):
        """
        Scrapt den Rohtext einer Website in einem separaten Thread.
        Wird vom ThreadPoolExecutor in process_website_scraping_batch aufgerufen.
        Nutzt die uebergebene Funktion zum Abrufen des Rohtexts.

        Args:
            task_info (dict): Enthält {'row_num': int, 'url': str}.
            get_website_raw_func (function): Die Funktion zum Abrufen des Website-Rohtexts (sollte die globale get_website_raw sein).

        Returns:
            dict: Enthält {'row_num': int, 'raw_text': str, 'error': str}.
        """
        # Logger für diese Funktion holen (da sie in einem Thread läuft)
        logger = logging.getLogger(__name__ + ".scrape_worker")

        row_num = task_info['row_num']
        url = task_info['url']
        raw_text = "k.A."
        error = None

        try:
            # RUFT die uebergebene Funktion zum Abrufen des Rohtexts auf.
            # Der retry_on_failure Decorator auf get_website_raw_func (der hoffentlich get_website_raw ist)
            # behandelt Retries und die meisten Fehler.
            raw_text = get_website_raw_func(url) # <<< Ruft die uebergebene Funktion auf

            # Wenn die Funktion einen Fehler loggt und einen Fehlerstring im Ergebnis zurueckgibt,
            # wird dies hier als Fehler im Task markiert.
            if isinstance(raw_text, str) and (raw_text.startswith("k.A. (Fehler") or raw_text.startswith("FEHLER:")):
                 error = f"Scraping Fehler (Details im Rohtext): {raw_text[:100]}..."
                 # Der Fehler wurde bereits in get_website_raw geloggt, kein weiteres Logging hier noetig.
                 # Das raw_text selbst enthaelt den Fehlerstring.

            elif not isinstance(raw_text, str) or not raw_text.strip():
                 # Wenn die Funktion keinen String oder einen leeren String zurueckgibt
                 error = "Scraping Task Fehler: Funktion gab keinen gueltigen String zurueck."
                 raw_text = "k.A. (Extraktion fehlgeschlagen)" # Standard-Fehlerwert

        except Exception as e:
            # Dieser Block sollte jetzt sehr selten erreicht werden, da die uebergegebene Funktion
            # mit retry_on_failure die meisten Fehler abfangen sollte.
            # Wenn eine Exception hier durchkommt, ist es ein sehr unerwarteter Fehler im Task-Handling selbst.
            error = f"Unerwarteter Fehler im Scraping Task Zeile {row_num} ({url[:100]}): {type(e).__name__} - {e}" # Gekuerzt loggen
            logger.error(error) # Loggen Sie diesen unerwarteten Fehler
            raw_text = "k.A. (Unerwarteter Fehler Task)" # Setze einen spezifischen Fehlerwert


        # logger.debug(f"Scraping Task Zeile {row_num} abgeschlossen. Textlaenge: {len(str(raw_text))}.") # Zu viel Laerm im Debug
        return {"row_num": row_num, "raw_text": raw_text, "error": error}

    # --- Methode fuer den Website-Scraping-Batchmodus (AR) ---
    # Diese Methode verarbeitet Zeilen, bei denen AR leer ist, um den Rohtext zu scrapen.
    # Sie nutzt einen ThreadPoolExecutor und ruft die interne Worker-Funktion auf.
    # Basierend auf process_website_batch aus Teil 9.
    # Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter, _scrape_raw_text_task.
    # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), datetime, time,
    #                      concurrent.futures, get_website_raw (Block 11).
    # Nutzt die uebergeordnete sheet_handler Instanz (Block 14).
    def process_website_scraping_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None):
        """
        Batch-Prozess NUR fuer Website-Scraping (Rohtext AR).
        Laedt Daten neu, prueft Spalte AR auf Inhalt ('', 'k.A.', etc.) und ueberspringt Zeilen mit Inhalt.
        Setzt AR + AT + AP fuer bearbeitete Zeilen. Sendet Updates gebuendelt.

        Args:
            start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AT).
            end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
            limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht uebersprungener) Zeilen. Defaults to None (Unbegrenzt).
        """
        # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
        # Logge die Konfiguration des Batch-Laufs
        self.logger.info(f"Starte Website-Scraping (Batch AR, AT, AP). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT


        # --- Daten laden und Startzeile ermitteln ---
        # Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt
        if start_sheet_row is None:
             self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AT...") # <<< GEÄNDERT
             # Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AT (Block 1 Column Map).
             # Standardmaessig ab Zeile 7
             start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Website Scrape Timestamp", min_sheet_row=7)

             # Wenn get_start_row_index -1 zurueckgibt (Fehler)
             if start_data_index_no_header == -1:
                  self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche Batch ab.") # <<< GEÄNDERT
                  return # Beende die Methode

             # Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten Daten-Index
             start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut
             self.logger.info(f"Automatisch ermittelte Startzeile (erste leere AT Zelle): {start_sheet_row}") # <<< GEÄNDERT
        else:
            # Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein.
            # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2).
            if not self.sheet_handler.load_data():
                 self.logger.error("FEHLER beim Laden der Daten fuer process_website_scraping_batch.") # <<< GEÄNDERT
                 return # Beende die Methode, wenn das Laden fehlschlaegt


        # Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler.
        all_data = self.sheet_handler.get_all_data_with_headers()
        # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14).
        header_rows = self.sheet_handler._header_rows
        total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet


        # Berechne Endzeile, wenn nicht manuell gesetzt
        if end_sheet_row is None:
             end_sheet_row = total_sheet_rows # Bis zur letzten Zeile

        # Logge den verarbeitungsbereich
        self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT

        # Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen)
        if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
             self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
             return # Beende die Methode, wenn der Bereich leer ist


        # --- Indizes und Buchstaben ---
        # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind
        required_keys = [
            "Website Rohtext", "CRM Website", "Version", "Website Scrape Timestamp", "CRM Name" # AR, D, AP, AT, B
        ]
        # Erstellen Sie ein Dictionary mit Schluesseln und Indizes
        col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}

        # Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden
        if None in col_indices.values():
            missing = [k for k, v in col_indices.items() if v is None]
            self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_website_scraping_batch: {missing}. Breche ab.") # <<< GEÄNDERT
            return # Beende die Methode bei kritischem Fehler

        # Ermitteln Sie die Indizes und Buchstaben fuer Updates (AR, AT, AP)
        rohtext_col_idx = col_indices["Website Rohtext"]
        website_col_idx = col_indices["CRM Website"]
        version_col_idx = col_indices["Version"]
        timestamp_col_idx = col_indices["Website Scrape Timestamp"]
        name_col_idx = col_indices["CRM Name"]

        rohtext_col_letter = self.sheet_handler._get_col_letter(rohtext_col_idx + 1) # Block 14 _get_col_letter
        version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1)
        timestamp_col_letter = self.sheet_handler._get_col_letter(timestamp_col_idx + 1)


        # --- Hauptlogik: Iteriere und sammle Batches ---
        # Holen Sie die Batch-Groesse fuer Verarbeitung (Threading) aus Config (Block 1)
        processing_batch_size = getattr(Config, 'PROCESSING_BATCH_SIZE', 20)
        # Holen Sie die maximale Anzahl Worker aus Config (Block 1)
        max_scraping_workers = getattr(Config, 'MAX_SCRAPING_WORKERS', 10)
        # Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1)
        update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)


        tasks_for_processing_batch = [] # Tasks fuer den aktuellen Scraping-Batch (Liste von Dicts)
        rows_in_current_scraping_batch = [] # 1-basierte Zeilennummern im aktuellen Batch
        all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts)


        processed_count = 0 # Zaehlt Zeilen, die fuer die Verarbeitung in Frage kommen und in den Batch aufgenommen werden (im Rahmen des Limits).
        skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (wegen Inhalt oder fehlender URL).
        skipped_no_url = 0 # Zaehlt Zeilen, die speziell wegen fehlender URL uebersprungen wurden.


        # Iteriere ueber die Sheet-Zeilen im definierten Bereich (1-basierte Sheet-Zeilennummer)
        for i in range(start_sheet_row, end_sheet_row + 1):
            row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
            # Pruefen Sie, ob das Ende des Sheets erreicht wurde
            if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht


            row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile


            # Stellen Sie sicher, dass die Zeile nicht leer ist
            if not any(cell and isinstance(cell, str) and cell.strip() for cell in row):
                 #self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug
                 skipped_count += 1 # Zaehlen als uebersprungen
                 continue # Springe zur naechsten Zeile


            # --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
            # Kriterium: Website Rohtext (AR) ist leer oder ein Standard-Fehlerwert.
            # UND Website URL (D) ist vorhanden und gueltig aussehend.

            # Holen Sie den Wert aus Spalte AR (Website Rohtext) (nutzt interne Helfer _get_cell_value_safe)
            cell_value_ar = self._get_cell_value_safe(row, "Website Rohtext") # Block 1 Column Map
            # Pruefen Sie, ob AR leer ist oder einen Standard-Fehlerwert enthaelt.
            ar_is_empty_or_default = not cell_value_ar or (isinstance(cell_value_ar, str) and str(cell_value_ar).strip().lower() in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"])


            # Holen Sie den Wert aus Spalte D (CRM Website) (nutzt interne Helfer _get_cell_value_safe)
            website_url = self._get_cell_value_safe(row, "CRM Website").strip() # Block 1 Column Map
            # Pruefen Sie, ob die Website URL (D) vorhanden und gueltig aussehend ist.
            website_url_is_valid_looking = website_url and isinstance(website_url, str) and website_url.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"] # Fuege "http:" hinzu basierend auf Log


            # Verarbeitung ist noetig, wenn AR leer/default ist UND D gefuellt/gueltig aussieht.
            processing_needed_for_row = ar_is_empty_or_default and website_url_is_valid_looking


            # Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
            log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
            if log_check:
                 company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map
                 self.logger.debug(f"Zeile {i} ({company_name[:50]}... Website Scraping Check): AR leer/default? {ar_is_empty_or_default}, D gueltig? {website_url_is_valid_looking}. Benötigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT


            # Wenn die Verarbeitung fuer diese Zeile nicht noetig ist
            if not processing_needed_for_row:
                skipped_count += 1 # Zaehlen als uebersprungene Zeile
                # Zaehlen Sie speziell, wenn die Zeile wegen fehlender gueltiger URL uebersprungen wurde.
                if not website_url_is_valid_looking: skipped_no_url += 1
                continue # Springe zur naechsten Zeile


            # --- Wenn Verarbeitung noetig: Fuege zur Batch-Liste hinzu ---
            processed_count += 1 # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im Rahmen des Limits zaehlen)

            # Pruefe das Limit fuer verarbeitete Zeilen
            if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit:
                 # Wenn das Limit erreicht ist und es ein positives Limit gibt
                 self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_website_scraping_batch erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
                 break # Brich die Schleife ab


            # Fuege die benoetigten Daten fuer den Task hinzu (Zeilennummer und URL)
            tasks_for_processing_batch.append({"row_num": i, "url": website_url})
            # Fuege die Zeilennummer zur Liste der Zeilennummern im Batch hinzu
            rows_in_current_scraping_batch.append(i)


            # --- Verarbeite den Batch, wenn voll ---
            # Pruefe, ob die aktuelle Batch-Liste die definierte Groesse erreicht hat.
            # scraping_batch_size wird aus Config geholt (Block 1).
            if len(tasks_for_processing_batch) >= processing_batch_size:
                # Logge den Start der Batch-Verarbeitung
                batch_start_row = tasks_for_processing_batch[0]['row_num']
                batch_end_row = tasks_for_processing_batch[-1]['row_num']
                self.logger.debug(f"\n--- Starte Website-Scraping Batch ({len(tasks_for_processing_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") # <<< GEÄNDERT

                scraping_results = {} # Dictionary zum Speichern der Ergebnisse {row_num: raw_text}
                batch_error_count = 0 # Fehlerzaehler fuer diesen spezifischen Batch

                self.logger.debug(f"  Scrape {len(tasks_for_processing_batch)} Websites parallel (max {max_scraping_workers} worker)...") # <<< GEÄNDERT
                # Nutzt concurrent.futures.ThreadPoolExecutor fuer paralleles Scraping.
                # max_workers wird aus Config geholt (Block 1).
                with concurrent.futures.ThreadPoolExecutor(max_workers=max_scraping_workers) as executor:
                    # Map tasks to futures. Ruft die INTERNE Worker-Funktion auf.
                    # Uebergibt das task_info Dictionary und die globale Funktion get_website_raw (Block 11) als Argument.
                    future_to_task = {executor.submit(self._scrape_raw_text_task, task, get_website_raw): task for task in tasks_for_processing_batch} # <<< Korrigiert: interne Methode

                    # Verarbeite die Ergebnisse, sobald sie fertig sind.
                    for future in concurrent.futures.as_completed(future_to_task):
                        task = future_to_task[future] # Holen Sie die urspruenglichen Task-Daten (Dict)
                        try:
                            # Holen Sie das Ergebnis vom Future. Wenn die Worker-Funktion eine Exception wirft, wird diese hier gefangen.
                            result = future.result() # Ergebnis ist ein Dictionary {'row_num': ..., 'raw_text': ..., 'error': ...}
                            # Speichere das Ergebnis im scraping_results Dictionary
                            scraping_results[result['row_num']] = result['raw_text']
                            # Wenn der Worker einen Fehler gemeldet hat (z.B. durch Fehlerstring im raw_text oder error-Feld)
                            if result.get('error'):
                                batch_error_count += 1 # Erhoehe den Fehlerzaehler fuer diesen Batch

                        except Exception as exc:
                            # Dieser Block faengt unerwartete Fehler ab, die waehrend der Future-Ergebnis-Abfrage auftreten.
                            # Die meisten Fehler sollten von get_website_raws retry/logging behandelt werden.
                            row_num = task['row_num'] # Zeilennummer aus den Task-Daten
                            err_msg = f"Unerwarteter Fehler bei Ergebnisabfrage Scraping Task Zeile {row_num} ({task['url'][:100]}): {type(exc).__name__} - {exc}" # Gekuerzt loggen
                            self.logger.error(err_msg) # <<< GEÄNDERT
                            # Setze einen Standard-Fehlerwert fuer diese Zeile im Ergebnis
                            scraping_results[row_num] = "k.A. (Unerwarteter Fehler Task)"
                            batch_error_count += 1 # Erhoehe den Fehlerzaehler


                self.logger.debug(f"  Scraping fuer Batch beendet. {len(scraping_results)} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).") # <<< GEÄNDERT


                # Sammle Sheet Updates (AR, AT, AP) fuer diesen Batch.
                # Dies geschieht jetzt nach der parallelen Verarbeitung.
                if scraping_results:
                    # Aktueller Zeitstempel und Version
                    current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                    current_version = getattr(Config, 'VERSION', 'unknown') # Block 1 Config Attribut
                    batch_sheet_updates = [] # Updates fuer DIESEN spezifischen Batch von Zeilen


                    # Iteriere ueber die Zeilennummern im Batch, fuer die Ergebnisse vorliegen.
                    # Ergebnisse koennen Fehlerwerte enthalten.
                    for row_num, raw_text_res in scraping_results.items():
                        # Fuege Updates fuer AR, AT und AP hinzu (nutzt interne Helfer)
                        # AR: Roh extrahierter Text (kann auch Fehlerwert sein)
                        batch_sheet_updates.append({'range': f'{rohtext_col_letter}{row_num}', 'values': [[raw_text_res]]}) # Block 1 Column Map
                        # AT: Timestamp des Scraping-Versuchs (immer setzen, wenn versucht wurde)
                        batch_sheet_updates.append({'range': f'{timestamp_col_letter}{row_num}', 'values': [[current_timestamp]]}) # Block 1 Column Map
                        # AP: Version des Skripts
                        batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Block 1 Column Map


                    # Sammle diese Batch-Updates fuer das groessere Batch-Update am Ende oder bei Limit.
                    # update_batch_row_limit wird aus Config geholt (Block 1).
                    all_sheet_updates.extend(batch_sheet_updates)


                # Leere den Scraping-Batch fuer die naechste Iteration
                tasks_for_processing_batch = []
                rows_in_current_scraping_batch = []


                # Sende gesammelte Sheet Updates, wenn das Update-Batch-Limit erreicht ist.
                # Updates pro Zeile sind 3 (AR, AT, AP). Anzahl der Zeilen = len(all_sheet_updates) / 3.
                rows_in_update_batch = len(all_sheet_updates) // 3 # Ganzzahl-Division

                if rows_in_update_batch >= update_batch_row_limit:
                    self.logger.debug(f"  Sende gesammelte Sheet-Updates ({rows_in_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
                    # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
                    # Wenn es fehlschlaegt, wird es intern geloggt.
                    success = self.sheet_handler.batch_update_cells(all_sheet_updates)
                    if success:
                        self.logger.info(f"  Sheet-Update fuer {rows_in_update_batch} Zeilen erfolgreich.") # <<< GEÄNDERT
                    # Der Fehlerfall wird von batch_update_cells geloggt

                    # Leere die gesammelten Updates nach dem Senden.
                    all_sheet_updates = []
                    # rows_in_update_batch muss nicht explizit zurueckgesetzt werden, da es aus len(all_sheet_updates) berechnet wird.


                # Keine Pause hier nach jedem kleinen Scraping-Batch, da wir auf batch_update warten.
                # Die Pause kommt erst nach dem Batch-Update (oder am Ende des Modus).
                # time.sleep(0.1) # Optionale kurze Pause


        # --- Verarbeitung des letzten unvollstaendigen Scraping-Batches nach der Schleife ---
        # Fuehre den letzten Batch aus, wenn nach der Hauptschleife noch Tasks in der Batch-Liste sind.
        if tasks_for_processing_batch:
            # Logge den Start des finalen Batches
            batch_start_row = tasks_for_processing_batch[0]['row_num']
            batch_end_row = tasks_for_processing_batch[-1]['row_num']
            self.logger.debug(f"\n--- Starte FINALEN Website-Scraping Batch ({len(tasks_for_processing_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") # <<< GEÄNDERT

            scraping_results = {} # Dictionary fuer die Ergebnisse
            batch_error_count = 0 # Fehlerzaehler

            self.logger.debug(f"  Scrape {len(tasks_for_processing_batch)} Websites parallel (max {max_scraping_workers} worker)...") # <<< GEÄNDERT
            with concurrent.futures.ThreadPoolExecutor(max_workers=max_scraping_workers) as executor:
                 # Map tasks to futures. Ruft die INTERNE Worker-Funktion auf.
                 future_to_task = {executor.submit(self._scrape_raw_text_task, task, get_website_raw): task for task in tasks_for_processing_batch} # <<< Korrigiert: interne Methode

                 # Verarbeite die Ergebnisse
                 for future in concurrent.futures.as_completed(future_to_task):
                     task = future_to_task[future] # Holen Sie die urspruenglichen Task-Daten
                     try:
                         result = future.result() # Holen Sie das Ergebnis
                         scraping_results[result['row_num']] = result['raw_text']
                         # Pruefe, ob der Worker einen Fehler gemeldet hat
                         if result.get('error'): batch_error_count += 1
                     except Exception as exc:
                         # Faengt unerwartete Fehler bei der Ergebnisabfrage ab
                         row_num = task['row_num']
                         err_msg = f"Unerwarteter Fehler bei Ergebnisabfrage Scraping Task Zeile {row_num} ({task['url'][:100]}): {type(exc).__name__} - {exc}" # Gekuerzt loggen
                         self.logger.error(err_msg) # <<< GEÄNDERT
                         # Setze einen Standard-Fehlerwert
                         scraping_results[row_num] = "k.A. (Unerwarteter Fehler Task)"
                         batch_error_count += 1


            self.logger.debug(f"  FINALER Scraping Batch beendet. {len(scraping_results)} Ergebnisse erhalten ({batch_error_count} Fehler).") # <<< GEÄNDERT

            # Sammle Sheet Updates (AR, AT, AP) fuer diesen finalen Batch.
            if scraping_results:
                current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                current_version = getattr(Config, 'VERSION', 'unknown') # Block 1 Config Attribut
                batch_sheet_updates = [] # Updates fuer diesen spezifischen Batch
                # Iteriere ueber die Zeilennummern im Batch, fuer die Ergebnisse vorliegen.
                for row_num, raw_text_res in scraping_results.items():
                    # Fuege Updates fuer AR, AT und AP hinzu
                    batch_sheet_updates.append({'range': f'{rohtext_col_letter}{row_num}', 'values': [[raw_text_res]]}) # Block 1 Column Map
                    batch_sheet_updates.append({'range': f'{timestamp_col_letter}{row_num}', 'values': [[current_timestamp]]}) # Block 1 Column Map
                    batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Block 1 Column Map
                # Fuege diese Updates zur globalen Liste hinzu (wird dann nur noch einmal gesendet)
                all_sheet_updates.extend(batch_sheet_updates)


        # --- Finale Sheet Updates senden ---
        # Sende alle verbleibenden gesammelten Updates in einem letzten Batch-Update.
        if all_sheet_updates:
            rows_in_final_update_batch = len(all_sheet_updates) // 3 # Updates pro Zeile ist 3
            self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({rows_in_final_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
            # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
            success = self.sheet_handler.batch_update_cells(all_sheet_updates)
            if success:
                self.logger.info(f"FINALES Sheet-Update erfolgreich.") # <<< GEÄNDERT
            # Der Fehlerfall wird von batch_update_cells geloggt


        # Logge den Abschluss des Modus
        self.logger.info(f"Website-Scraping (Batch) abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {skipped_count} Zeilen uebersprungen.") # <<< GEÄNDERT
        # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt.


    # ==============================================================================
    # Ende DataProcessor Klasse Batch: Website Scraping Block
    # ==============================================================================

    # ==========================================================================
    # === Batch Processing Methods =============================================
    # ==========================================================================

    # --- Methode fuer den Website-Summarization-Batchmodus (AS) ---
    # Diese Methode verarbeitet Zeilen, bei denen AR gefuellt und AS leer ist, um die Zusammenfassung zu erstellen.
    # Sie nutzt die globale Batch-Zusammenfassungsfunktion fuer den OpenAI Call.
    # Basierend auf process_website_summarization_batch aus Teil 9.
    # Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter.
    # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), datetime, time,
    #                      summarize_batch_openai (Block 9).
    # Nutzt die uebergeordnete sheet_handler Instanz (Block 14).
    def process_summarization_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None):
        """
        Batch-Prozess NUR fuer Website-Zusammenfassung (AS).
        Laedt Daten neu, prueft, ob Rohtext (AR) vorhanden und Zusammenfassung (AS) fehlt.
        Fasst Rohtexte im Batch ueber OpenAI zusammen und setzt AS + AP.

        Args:
            start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AS).
            end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
            limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht uebersprungener) Zeilen. Defaults to None (Unbegrenzt).
        """
        # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
        # Logge die Konfiguration des Batch-Laufs
        self.logger.info(f"Starte Website-Zusammenfassung (Batch AS, AP). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT


        # --- Daten laden und Startzeile ermitteln ---
        # Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt
        if start_sheet_row is None:
             self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AS...") # <<< GEÄNDERT
             # Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AS (Block 1 Column Map).
             # Standardmaessig ab Zeile 7
             start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Website Zusammenfassung", min_sheet_row=7)

             # Wenn get_start_row_index -1 zurueckgibt (Fehler)
             if start_data_index_no_header == -1:
                  self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche Batch ab.") # <<< GEÄNDERT
                  return # Beende die Methode

             # Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten Daten-Index
             start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut
             self.logger.info(f"Automatisch ermittelte Startzeile (erste leere AS Zelle): {start_sheet_row}") # <<< GEÄNDERT
        else:
            # Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein.
            # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2).
            if not self.sheet_handler.load_data():
                 self.logger.error("FEHLER beim Laden der Daten fuer process_summarization_batch.") # <<< GEÄNDERT
                 return # Beende die Methode, wenn das Laden fehlschlaegt


        # Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler.
        all_data = self.sheet_handler.get_all_data_with_headers();
        # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14).
        header_rows = self.sheet_handler._header_rows;
        total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet


        # Berechne Endzeile, wenn nicht manuell gesetzt
        if end_sheet_row is None:
             end_sheet_row = total_sheet_rows # Bis zur letzten Zeile

        # Logge den verarbeitungsbereich
        self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT

        # Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen)
        if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
             self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
             return # Beende die Methode, wenn der Bereich leer ist


        # --- Indizes und Buchstaben ---
        # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind
        required_keys = [
            "Website Rohtext", "Website Zusammenfassung", "Version", "CRM Name" # AR, AS, AP, B
        ]
        # Erstellen Sie ein Dictionary mit Schluesseln und Indizes
        col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}

        # Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden
        if None in col_indices.values():
            missing = [k for k, v in col_indices.items() if v is None]
            self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_summarization_batch: {missing}. Breche ab.") # <<< GEÄNDERT
            return # Beende die Methode bei kritischem Fehler

        # Ermitteln Sie die Indizes und Buchstaben fuer Updates (AS, AP)
        rohtext_col_idx = col_indices["Website Rohtext"]
        summary_col_idx = col_indices["Website Zusammenfassung"]
        version_col_idx = col_indices["Version"]
        name_col_idx = col_indices["CRM Name"] # Benoetigt fuer Logging

        summary_col_letter = self.sheet_handler._get_col_letter(summary_col_idx + 1) # Block 14 _get_col_letter
        version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1)


        # --- Verarbeitung ---
        # Holen Sie die Batch-Groesse fuer OpenAI-Aufrufe aus Config (Block 1)
        openai_batch_size = getattr(Config, 'OPENAI_BATCH_SIZE_LIMIT', 4)
        # Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1)
        update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)


        tasks_for_openai_batch = [] # Tasks fuer den aktuellen OpenAI Batch (Liste von Dicts)
        rows_in_current_openai_batch = [] # 1-basierte Zeilennummern im aktuellen OpenAI Batch
        all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts)


        processed_count = 0 # Zaehlt Zeilen, die fuer die Verarbeitung in Frage kommen und in den Batch aufgenommen werden (im Rahmen des Limits).
        skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (wegen fehlendem Rohtext oder vorhandener Zusammenfassung).


        # Iteriere ueber die Sheet-Zeilen im definierten Bereich (1-basierte Sheet-Zeilennummer)
        for i in range(start_sheet_row, end_sheet_row + 1):
            row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
            # Pruefen Sie, ob das Ende des Sheets erreicht wurde
            if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht


            row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile


            # Stellen Sie sicher, dass die Zeile nicht leer ist
            if not any(cell and isinstance(cell, str) and cell.strip() for cell in row):
                 #self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug
                 skipped_count += 1 # Zaehlen als uebersprungen
                 continue # Springe zur naechsten Zeile


            # --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
            # Kriterium: Website Rohtext (AR) ist vorhanden und gueltig (nicht k.A. oder Fehlerwerte).
            # UND Website Zusammenfassung (AS) ist leer oder ein Standard-Fehlerwert.

            # Holen Sie den Wert aus Spalte AR (Website Rohtext) (nutzt interne Helfer _get_cell_value_safe)
            raw_text = self._get_cell_value_safe(row, "Website Rohtext") # Block 1 Column Map
            # Pruefen Sie, ob AR gefuellt und gueltig ist.
            raw_text_is_valid = raw_text and isinstance(raw_text, str) and str(raw_text).strip().lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]


            # Holen Sie den Wert aus Spalte AS (Website Zusammenfassung) (nutzt interne Helfer _get_cell_value_safe)
            summary_value = self._get_cell_value_safe(row, "Website Zusammenfassung") # Block 1 Column Map
            # Pruefen Sie, ob AS leer ist oder einen Standard-Fehlerwert enthaelt.
            summary_is_empty_or_default = not summary_value or (isinstance(summary_value, str) and str(summary_value).strip().lower() in ["k.a.", "k.a. (keine zusammenfassung erhalten)"])


            # Verarbeitung ist noetig, wenn AR gueltig ist UND AS leer/default ist.
            processing_needed_for_row = raw_text_is_valid and summary_is_empty_or_default


            # Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
            log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
            if log_check:
                 company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map
                 self.logger.debug(f"Zeile {i} ({company_name[:50]}... Website Summarization Check): AR gueltig? {raw_text_is_valid} (len={len(str(raw_text))}), AS leer/default? {summary_is_empty_or_default}. Benötigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT


            # Wenn die Verarbeitung fuer diese Zeile nicht noetig ist
            if not processing_needed_for_row:
                skipped_count += 1 # Zaehlen als uebersprungene Zeile
                continue # Springe zur naechsten Zeile


            # --- Wenn Verarbeitung noetig: Fuege zur Batch-Liste fuer OpenAI hinzu ---
            processed_count += 1 # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im Rahmen des Limits zaehlen)

            # Pruefe das Limit fuer verarbeitete Zeilen
            if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit:
                 # Wenn das Limit erreicht ist und es ein positives Limit gibt
                 self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_summarization_batch erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
                 break # Brich die Schleife ab


            # Fuege die benoetigten Daten fuer den OpenAI Batch hinzu (Zeilennummer und Rohtext)
            tasks_for_openai_batch.append({'row_num': i, 'raw_text': raw_text})
            # Fuege die Zeilennummer zur Liste der Zeilennummern im Batch hinzu
            rows_in_current_openai_batch.append(i)


            # --- Verarbeite den Batch, wenn voll ---
            # Pruefe, ob die aktuelle Batch-Liste die definierte Groesse erreicht hat.
            # openai_batch_size wird aus Config geholt (Block 1).
            if len(tasks_for_openai_batch) >= openai_batch_size:
                 # Logge den Start der Batch-Verarbeitung
                 batch_start_row = tasks_for_openai_batch[0]['row_num']
                 batch_end_row = tasks_for_openai_batch[-1]['row_num']
                 self.logger.debug(f"\n--- Starte Website-Summarization Batch ({len(tasks_for_openai_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") # <<< GEÄNDERT

                 # Rufe die globale Funktion auf, die den OpenAI Call fuer den Batch macht (Block 9).
                 # summarize_batch_openai ist mit retry_on_failure dekoriert (Block 2).
                 # Wenn summarize_batch_openai eine Exception wirft (nach Retries), wird diese hier gefangen.
                 # !!! KORRIGIERTER AUFRUF !!!
                 try:
                      # Rufen Sie die korrekte globale Funktion auf
                      batch_results = summarize_batch_openai(tasks_for_openai_batch) # <<< Korrigierter Aufruf (vorher war fälschlicherweise _process_verification_openai_batch)
                      # Ergebnisse sollten ein Dictionary {row_num: summary_text} sein, auch bei Fehlern.

                      # Sammle Sheet Updates (AS, AP) fuer diesen Batch
                      current_version = getattr(Config, 'VERSION', 'unknown') # Block 1 Config Attribut
                      batch_sheet_updates = [] # Updates fuer DIESEN spezifischen Batch von Zeilen

                      # Iteriere ueber die Zeilennummern, die in DIESEM OpenAI Batch waren
                      for row_num in rows_in_current_openai_batch:
                           # Hole das Ergebnis fuer diese Zeile aus dem Ergebnis-Dictionary.
                           # Fallback auf einen Fehlerstring, wenn das Ergebnis fehlt (sollte nicht passieren, wenn summarize_batch_openai korrekt ist).
                           summary = batch_results.get(row_num, "k.A. (Batch Ergebnis fehlte)")
                           # Stelle sicher, dass 'k.A.' bei leeren/kurzen Summaries gesetzt wird
                           if not summary or (isinstance(summary, str) and summary.strip().lower() in ["k.a.", "k.a. (keine zusammenfassung erhalten)"]):
                                summary = "k.A. (Keine Zusammenfassung erhalten)"
                           # Fuege "k.A." oder Fehler an, wenn der Wert von summarize_batch_openai ein Fehlerstring ist
                           elif isinstance(summary, str) and (summary.startswith("k.A. (Fehler") or summary.startswith("FEHLER:")):
                                pass # Behalte den Fehlerstring von summarize_batch_openai

                           # Fuege Updates fuer AS und AP hinzu (nutzt interne Helfer)
                           batch_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[summary]]}) # Block 1 Column Map
                           batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Block 1 Column Map


                      # Sammle diese Batch-Updates fuer das groessere Batch-Update
                      all_sheet_updates.extend(batch_sheet_updates)


                 except Exception as e_openai_batch:
                      # Wenn summarize_batch_openai eine Exception wirft (nach Retries)
                      # Der Fehler wird bereits vom retry_on_failure Decorator auf summarize_batch_openai geloggt.
                      self.logger.error(f"Endgueltiger FEHLER beim OpenAI-Batch-Aufruf fuer Zusammenfassung: {e_openai_batch}") # <<< GEÄNDERT
                      # Logge den Traceback
                      self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
                      # Fügen Sie Fehlerwerte für alle Zeilen im Batch hinzu
                      current_version = getattr(Config, 'VERSION', 'unknown') # Block 1 Config Attribut
                      for row_num in rows_in_current_openai_batch:
                           error_summary = f"FEHLER OpenAI Batch: {str(e_openai_batch)[:100]}..." # Gekuerzt
                           # Fuege Updates mit Fehlerwerten fuer AS und AP hinzu
                           all_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[error_summary]]}) # Block 1 Column Map
                           all_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Block 1 Column Map


                 # Leere den OpenAI-Batch zurueck
                 tasks_for_openai_batch = []
                 rows_in_current_openai_batch = []


                 # Sende gesammelte Sheet Updates, wenn das Update-Batch-Limit erreicht ist.
                 # Updates pro Zeile sind 2 (AS, AP). Anzahl der Zeilen = len(all_sheet_updates) / 2.
                 rows_in_update_batch = len(all_sheet_updates) // 2

                 if rows_in_update_batch >= update_batch_row_limit:
                      self.logger.debug(f"  Sende gesammelte Sheet-Updates ({rows_in_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
                      # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
                      # Wenn es fehlschlaegt, wird es intern geloggt.
                      success = self.sheet_handler.batch_update_cells(all_sheet_updates)
                      if success:
                           self.logger.info(f"  Sheet-Update fuer {rows_in_update_batch} Zeilen erfolgreich.") # <<< GEÄNDERT
                       # Der Fehlerfall wird von batch_update_cells geloggt

                      # Leere die gesammelten Updates nach dem Senden.
                      all_sheet_updates = []

                 # Kurze Pause nach jedem OpenAI Batch (nutzt Config Block 1).
                 # Dies ist wichtig, um Rate Limits zu vermeiden.
                 pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.5 # 50% der Retry-Wartezeit
                 self.logger.debug(f"Warte {pause_duration:.2f}s vor naechstem Batch...") # <<< GEÄNDERT
                 time.sleep(pause_duration)


        # --- Verarbeitung des letzten unvollstaendigen OpenAI Batches nach der Schleife ---
        # Wenn nach der Hauptschleife noch Tasks in der Batch-Liste sind.
        if tasks_for_openai_batch: # Korrektur: War vorher `current_openai_batch_data`
             # Logge den Start des finalen Batches
             batch_start_row = tasks_for_openai_batch[0]['row_num'] # Korrektur: War vorher `current_openai_batch_data`
             batch_end_row = tasks_for_openai_batch[-1]['row_num'] # Korrektur: War vorher `current_openai_batch_data`
             self.logger.debug(f"\n--- Starte FINALEN Website-Summarization Batch ({len(tasks_for_openai_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") # <<< GEÄNDERT

             # Rufe die globale Funktion auf, die den OpenAI Call fuer den Batch macht (Block 9).
             # summarize_batch_openai ist mit retry_on_failure dekoriert (Block 2).
             # Wenn summarize_batch_openai eine Exception wirft (nach Retries), wird diese hier gefangen.
             batch_results = None
             try:
                  batch_results = summarize_batch_openai(tasks_for_openai_batch) # <<< Korrekter Aufruf Block 9, Korrektur: War vorher `current_openai_batch_data`
                  # Ergebnisse sollten ein Dictionary {row_num: summary_text} sein, auch bei Fehlern.

                  # Sammle Sheet Updates (AS, AP) fuer diesen finalen Batch
                  current_version = getattr(Config, 'VERSION', 'unknown') # Block 1 Config Attribut
                  batch_sheet_updates = [] # Updates fuer diesen spezifischen Batch
                  # Iteriere ueber die Zeilennummern im Batch, fuer die Ergebnisse vorliegen.
                  for row_num in rows_in_current_openai_batch:
                       # Hole das Ergebnis fuer diese Zeile aus dem Ergebnis-Dictionary.
                       summary = batch_results.get(row_num, "k.A. (Batch Ergebnis fehlte)") # Fallback

                       # Stelle sicher, dass 'k.A.' bei leeren/kurzen Summaries gesetzt wird
                       if not summary or (isinstance(summary, str) and summary.strip().lower() in ["k.a.", "k.a. (keine zusammenfassung erhalten)"]):
                           summary = "k.A. (Keine Zusammenfassung erhalten)"
                       # Fuege "k.A." oder Fehler an, wenn der Wert von summarize_batch_openai ein Fehlerstring ist
                       elif isinstance(summary, str) and (summary.startswith("k.A. (Fehler") or summary.startswith("FEHLER:")):
                           pass # Behalte den Fehlerstring von summarize_batch_openai


                       # Fuege Updates fuer AS und AP hinzu
                       batch_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[summary]]}) # Block 1 Column Map
                       batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Block 1 Column Map


                  # Fuege diese Updates zur globalen Liste hinzu (wird dann nur noch einmal gesendet)
                  all_sheet_updates.extend(batch_sheet_updates)


             except Exception as e_openai_batch:
                  # Wenn summarize_batch_openai eine Exception wirft (nach Retries)
                  # Der Fehler wird bereits vom retry_on_failure Decorator auf summarize_batch_openai geloggt.
                  self.logger.error(f"Endgueltiger FEHLER beim FINALEN OpenAI-Batch-Aufruf fuer Zusammenfassung: {e_openai_batch}") # <<< GEÄNDERT
                  # Logge den Traceback
                  self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
                  # Fügen Sie Fehlerwerte für alle Zeilen im Batch hinzu
                  current_version = getattr(Config, 'VERSION', 'unknown') # Block 1 Config Attribut
                  for row_num in rows_in_current_openai_batch:
                       error_summary = f"FEHLER OpenAI Batch: {str(e_openai_batch)[:100]}..." # Gekuerzt
                       # Fuege Updates mit Fehlerwerten fuer AS und AP hinzu
                       all_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[error_summary]]}) # Block 1 Column Map
                       all_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Block 1 Column Map


        # --- Finale Sheet Updates senden ---
        # Sende alle verbleibenden gesammelten Updates in einem letzten Batch-Update.
        if all_sheet_updates:
            rows_in_final_update_batch = len(all_sheet_updates) // 2 # Updates pro Zeile ist 2
            self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({rows_in_final_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
            # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
            success = self.sheet_handler.batch_update_cells(all_sheet_updates)
            if success:
                self.logger.info(f"FINALES Sheet-Update erfolgreich.") # <<< GEÄNDERT
            # Der Fehlerfall wird von batch_update_cells geloggt


        # Logge den Abschluss des Modus
        self.logger.info(f"Website-Zusammenfassung (Batch) abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {skipped_count} Zeilen uebersprungen.") # <<< GEÄNDERT
        # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt.

    # ==============================================================================
    # Ende DataProcessor Klasse Batch: Summarization Block
    # ==============================================================================

    # ==========================================================================
    # === Batch Processing Methods =============================================
    # ==========================================================================

    # --- Interne Hilfsfunktion fuer Branchen-Batch (OpenAI Call) ---
    # Diese Funktion laeuft in einem separaten Thread fuer parallele Verarbeitung.
    # Sie wird von process_branch_batch (dieser Block) aufgerufen.
    # Sie nutzt die globale Funktion evaluate_branche_chatgpt und erhaelt sie als Argument uebergeben.
    # Nutzt globale Helfer: evaluate_branche_chatgpt (Block 10), logger, time, threading.Semaphore, traceback.
    # Nutzt lokale Semaphore Instanz, die von process_branch_batch uebergeben wird.
    def evaluate_branch_task(self, task_data, openai_semaphore):
        """
        Führt die Branchenevaluation fuer eine einzelne Zeile aus.
        Läuft in einem separaten Thread fuer den Branchen-Batch.

        Args:
            task_data (dict): Enthält die Daten fuer die Zeile.
            openai_semaphore (threading.Semaphore): Semaphore zur Begrenzung gleichzeitiger OpenAI-Calls.

        Returns:
            dict: Ergebnis von evaluate_branche_chatgpt (Block 10) plus row_num und error.
        """
        # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
        logger = logging.getLogger(__name__ + ".evaluate_branch_task") # Eigener Logger für den Task
        row_num = task_data['row_num']
        # Initialisiere Ergebnis mit Fehlerwerten, falls der Task fehlschlaegt
        result = {"branch": "k.A. (Fehler Task)", "consistency": "error", "justification": "Fehler in Worker-Task"}
        error = None

        try:
            # Acquire the semaphore before making the OpenAI call (indirekt ueber evaluate_branche_chatgpt)
            with openai_semaphore:
                # Kleine künstliche Pause reduziert manchmal Race Conditions bei hoher Last oder schnellen APIs
                # time.sleep(0.05) # Optional

                # Rufe die globale Funktion zur Branchenevaluation auf (Block 10).
                # evaluate_branche_chatgpt nutzt call_openai_chat (Block 8), der den retry_on_failure Decorator (Block 2) nutzt.
                # Wenn evaluate_branche_chatgpt eine Exception wirft (nach Retries), wird diese hier gefangen.
                result = evaluate_branche_chatgpt(
                    task_data['crm_branche'],
                    task_data['beschreibung'],
                    task_data['wiki_branche'],
                    task_data['wiki_kategorien'],
                    task_data['website_summary']
                )
        except Exception as e:
            # Wenn evaluate_branche_chatgpt eine Exception wirft (nach Retries)
            # Der Fehler wird bereits vom retry_on_failure Decorator oder evaluate_branche_chatgpt geloggt.
            error = f"Fehler bei Branchenevaluation Zeile {row_num}: {type(e).__name__} - {e}"
            logger.error(error) # Logge den Fehler
            # Logge den Traceback
            logger.debug(traceback.format_exc())
            # Stellen Sie sicher, dass das Ergebnis-Dict im Fehlerfall spezifische Fehlerwerte enthaelt
            result = {"branch": "FEHLER", "consistency": "error_task", "justification": error[:500]} # Kuerze Begruendung

        # logger.debug(f"Branch Task Zeile {row_num} abgeschlossen.") # Zu viel Laerm im Debug
        return {"row_num": row_num, "result": result, "error": error}


    # --- Methode fuer den Branchen-Batchmodus (AO) ---
    # Diese Methode verarbeitet Zeilen, bei denen AO leer ist, um die Branche ueber ChatGPT zu bewerten.
    # Sie nutzt einen ThreadPoolExecutor und ruft die interne Worker-Funktion evaluate_branch_task auf.
    # Basierend auf process_branch_batch aus Teil 9.
    # Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter, evaluate_branch_task (derselbe Block).
    # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), datetime, time,
    #                      concurrent.futures, threading.Semaphore, load_target_schema (Block 6),
    #                      ALLOWED_TARGET_BRANCHES (Block 6).
    # Nutzt die uebergeordnete sheet_handler Instanz (Block 14).
    def process_branch_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None):
        self.logger.info(f"Starte Brancheneinschaetzung (Parallel Batch). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...")

        # --- Daten laden und Startzeile ermitteln ---
        if start_sheet_row is None:
             self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren Timestamp letzte Pruefung (BC)...")
             start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Timestamp letzte Pruefung", min_sheet_row=7)
             if start_data_index_no_header == -1:
                  self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche Batch ab.")
                  return
             start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1
             self.logger.info(f"Automatisch ermittelte Startzeile (erste leere BC Zelle): {start_sheet_row}")
        else:
            if not self.sheet_handler.load_data():
                 self.logger.error("FEHLER beim Laden der Daten fuer process_branch_batch.")
                 return

        all_data = self.sheet_handler.get_all_data_with_headers()
        header_rows = self.sheet_handler._header_rows
        total_sheet_rows = len(all_data)

        if end_sheet_row is None:
             end_sheet_row = total_sheet_rows
        self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}")
        if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
             self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.")
             return

        # --- Indizes und Buchstaben ---
        required_keys = [
            "Timestamp letzte Pruefung", "CRM Branche", "CRM Beschreibung", "Wiki Branche", 
            "Wiki Kategorien", "Website Zusammenfassung", "Version", "Chat Vorschlag Branche", 
            "Chat Branche Konfidenz", "Chat Konsistenz Branche", "Chat Begruendung Abweichung Branche",
            "CRM Name"
        ]
        col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
        if None in col_indices.values():
            missing = [k for k, v in col_indices.items() if v is None]
            self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_branch_batch: {missing}. Breche ab.")
            return

        MAX_BRANCH_WORKERS = getattr(Config, 'MAX_BRANCH_WORKERS', 10)
        OPENAI_CONCURRENCY_LIMIT = getattr(Config, 'OPENAI_CONCURRENCY_LIMIT', 3)
        processing_batch_size = getattr(Config, 'PROCESSING_BRANCH_BATCH_SIZE', 20)

        tasks_for_current_batch = []
        processed_tasks_count = 0 # Zählt Tasks, die tatsächlich verarbeitet wurden
        skipped_count = 0 

        global ALLOWED_TARGET_BRANCHES
        if not ALLOWED_TARGET_BRANCHES:
             load_target_schema()
             if not ALLOWED_TARGET_BRANCHES:
                  self.logger.critical("FEHLER: Ziel-Branchenschema konnte nicht geladen werden. Breche Batch ab.")
                  return

        # Funktion zum Verarbeiten eines einzelnen Batches (um Code-Duplikation zu reduzieren)
        def _execute_and_write_batch(batch_tasks_to_run):
            nonlocal processed_tasks_count # Zugriff auf die äußere Variable
            if not batch_tasks_to_run:
                return

            batch_start_log = batch_tasks_to_run[0]['row_num']
            batch_end_log = batch_tasks_to_run[-1]['row_num']
            self.logger.debug(f"\n--- Verarbeite Branch-Evaluation Batch ({len(batch_tasks_to_run)} Tasks, Zeilen {batch_start_log}-{batch_end_log}) ---")

            current_batch_results = []
            current_batch_errors = 0
            openai_sem = threading.Semaphore(OPENAI_CONCURRENCY_LIMIT)

            with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_BRANCH_WORKERS) as executor:
                future_map = {executor.submit(self.evaluate_branch_task, task, openai_sem): task for task in batch_tasks_to_run}
                for future in concurrent.futures.as_completed(future_map):
                    task_info = future_map[future]
                    try:
                        res_data = future.result()
                        current_batch_results.append(res_data)
                        if res_data.get('error'): current_batch_errors +=1
                    except Exception as exc_future:
                        self.logger.error(f"Exception im Future für Zeile {task_info['row_num']}: {exc_future}")
                        current_batch_results.append({"row_num": task_info['row_num'], "result": {"branch": "FEHLER FUTURE", "consistency": "error_task", "justification": str(exc_future)[:100]}, "error": str(exc_future)})
                        current_batch_errors += 1
            
            self.logger.debug(f"  Batch ({batch_start_log}-{batch_end_log}) beendet. {len(current_batch_results)} Ergebnisse, {current_batch_errors} Fehler.")

            if current_batch_results:
                ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                ver = getattr(Config, 'VERSION', 'unknown')
                updates_this_batch = []
                current_batch_results.sort(key=lambda x: x['row_num'])
                for item in current_batch_results:
                    rn, res = item['row_num'], item['result']
                    self.logger.debug(f"  Zeile {rn} (Ergebnis): {res}")
                    updates_this_batch.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"] + 1)}{rn}', 'values': [[res.get("branch", "ERR BR")]]})
                    updates_this_batch.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Branche Konfidenz"] + 1)}{rn}', 'values': [[res.get("confidence", "N/A CO")]]})
                    updates_this_batch.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenz Branche"] + 1)}{rn}', 'values': [[res.get("consistency", "err CO")]]})
                    updates_this_batch.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung Abweichung Branche"] + 1)}{rn}', 'values': [[res.get("justification", "No JU")]]})
                    updates_this_batch.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Timestamp letzte Pruefung"] + 1)}{rn}', 'values': [[ts]]})
                    updates_this_batch.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Version"] + 1)}{rn}', 'values': [[ver]]})
                
                if updates_this_batch:
                    self.logger.debug(f"  Sende Sheet-Update für {len(current_batch_results)} Zeilen dieses Batches...")
                    s_upd = self.sheet_handler.batch_update_cells(updates_this_batch)
                    if s_upd: self.logger.info(f"  Sheet-Update für Batch Zeilen {batch_start_log}-{batch_end_log} erfolgreich.")
            
            processed_tasks_count += len(batch_tasks_to_run) # Zähle verarbeitete Tasks
            
            pause_dur = getattr(Config, 'RETRY_DELAY', 5) * 0.8
            self.logger.debug(f"--- Batch ({batch_start_log}-{batch_end_log}) abgeschlossen. Warte {pause_dur:.2f}s ---")
            time.sleep(pause_dur)
        # Ende der Hilfsfunktion _execute_and_write_batch

        # Hauptschleife über die Zeilen
        for i in range(start_sheet_row, end_sheet_row + 1):
            if limit is not None and processed_tasks_count >= limit: # Prüfe Limit für *tatsächlich verarbeitete* Tasks
                self.logger.info(f"Verarbeitungslimit ({limit}) erreicht. Stoppe weitere Zeilenprüfung.")
                break 

            row_index_in_list = i - 1 
            if row_index_in_list >= total_sheet_rows: break

            row = all_data[row_index_in_list] 
            if not any(cell and isinstance(cell, str) and cell.strip() for cell in row):
                 skipped_count += 1 
                 continue 
            
            company_name_log = self._get_cell_value_safe(row, "CRM Name").strip()
            ao_value = self._get_cell_value_safe(row, "Timestamp letzte Pruefung").strip()
            if ao_value: # Wenn Timestamp gesetzt ist, überspringen
                skipped_count += 1 
                continue 

            # --- DEBUG BLOCK für info_sources_count (wie gehabt) ---
            crm_branche_val = self._get_cell_value_safe(row, "CRM Branche").strip(); crm_beschreibung_val = self._get_cell_value_safe(row, "CRM Beschreibung").strip()
            wiki_branche_val = self._get_cell_value_safe(row, "Wiki Branche").strip(); wiki_kategorien_val = self._get_cell_value_safe(row, "Wiki Kategorien").strip()
            website_summary_val = self._get_cell_value_safe(row, "Website Zusammenfassung").strip()
            # ... (kompletter detaillierter Debug-Block für info_sources_count hier einfügen) ...
            self.logger.debug(f"Zeile {i} ({company_name_log[:30]}...) - Rohwerte für Info-Quellen:")
            sources_to_check = {"CRM Branche": crm_branche_val, "CRM Beschreibung": crm_beschreibung_val, "Wiki Branche": wiki_branche_val, "Wiki Kategorien": wiki_kategorien_val, "Website Zusammenfassung": website_summary_val}
            info_sources_count = 0; counted_sources = []
            for source_name, val in sources_to_check.items():
                cond1 = bool(val); cond2 = isinstance(val, str); cond3 = False; cond4 = False; cond5 = False
                if cond1 and cond2:
                    stripped_val = val.strip(); cond3 = bool(stripped_val)
                    if cond3: lower_stripped_val = stripped_val.lower(); cond4 = lower_stripped_val != "k.a."; cond5 = not stripped_val.upper().startswith("FEHLER")
                is_valid_source = cond1 and cond2 and cond3 and cond4 and cond5
                if is_valid_source: info_sources_count += 1; counted_sources.append(source_name)
                self.logger.debug(f"  Prüfe Quelle '{source_name}': Wert='{str(val)[:30]}...', c1?{cond1}, c2?{cond2}, c3?{cond3}, c4?{cond4}, c5?{cond5} -> Gültig? {is_valid_source}")
            self.logger.debug(f"Zeile {i} ({company_name_log[:30]}...) - Gezählte valide Quellen: {info_sources_count} - {counted_sources}")
            
            if info_sources_count < 2:
                 self.logger.info(f"Zeile {i} ({company_name_log[:30]}...) (Branch Check): Uebersprungen (Timestamp BC leer, aber nur {info_sources_count} Informationsquellen verfuegbar: {counted_sources}). Mindestens 2 benoetigt.")
                 skipped_count += 1
                 continue 
            
            # Task zur Liste hinzufügen, wenn alle Kriterien erfüllt sind UND Limit noch nicht erreicht
            if limit is None or (processed_tasks_count + len(tasks_for_current_batch)) < limit:
                tasks_for_current_batch.append({
                    "row_num": i, "crm_branche": crm_branche_val, "beschreibung": crm_beschreibung_val, 
                    "wiki_branche": wiki_branche_val, "wiki_kategorien": wiki_kategorien_val, 
                    "website_summary": website_summary_val 
                })
            elif limit is not None and (processed_tasks_count + len(tasks_for_current_batch)) >= limit :
                 # Wenn das Hinzufügen dieses Tasks das Limit erreichen oder überschreiten würde,
                 # füge ihn noch hinzu (wird im nächsten Batch-Check gekürzt) und beende dann die Schleife
                 tasks_for_current_batch.append({
                    "row_num": i, "crm_branche": crm_branche_val, "beschreibung": crm_beschreibung_val, 
                    "wiki_branche": wiki_branche_val, "wiki_kategorien": wiki_kategorien_val, 
                    "website_summary": website_summary_val 
                 })
                 self.logger.info(f"Zeile {i} wurde als letzter Task vor Erreichen des Limits ({limit}) gesammelt.")
                 # Die execute_and_write_batch Logik wird getriggert, wenn die Schleife endet oder der Batch voll ist.


            # Batch ausführen, wenn voll ODER es die letzte Zeile ist
            if len(tasks_for_current_batch) >= processing_batch_size or i == end_sheet_row:
                _execute_and_write_batch(tasks_for_current_batch)
                tasks_for_current_batch = [] # Batch-Liste für den nächsten Durchlauf leeren
        
        # Sicherstellen, dass ein eventuell nicht voller letzter Batch auch noch verarbeitet wird,
        # falls die Schleife durch das Limit beendet wurde und noch Tasks übrig sind.
        if tasks_for_current_batch:
            self.logger.debug(f"Verarbeite verbleibenden Rest-Batch von {len(tasks_for_current_batch)} Tasks...")
            _execute_and_write_batch(tasks_for_current_batch)

        self.logger.info(f"Brancheneinschaetzung (Parallel Batch) abgeschlossen. {processed_tasks_count} Zeilen verarbeitet, {skipped_count} Zeilen uebersprungen.")


    # ==============================================================================
    # Ende DataProcessor Klasse Batch: Branch Evaluation Block
    # ==============================================================================

    # ==========================================================================
    # === Batch Processing Methods =============================================
    # ==========================================================================

    # --- Methode fuer den SerpAPI Wiki Search Batchmodus (AY) ---
    # Diese Methode sucht fehlende Wiki-URLs ueber SerpAPI.
    # Basierend auf process_find_wiki_with_serp aus Teil 2.
    # Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter.
    # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), datetime, time,
    #                      get_numeric_filter_value (Block 5), serp_wikipedia_lookup (Block 10).
    # Nutzt die uebergeordnete sheet_handler Instanz (Block 14).
    def process_find_wiki_serp(self, start_sheet_row=None, end_sheet_row=None, limit=None, min_employees=500, min_umsatz=200):
        """
        Sucht fehlende Wikipedia-URLs (Spalte M = k.A.) ueber SerpAPI fuer Unternehmen mit
        (Umsatz CRM > min_umsatz MIO € ODER Mitarbeiter CRM > min_employees)
        UND wenn der SerpAPI Wiki Search Timestamp (AY) leer ist.
        Traegt gefundene URLs in Spalte M ein. Setzt ReEval-Flag (A)
        und loescht abhaengige Wiki-Spalten (N-V, AN, AO, AP, AX).
        Setzt Timestamp in Spalte AY, wann die Suche durchgefuehrt wurde (unabhaengig vom Ergebnis).

        Args:
            start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AY).
            end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
            limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht uebersprungener) Zeilen. Defaults to None (Unbegrenzt).
            min_employees (int, optional): Mindestanzahl Mitarbeiter (Spalte K) als Teilfilter. Defaults to 500.
            min_umsatz (int, optional): Mindestumsatz in MIO € (Spalte J) als Teilfilter. Defaults to 200.
        """
        # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
        # Logge die Konfiguration des Batch-Laufs
        self.logger.info(f"Starte Modus 'find_wiki_serp' (AY, M, A). Filter: (Umsatz CRM > {min_umsatz} MIO € ODER Mitarbeiter CRM > {min_employees}). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT


        # --- Daten laden und Startzeile ermitteln ---
        # Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt
        if start_sheet_row is None:
             self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AY...") # <<< GEÄNDERT
             # Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AY (Block 1 Column Map).
             # Standardmaessig ab Zeile 7
             start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="SerpAPI Wiki Search Timestamp", min_sheet_row=7)

             # Wenn get_start_row_index -1 zurueckgibt (Fehler)
             if start_data_index_no_header == -1:
                  self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche Batch ab.") # <<< GEÄNDERT
                  return # Beende die Methode

             # Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten Daten-Index
             start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut
             self.logger.info(f"Automatisch ermittelte Startzeile (erste leere AY Zelle): {start_sheet_row}") # <<< GEÄNDERT
        else:
            # Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein.
            # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2).
            if not self.sheet_handler.load_data():
                 self.logger.error("FEHLER beim Laden der Daten fuer process_find_wiki_serp.") # <<< GEÄNDERT
                 return # Beende die Methode, wenn das Laden fehlschlaegt


        # Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler.
        all_data = self.sheet_handler.get_all_data_with_headers();
        # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14).
        header_rows = self.sheet_handler._header_rows;
        total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet


        # Berechne Endzeile, wenn nicht manuell gesetzt
        if end_sheet_row is None:
             end_sheet_row = total_sheet_rows # Bis zur letzten Zeile

        # Logge den verarbeitungsbereich
        self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT

        # Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen)
        if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
             self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
             return # Beende die Methode, wenn der Bereich leer ist


        # --- Indizes und Buchstaben ---
        # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind
        required_keys = [
            "SerpAPI Wiki Search Timestamp", "Wiki URL", "CRM Umsatz", "CRM Anzahl Mitarbeiter", # AY, M, J, K (Pruefkriterien / Timestamp)
            "ReEval Flag", "CRM Name", "CRM Website", # A, B, D (Daten fuer Suche / Updates)
            "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", # N-R (Spalten zum Leeren)
            "Chat Wiki Konsistenzpruefung", "Chat Begruendung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", # S-U (Spalten zum Leeren)
            "Begruendung bei Abweichung", "Wikipedia Timestamp", "Timestamp letzte Pruefung", # V, AN, AO (Spalten zum Leeren)
            "Version", "Wiki Verif. Timestamp" # AP, AX (Spalten zum Leeren)
        ]
        # Erstellen Sie ein Dictionary mit Schluesseln und Indizes
        col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}

        # Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden
        if None in col_indices.values():
            missing = [k for k, v in col_indices.items() if v is None]
            self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_find_wiki_serp: {missing}. Breche ab.") # <<< GEÄNDERT
            return # Beende die Methode bei kritischem Fehler

        # Ermitteln Sie die Spaltenbuchstaben fuer Updates und Leerung (nutzt interne Helfer _get_col_letter Block 14)
        ts_ay_letter = self.sheet_handler._get_col_letter(col_indices["SerpAPI Wiki Search Timestamp"] + 1) # Timestamp zu setzen (AY)
        m_letter = self.sheet_handler._get_col_letter(col_indices["Wiki URL"] + 1) # Wiki URL Spalte (M)
        a_letter = self.sheet_handler._get_col_letter(col_indices["ReEval Flag"] + 1) # ReEval Flag (A)

        # Spalten N-V leeren.
        # N ist Wiki Absatz, V ist Begruendung bei Abweichung.
        n_idx = col_indices["Wiki Absatz"]
        v_idx = col_indices["Begruendung bei Abweichung"]
        # Erstellen Sie den Bereichsnamen (z.B. "N:V")
        n_letter = self.sheet_handler._get_col_letter(n_idx + 1)
        v_letter = self.sheet_handler._get_col_letter(v_idx + 1)
        nv_range_letter = f'{n_letter}:{v_letter}' # z.B. N:V
        # Erstellen Sie eine Liste von leeren Strings fuer diesen Bereich
        empty_nv_values = [''] * (v_idx - n_idx + 1) # Anzahl der Spalten = V_Index - N_Index + 1


        # Timestamps AN, AO, AP, AX leeren.
        an_letter = self.sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"] + 1) # AN (Wiki Extraction TS)
        ao_letter = self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Pruefung"] + 1) # AO (Chat Evaluation TS)
        ap_letter = self.sheet_handler._get_col_letter(col_indices["Version"] + 1) # AP (Version)
        ax_letter = self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"] + 1) # AX (Wiki Verif. TS)


        # --- Verarbeitung ---
        # Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1)
        update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)


        all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts)


        processed_count = 0 # Zaehlt Zeilen, fuer die SerpAPI versucht wurde (im Rahmen des Limits zaehlen).
        skipped_count = 0     # Zaehlt Zeilen, die uebersprungen wurden (verschiedene Gruende).
        found_urls_count = 0  # Zaehlt Zeilen, wo eine URL gefunden und eingetragen wurde.


        # Aktueller Zeitstempel fuer den AY Timestamp
        now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")


        # Iteriere durch die Datenzeilen im definierten Bereich (1-basierte Sheet-Zeilennummer)
        for i in range(start_sheet_row, end_sheet_row + 1):
            row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
            # Pruefen Sie, ob das Ende des Sheets erreicht wurde
            if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht


            row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile


            # Stellen Sie sicher, dass die Zeile nicht leer ist
            if not any(cell and isinstance(cell, str) and cell.strip() for cell in row):
                 #self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug
                 skipped_count += 1 # Zaehlen als uebersprungen
                 continue # Springe zur naechsten Zeile


            # --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
            # Kriterium: SerpAPI Wiki Search Timestamp (AY) ist leer.
            # UND Wiki URL (M) ist leer oder "k.A.".
            # UND (Umsatz CRM (J) > min_umsatz MIO € ODER Mitarbeiter CRM (K) > min_employees).

            # Holen Sie die Werte aus den entsprechenden Spalten (nutzt interne Helfer _get_cell_value_safe)
            ay_value = self._get_cell_value_safe(row, "SerpAPI Wiki Search Timestamp").strip() # Block 1 Column Map
            m_value = self._get_cell_value_safe(row, "Wiki URL").strip() # Block 1 Column Map
            umsatz_val_str = self._get_cell_value_safe(row, "CRM Umsatz") # Block 1 Column Map
            ma_val_str = self._get_cell_value_safe(row, "CRM Anzahl Mitarbeiter") # Block 1 Column Map


            # Pruefen Sie, ob AY leer ist.
            is_ay_empty = not ay_value
            # Pruefen Sie, ob M leer oder "k.A." ist.
            is_m_empty_or_ka = not m_value or (isinstance(m_value, str) and m_value.lower() == "k.a.")

            # Nutze die globale Hilfsfunktion (Block 5), um die Werte fuer den Groessen-Filter zu bekommen.
            # get_numeric_filter_value gibt 0 fuer ungueltige/leere Werte zurueck.
            umsatz_val_mio = get_numeric_filter_value(umsatz_val_str, is_umsatz=True)
            ma_val_num = get_numeric_filter_value(ma_val_str, is_umsatz=False)

            # Pruefen Sie, ob das Groessen-Kriterium erfuellt ist.
            size_criteria_met = (umsatz_val_mio > min_umsatz) or (ma_val_num > min_employees)


            # Verarbeitung ist noetig, wenn AY leer ist UND M leer/k.A. ist UND das Groessen-Kriterium erfuellt ist.
            processing_needed_for_row = is_ay_empty and is_m_empty_or_ka and size_criteria_met


            # Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
            log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
            if log_check:
                 company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map
                 self.logger.debug(f"Zeile {i} ({company_name[:50]}... SerpAPI Wiki Search Check): AY leer? {is_ay_empty}, M leer/k.A.? {is_m_empty_or_ka}, Groesse ({umsatz_val_mio:.1f} Mio, {ma_val_num} MA) Kriterium ({min_umsatz} Mio, {min_employees} MA)? {size_criteria_met}. Benötigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT


            # Wenn die Verarbeitung fuer diese Zeile nicht noetig ist
            if not processing_needed_for_row:
                skipped_count += 1 # Zaehlen als uebersprungene Zeile
                continue # Springe zur naechsten Zeile


            # --- Wenn Verarbeitung noetig: Fuehre SerpAPI Suche aus ---
            processed_count += 1 # Zaehle die Zeile, fuer die SerpAPI versucht wird (im Rahmen des Limits zaehlen)

            # Pruefe das Limit fuer verarbeitete Zeilen
            if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit:
                 # Wenn das Limit erreicht ist und es ein positives Limit gibt
                 self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_find_wiki_serp erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
                 break # Brich die Schleife ab


            # Hole Firmenname und Website fuer die Suche (nutzt interne Helfer _get_cell_value_safe)
            company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map
            website_url = self._get_cell_value_safe(row, "CRM Website").strip() # Block 1 Column Map (Website kann fuer SerpAPI Kontext hilfreich sein)

            # Wenn kein Firmenname vorhanden ist, kann die Suche nicht durchgefuehrt werden
            if not company_name:
                self.logger.warning(f"Zeile {i}: Uebersprungen (kein Firmenname fuer Suche vorhanden in Spalte B).") # <<< GEÄNDERT
                skipped_count += 1 # Zaehlen als uebersprungene Zeile, da Suche nicht moeglich
                # Setze AY Timestamp auch hier, um nicht immer wieder zu versuchen
                # Erstelle leeres Update-Dict, damit extend funktioniert
                updates = []
                updates.append({'range': f'{ts_ay_letter}{i}', 'values': [[now_timestamp_str]]}) # Block 1 Column Map
                all_sheet_updates.extend(updates) # Fuege dieses einzelne Update zur Liste hinzu
                updates = [] # Leere die lokale Liste
                continue # Springe zur naechsten Zeile


            self.logger.info(f"Zeile {i}: Suche Wiki-URL fuer '{company_name[:100]}...' (Umsatz (Mio): {umsatz_val_mio:.1f}, MA: {ma_val_num}) ueber SerpAPI...") # <<< GEÄNDERT


            # Führe die SerpAPI Suche durch (nutzt globale Funktion Block 10 mit Retry).
            # serp_wikipedia_lookup ist mit retry_on_failure dekoriert (Block 2).
            # Wenn serp_wikipedia_lookup nach Retries fehlschlaegt, wirft er eine Exception.
            wiki_url_found = None # Initialisiere mit None
            try:
                 wiki_url_found = serp_wikipedia_lookup(company_name, website=website_url) # Nutzt globalen Helfer (Block 10)
                 # Wenn serp_wikipedia_lookup erfolgreich ist, gibt es die URL oder None zurueck.

            except Exception as e_serp_wiki:
                 # Wenn serp_wikipedia_lookup eine Exception wirft (nach Retries)
                 self.logger.error(f"FEHLER bei serp_wikipedia_lookup fuer Zeile {i} ('{company_name[:100]}...'): {e_serp_wiki}") # <<< GEÄNDERT
                 # wiki_url_found bleibt None. Fahren Sie fort.
                 pass # Fahren Sie fort, um Timestamp zu setzen und Updates vorzubereiten


            # --- Updates vorbereiten ---
            # Timestamp AY IMMER setzen, nachdem der Versuch gemacht wurde, unabhaengig vom Ergebnis der Suche.
            updates_for_row = [] # Lokale Liste fuer Updates dieser Zeile

            updates_for_row.append({'range': f'{ts_ay_letter}{i}', 'values': [[now_timestamp_str]]}) # Block 1 Column Map


            # Wenn eine URL gefunden wurde, bereite weitere Updates vor.
            # Eine gefundene URL ist ein String, der nicht None ist und nicht "k.A." oder Fehlerstring ist.
            if wiki_url_found and isinstance(wiki_url_found, str) and wiki_url_found.lower() not in ["k.a.", "kein artikel gefunden"] and not wiki_url_found.startswith("FEHLER"): # Korrektur Pruefung
                 self.logger.info(f"  -> URL gefunden: {wiki_url_found[:100]}... Bereite Update vor (Setze M, A; Loesche N-V, AN, AO, AP, AX).") # <<< GEÄNDERT
                 found_urls_count += 1 # Zaehle den Fund


                 # Setze M (Wiki URL) mit der gefundenen URL
                 updates_for_row.append({'range': f'{m_letter}{i}', 'values': [[wiki_url_found]]}) # Block 1 Column Map
                 # Setze ReEval Flag (A) auf 'x' (signalisiert, dass eine Re-Evaluation noetig ist)
                 updates_for_row.append({'range': f'{a_letter}{i}', 'values': [['x']]}) # Block 1 Column Map


                 # Leere Spalten N-V.
                 # Fuege das Update zum Leeren des Bereichs V-Y hinzu, falls der Bereichsname ermittelt werden konnte.
                 if nv_range_letter: # Pruefe, ob der Bereichsname ermittelt werden konnte.
                      updates_for_row.append({'range': f'{n_letter}{i}:{v_letter}{i}', 'values': [empty_nv_values]}) # Block 1 Column Map, lokale Variable
                 else:
                      self.logger.warning(f"Konnte Spaltenbereich N-V ({n_letter}:{v_letter}) fuer Leerung nicht ermitteln fuer Zeile {i}. Leerung uebersprungen.") # <<< GEÄNDERT


                 # Leere Timestamps AN, AO, AP, AX.
                 # Dies setzt die Zeile zurueck, damit andere Schritte sie spaeter bearbeiten.
                 updates_for_row.append({'range': f'{an_letter}{i}', 'values': [['']]}) # Block 1 Column Map
                 updates_for_row.append({'range': f'{ao_letter}{i}', 'values': [['']]}) # Block 1 Column Map
                 updates_for_row.append({'range': f'{ap_letter}{i}', 'values': [['']]}) # Block 1 Column Map
                 updates_for_row.append({'range': f'{ax_letter}{i}', 'values': [['']]}) # Block 1 Column Map


            else:
                 # Wenn keine Wiki-URL ueber SerpAPI gefunden wurde
                 self.logger.debug(f"  -> Keine Wiki-URL fuer '{company_name[:100]}...' ueber SerpAPI gefunden.") # <<< GEÄNDERT
                 # Nur AY Timestamp wird gesetzt, was bereits oben passiert ist. Keine weiteren Updates fuer M, A, N-V etc.


            # Sammle die Updates fuer diese Zeile in der globalen Liste.
            all_sheet_updates.extend(updates_for_row)


            # Sende gesammelte Sheet Updates, wenn das Update-Batch-Limit erreicht ist.
            # update_batch_row_limit wird aus Config geholt (Block 1).
            # Die Anzahl der Updates pro Zeile variiert (1 bei nicht gefunden, ca. 10+ bei gefunden).
            # Pruefen Sie einfach die Laenge der gesammelten Liste.
            if len(all_sheet_updates) >= update_batch_row_limit * 5: # Grobe Schaetzung: im Schnitt 5 Updates pro Zeile
                 self.logger.debug(f"  Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
                 # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
                 # Wenn es fehlschlaegt, wird es intern geloggt.
                 success = self.sheet_handler.batch_update_cells(all_sheet_updates)
                 if success:
                      self.logger.info(f"  Sheet-Update fuer {len(all_sheet_updates)} Zellen erfolgreich.") # <<< GEÄNDERT
                   # Der Fehlerfall wird von batch_update_cells geloggt

                 # Leere die gesammelten Updates nach dem Senden.
                 all_sheet_updates = []

            # Kleine Pause nach jeder SerpAPI-Suche (nutzt Config Block 1).
            # Der retry_on_failure Decorator (Block 2) kuemmert sich um Retries mit Backoff.
            # Dies ist eine globale Rate-Limit-Vorsorge zwischen einzelnen Anfragen.
            serp_delay = getattr(Config, 'SERPAPI_DELAY', 1.5)
            #self.logger.debug(f"Warte {serp_delay:.2f}s nach SerpAPI Suche...") # Zu viel Laerm im Debug
            time.sleep(serp_delay)


        # --- Finale Sheet Updates senden ---
        # Sende alle verbleibenden gesammelten Updates in einem letzten Batch-Update.
        if all_sheet_updates:
            self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
            # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
            success = self.sheet_handler.batch_update_cells(all_sheet_updates)
            if success:
                self.logger.info(f"FINALES Sheet-Update erfolgreich.") # <<< GEÄNDERT
            # Der Fehlerfall wird von batch_update_cells geloggt


        # Logge den Abschluss des Modus
        self.logger.info(f"Modus 'find_wiki_serp' abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {found_urls_count} URLs gefunden & eingetragen, {skipped_count} Zeilen uebersprungen.") # <<< GEÄNDERT
        # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt.


    # --- Methode fuer den Contact Search Batchmodus (AM, AI-AL) ---
    # Diese Methode sucht LinkedIn Kontakte ueber SerpAPI und traegt Trefferzahlen ins Sheet ein.
    # Basierend auf process_contact_research aus Teil 10.
    # Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter.
    # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), datetime, time,
    #                      search_linkedin_contacts (Block 10), get_gender (Block 5), get_email_address (Block 5).
    # Nutzt die uebergeordnete sheet_handler Instanz (Block 14).
    def process_contact_search(self, start_sheet_row=None, end_sheet_row=None, limit=None):
        """
        Sucht LinkedIn Kontakte ueber SerpAPI fuer Zeilen, bei denen der
        Contact Search Timestamp (AM) leer ist. Traegt Trefferzahlen in
        AI-AL und den Timestamp in AM ein. Schreibt Details optional in ein 'Contacts' Blatt.

        Args:
            start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AM).
            end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
            limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht uebersprungener) Zeilen. Defaults to None (Unbegrenzt).
        """
        # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
        # Logge die Konfiguration des Batch-Laufs
        self.logger.info(f"Starte Contact Research (Batch AM, AI-AL). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT


        # --- Daten laden und Startzeile ermitteln ---
        # Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt
        if start_sheet_row is None:
             self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AM...") # <<< GEÄNDERT
             # Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AM (Block 1 Column Map).
             # Standardmaessig ab Zeile 7
             start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Contact Search Timestamp", min_sheet_row=7)

             # Wenn get_start_row_index -1 zurueckgibt (Fehler)
             if start_data_index_no_header == -1:
                  self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche Batch ab.") # <<< GEÄNDERT
                  return # Beende die Methode

             # Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten Daten-Index
             start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut
             self.logger.info(f"Automatisch ermittelte Startzeile (erste leere AM Zelle): {start_sheet_row}") # <<< GEÄNDERT
        else:
            # Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein.
            # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2).
            if not self.sheet_handler.load_data():
                 self.logger.error("FEHLER beim Laden der Daten fuer process_contact_search.") # <<< GEÄNDERT
                 return # Beende die Methode, wenn das Laden fehlschlaegt


        # Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler.
        all_data = self.sheet_handler.get_all_data_with_headers()
        # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14).
        header_rows = self.sheet_handler._header_rows
        total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet


        # Berechne Endzeile, wenn nicht manuell gesetzt
        if end_sheet_row is None:
             end_sheet_row = total_sheet_rows # Bis zur letzten Zeile

        # Logge den verarbeitungsbereich
        self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT

        # Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen)
        if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
             self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
             return # Beende die Methode, wenn der Bereich leer ist


        # --- Indizes und Buchstaben ---
        # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind
        required_keys = [
            "Contact Search Timestamp", # AM - Pruefkriterium / Timestamp
            "CRM Name", "CRM Kurzform", "CRM Website", # B, C, D (Daten fuer Suche)
            "Linked Serviceleiter gefunden", "Linked It-Leiter gefunden", # AI, AJ (Zielspalten fuer Trefferzahlen)
            "Linked Management gefunden", "Linked Disponent gefunden" # AK, AL (Zielspalten fuer Trefferzahlen)
        ]
        # Erstellen Sie ein Dictionary mit Schluesseln und Indizes
        col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}

        # Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden
        if None in col_indices.values():
            missing = [k for k, v in col_indices.items() if v is None]
            self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_contact_search: {missing}. Breche ab.") # <<< GEÄNDERT
            return # Beende die Methode bei kritischem Fehler


        # Ermitteln Sie die Spaltenbuchstaben fuer Updates (AI-AL, AM) (nutzt interne Helfer _get_col_letter Block 14)
        ts_am_letter = self.sheet_handler._get_col_letter(col_indices["Contact Search Timestamp"] + 1) # AM
        ai_letter = self.sheet_handler._get_col_letter(col_indices["Linked Serviceleiter gefunden"] + 1) # AI
        aj_letter = self.sheet_handler._get_col_letter(col_indices["Linked It-Leiter gefunden"] + 1) # AJ
        ak_letter = self.sheet_handler._get_col_letter(col_indices["Linked Management gefunden"] + 1) # AK
        al_letter = self.sheet_handler._get_col_letter(col_indices["Linked Disponent gefunden"] + 1) # AL


        # Positionen, nach denen gesucht wird (kann in Config verschoben werden Block 1)
        # Die Zuordnung zur Zaehlspalte (AI-AL) muss hier im Code erfolgen.
        positions_to_search = {
            "Serviceleiter": ["Serviceleiter", "Leiter Kundendienst", "Einsatzleiter"],
            "IT-Leiter": ["IT-Leiter", "Leiter IT"],
            "Management": ["Geschäftsführer", "Vorstand", "Inhaber", "CEO", "CTO", "COO", "Kaufmännischer Leiter", "Technischer Leiter"], # Management erweitert
            "Disponent": ["Disponent", "Einsatzplaner"] # Disponent erweitert
        }
        # Stellen Sie sicher, dass die Schluessel im Dict den COLUMN_MAP Keys (AI-AL) entsprechen,
        # damit die Zaehlung korrekt zugeordnet werden kann.


        # --- Kontakte-Blatt oeffnen oder erstellen ---
        contacts_sheet = None # Initialisiere mit None
        # Der Zugriff auf das Spreadsheet-Objekt erfolgt ueber den SheetHandler (Block 14).
        if self.sheet_handler and self.sheet_handler.sheet and self.sheet_handler.sheet.spreadsheet:
             try:
                 # Versuche, das Sheet "Contacts" zu oeffnen
                 contacts_sheet = self.sheet_handler.sheet.spreadsheet.worksheet("Contacts")
                 self.logger.info("Blatt 'Contacts' gefunden.") # <<< GEÄNDERT
             except gspread.exceptions.WorksheetNotFound:
                 # Wenn nicht gefunden, erstelle es.
                 self.logger.info("Blatt 'Contacts' nicht gefunden, erstelle neu...") # <<< GEÄNDERT
                 try:
                      # Definieren Sie den Header fuer das neue Blatt
                      contacts_header = ["Firmenname", "CRM Kurzform", "Website", "Geschlecht", "Vorname", "Nachname", "Position", "Suchbegriffskategorie", "E-Mail-Adresse", "LinkedIn-Link", "Timestamp"]
                      # Schaetzen Sie die Anzahl der Zeilen und Spalten fuer das neue Blatt (kann angepasst werden)
                      num_cols_contacts_sheet = len(contacts_header)
                      # Erstellen Sie das neue Blatt
                      contacts_sheet = self.sheet_handler.sheet.spreadsheet.add_worksheet(title="Contacts", rows="5000", cols=num_cols_contacts_sheet)
                      # Schreiben Sie den Header in die erste Zeile des neuen Blattes
                      # Nutzt _get_col_letter interne Methode des SheetHandlers (Block 14)
                      contacts_sheet.update(values=[contacts_header], range_name=f"A1:{self.sheet_handler._get_col_letter(num_cols_contacts_sheet)}1")
                      self.logger.info("Neues Blatt 'Contacts' erstellt und Header eingetragen.") # <<< GEÄNDERT

                 except Exception as e_create_sheet:
                      # Fange Fehler bei der Erstellung des Blattes ab und logge sie.
                      self.logger.critical(f"FEHLER: Konnte Blatt 'Contacts' nicht erstellen: {e_create_sheet}. Kontakt-Details koennen NICHT gespeichert werden.") # <<< GEÄNDERT
                      # Logge den Traceback.
                      self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
                      contacts_sheet = None # Setze contacts_sheet auf None, um spaetere Schreibversuche zu verhindern

        else:
             # Wenn SheetHandler oder Sheet-Objekt nicht verfuegbar war.
             self.logger.warning("SheetHandler oder Sheet-Objekt nicht verfuegbar. Kann Blatt 'Contacts' nicht oeffnen/erstellen. Kontakt-Details werden NICHT gespeichert.") # <<< GEÄNDERT
             contacts_sheet = None # Sicherstellen, dass contacts_sheet None ist


        # --- Verarbeitung ---
        all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Hauptblatt (Liste von Dicts)
        all_contact_rows_to_append = [] # Gesammelte Zeilen fuer append_rows ins Contacts-Blatt (Liste von Listen)
        # append_rows kann grosse Batches handhaben, wir koennen hier mehr sammeln als beim Batch-Update.
        # Oder wir schreiben pro Firma in das Contacts-Blatt (weniger sammelbar).
        # Fuer diesen Modus sammeln wir alle Kontaktzeilen und schreiben am Ende gesammelt mit append_rows.


        processed_count = 0 # Zaehlt Zeilen im Hauptblatt, die fuer die Verarbeitung in Frage kommen und in den Batch aufgenommen werden (im Rahmen des Limits).
        skipped_count = 0 # Zaehlt Zeilen im Hauptblatt, die uebersprungen wurden (wegen AM oder fehlender Daten).


        # Aktueller Zeitstempel fuer die AM Timestamp und Kontaktzeilen
        now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")


        # Iteriere durch die Datenzeilen im definierten Bereich (1-basierte Sheet-Zeilennummer)
        for i in range(start_sheet_row, end_sheet_row + 1):
            row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
            # Pruefen Sie, ob das Ende des Sheets erreicht wurde
            if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht


            row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile


            # Stellen Sie sicher, dass die Zeile nicht leer ist
            if not any(cell and isinstance(cell, str) and cell.strip() for cell in row):
                 #self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug
                 skipped_count += 1 # Zaehlen als uebersprungen
                 continue # Springe zur naechsten Zeile


            # --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
            # Kriterium: Contact Search Timestamp (AM) ist leer.
            # ZUSAETZLICH: Pruefen, ob CRM Name, Kurzform und Website vorhanden und gueltig sind.

            # Holen Sie den Wert aus Spalte AM (Contact Search Timestamp) (nutzt interne Helfer _get_cell_value_safe)
            am_value = self._get_cell_value_safe(row, "Contact Search Timestamp").strip() # Block 1 Column Map
            # Pruefung basiert darauf, ob AM leer ist.
            processing_needed_based_on_status = not am_value


            # Holen Sie die benoetigten Daten fuer die Suche (nutzt interne Helfer _get_cell_value_safe)
            company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map
            crm_kurzform = self._get_cell_value_safe(row, "CRM Kurzform").strip() # Block 1 Column Map
            website = self._get_cell_value_safe(row, "CRM Website").strip() # Block 1 Column Map

            # Pruefen Sie, ob die Mindestdaten fuer die Suche vorhanden und gueltig sind.
            # Name und Kurzform duerfen nicht leer sein. Website muss vorhanden und gueltig aussehen.
            has_min_data_for_search = company_name and crm_kurzform and website and isinstance(website, str) and website.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"] # Fuege "http:" hinzu


            # Verarbeitung ist noetig, wenn AM leer ist UND die Mindestdaten fuer die Suche vorhanden sind.
            processing_needed_for_row = processing_needed_based_on_status and has_min_data_for_search


            # Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
            log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
            if log_check:
                 company_name_log = company_name[:50] + '...' if len(company_name) > 50 else company_name # Gekuerzt loggen
                 self.logger.debug(f"Zeile {i} ({company_name_log} Contact Check): AM leer? {processing_needed_based_on_status}, Mindestdaten gueltig? {has_min_data_for_search}. Benötigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT


            # Wenn die Verarbeitung fuer diese Zeile nicht noetig ist
            if not processing_needed_for_row:
                skipped_count += 1 # Zaehlen als uebersprungene Zeile
                continue # Springe zur naechsten Zeile


            # --- Wenn Verarbeitung noetig: Fuehre LinkedIn Suche(n) aus ---
            processed_count += 1 # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im Rahmen des Limits zaehlen)

            # Pruefe das Limit fuer verarbeitete Zeilen
            if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit:
                 # Wenn das Limit erreicht ist und es ein positives Limit gibt
                 self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_contact_search erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
                 break # Brich die Schleife ab


            self.logger.info(f"Zeile {i}: Suche LinkedIn Kontakte fuer '{crm_kurzform[:50]}...' ({website[:50]}...)...") # <<< GEÄNDERT


            all_found_contacts_for_row = [] # Liste zum Sammeln aller gefundenen Kontakte fuer DIESE Zeile (Liste von Dicts)
            contact_counts_for_row = {key: 0 for key in positions_to_search.keys()} # Dictionary zum Zaehlen der Treffer pro Kategorie fuer diese Zeile (AI-AL)


            # Führe die Suche fuer jede Positionskategorie durch.
            # positions_to_search Dictionary ist oben definiert.
            for category, queries in positions_to_search.items():
                # Führe die Suche fuer jede spezifische Abfrage innerhalb der Kategorie durch.
                # search_linkedin_contacts (Block 10) nutzt den retry_on_failure Decorator (Block 2).
                # Wenn search_linkedin_contacts fehlschlaegt, wirft es eine Exception oder gibt eine leere Liste zurueck.
                found_contacts_in_category = {} # Dictionary zum Sammeln eindeutiger Kontakte {linkedin_url: contact_data} fuer diese Kategorie

                for position_query in queries:
                    self.logger.debug(f"  -> Suche nach Position: '{position_query}' bei '{crm_kurzform[:50]}'...") # <<< GEÄNDERT
                    try:
                        # Rufe die globale Funktion search_linkedin_contacts auf (Block 10).
                        # Limitieren Sie die Anzahl der SerpAPI Ergebnisse pro Query, um Kosten zu managen.
                        contacts_from_query = search_linkedin_contacts(
                            company_name=company_name, # Voller Name fuer Kontext (optional genutzt)
                            website=website, # Website fuer Email-Generierung spaeter
                            position_query=position_query, # Die spezifische Position
                            crm_kurzform=crm_kurzform, # Die Kurzform der Firma
                            num_results=getattr(Config, 'SERPAPI_LINKEDIN_RESULTS_PER_QUERY', 5) # Konfigurierbar in Config (Block 1)
                        )

                        # Fuege die gefundenen Kontakte (mit Suchkategorie) zur Liste fuer diese Kategorie hinzu, dedupliziert ueber URL.
                        for contact in contacts_from_query:
                            linkedin_url = contact.get("LinkedInURL")
                            if linkedin_url and isinstance(linkedin_url, str) and linkedin_url.strip(): # Stelle sicher, dass URL gueltig ist
                                if linkedin_url not in found_contacts_in_category:
                                    # Wenn die URL noch nicht in dieser Kategorie gefunden wurde, fuege den Kontakt hinzu.
                                    contact["Suchbegriffskategorie"] = category # Speichere die Kategorie, die den Treffer brachte
                                    found_contacts_in_category[linkedin_url] = contact
                                # else: Wenn die URL bereits gefunden wurde, mache nichts (erste Kategorie wird beibehalten).
                                # self.logger.debug(f"    -> Gefunden: {contact.get('Vorname')} {contact.get('Nachname')} ({contact.get('Position')})") # Zu viel Laerm im Debug


                    except Exception as e_linkedin_search:
                         # Wenn search_linkedin_contacts eine Exception wirft (nach Retries)
                         # Der Fehler wird bereits vom retry_on_failure Decorator oder search_linkedin_contacts geloggt.
                         self.logger.error(f"FEHLER bei search_linkedin_contacts fuer Zeile {i} (Query: '{position_query}', Firma: '{crm_kurzform[:50]}...'): {e_linkedin_search}") # <<< GEÄNDERT
                         pass # Faert fort mit der naechsten Query oder Kategorie

                    # Pause nach jeder SerpAPI Suche (pro position_query)
                    # Nutzt Config.SERPAPI_DELAY (Block 1).
                    serp_delay = getattr(Config, 'SERPAPI_DELAY', 1.5)
                    #self.logger.debug(f"Warte {serp_delay:.2f}s nach LinkedIn Suche fuer '{position_query}'...") # Zu viel Laerm im Debug
                    time.sleep(serp_delay)

                # Zaehle die eindeutigen Treffer in dieser Kategorie nach allen Queries innerhalb der Kategorie.
                contact_counts_for_row[category] = len(found_contacts_in_category)
                # Fuege die eindeutigen Kontakte DIESER Kategorie zur Gesamtliste fuer DIESE Zeile hinzu.
                all_found_contacts_for_row.extend(found_contacts_in_category.values())


            # --- Verarbeite gefundene Kontakte und bereite Updates vor ---
            rows_to_append_to_contacts_sheet = [] # Liste von Listen fuer append_rows ins 'Contacts' Blatt
            main_sheet_updates_for_row = [] # Updates fuer das Hauptblatt (AI-AL, AM) fuer DIESE Zeile
            timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Timestamp fuer DIESE Zeile/Kontakte


            # Fuegen Sie die Updates fuer die Trefferzahlen im Hauptblatt hinzu (nutzt interne Helfer _get_col_letter Block 14)
            # Stellen Sie sicher, dass die Spaltenbuchstaben korrekt sind (AI, AJ, AK, AL) (aus oben ermittelt)
            main_sheet_updates_for_row.append({'range': f'{ai_letter}{i}', 'values': [[str(contact_counts_for_row.get("Serviceleiter", 0))]]})
            main_sheet_updates_for_row.append({'range': f'{aj_letter}{i}', 'values': [[str(contact_counts_for_row.get("IT-Leiter", 0))]]})
            main_sheet_updates_for_row.append({'range': f'{ak_letter}{i}', 'values': [[str(contact_counts_for_row.get("Management", 0))]]})
            main_sheet_updates_for_row.append({'range': f'{al_letter}{i}', 'values': [[str(contact_counts_for_row.get("Disponent", 0))]]})
            # Setze den Contact Search Timestamp (AM) fuer DIESE Zeile
            main_sheet_updates_for_row.append({'range': f'{ts_am_letter}{i}', 'values': [[timestamp]]})


            # Sammeln Sie diese Updates fuer das Hauptblatt in der globalen Liste all_sheet_updates.
            all_sheet_updates.extend(main_sheet_updates_for_row)
            self.logger.info(f"Zeile {i}: Kontaktzahlen gesammelt: {contact_counts_for_row} – Timestamp AM vorgemerkt fuer Update.") # <<< GEÄNDERT


            # Bereiten Sie die Zeilen fuer das 'Contacts' Blatt vor (falls es existiert).
            # all_found_contacts_for_row enthaelt alle gefundenen Kontakte fuer DIESE Zeile (mit evtl. Duplikaten bei URL).
            if contacts_sheet: # Pruefen Sie, ob das Contacts-Sheet geoeffnet/erstellt werden konnte (siehe Initialisierung oben)
                 # Führen Sie eine finale Deduplizierung ueber die LinkedIn-URL durch.
                 # Dictionary-Comprehension behält nur das letzte Vorkommen bei Duplikaten.
                 unique_contacts_for_row_dict = {c['LinkedInURL']: c for c in all_found_contacts_for_row if c.get('LinkedInURL')} # Filtere Kontakte ohne URL
                 unique_contacts_for_row = list(unique_contacts_for_row_dict.values()) # Liste der eindeutigen Kontakte

                 # Iteriere ueber die eindeutigen Kontakte fuer diese Zeile
                 for contact in unique_contacts_for_row:
                     firstname = contact.get("Vorname", "") # Nutzt den extrahierten Vornamen
                     lastname = contact.get("Nachname", "") # Nutzt den extrahierten Nachnamen

                     # Generiere Geschlecht und E-Mail-Adresse (nutzt globale Funktionen Block 5).
                     # get_gender und get_email_address behandeln leere/ungueltige Eingaben.
                     gender_value = get_gender(firstname)
                     email = get_email_address(firstname, lastname, website) # Nutzt die Website der Firma (initial geladen)

                     # Erstellen Sie die Liste der Werte fuer eine Zeile im 'Contacts' Blatt.
                     contact_row = [
                         contact.get("Firmenname", ""), # Voller Firmenname
                         contact.get("CRM Kurzform", ""), # Firmenkurzform
                         contact.get("Website", ""), # Website der Firma
                         gender_value, # Generiertes Geschlecht
                         firstname, # Extrahierter Vorname
                         lastname, # Extrahierter Nachname
                         contact.get("Position", ""), # Extrahierte oder Fallback Position
                         contact.get("Suchbegriffskategorie", ""), # Kategorie, die den Treffer brachte
                         email, # Generierte E-Mail-Adresse
                         contact.get("LinkedInURL", ""), # URL des LinkedIn Profils
                         timestamp # Zeitstempel des Suchlaufs
                     ]
                     # Fuegen Sie diese Zeile zur Liste der Zeilen hinzu, die spaeter ins Contacts-Sheet geschrieben werden.
                     rows_to_append_to_contacts_sheet.append(contact_row)

                 # Wenn Zeilen zum Anfuegen gefunden wurden
                 if rows_to_append_to_contacts_sheet:
                     # Fuegen Sie diese Zeilen zur globalen Liste aller Kontakte hinzu, die spaeter angefuegt werden.
                     all_contact_rows_to_append.extend(rows_to_append_to_contacts_sheet)
                     self.logger.debug(f"  -> {len(rows_to_append_to_contacts_sheet)} eindeutige Kontakte fuer Zeile {i} zum Anfuegen an 'Contacts' vorgemerkt.") # <<< GEÄNDERT
                 else:
                     self.logger.debug(f"  -> Keine neuen Kontakte fuer Zeile {i} gefunden.") # <<< GEÄNDERT


            # Sende gesammelte Sheet Updates (Hauptblatt) wenn das Update-Batch-Limit erreicht ist.
            # update_batch_row_limit wird aus Config geholt (Block 1).
            # Updates pro Zeile im Hauptblatt sind 5 (AI-AL + AM). Anzahl der Zeilen = len(all_sheet_updates) / 5.
            rows_in_main_sheet_update_batch = len(all_sheet_updates) // 5

            if rows_in_main_sheet_update_batch >= update_batch_row_limit:
                 self.logger.debug(f"  Sende gesammelte Hauptblatt-Updates ({rows_in_main_sheet_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
                 # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
                 # Wenn es fehlschlaegt, wird es intern geloggt.
                 success = self.sheet_handler.batch_update_cells(all_sheet_updates)
                 if success:
                      self.logger.info(f"  Hauptblatt-Update fuer {rows_in_main_sheet_update_batch} Zeilen erfolgreich.") # <<< GEÄNDERT
                   # Der Fehlerfall wird von batch_update_cells geloggt

                 # Leere die gesammelten Updates nach dem Senden.
                 all_sheet_updates = []


            # Eine laengere Pause nach der Verarbeitung jeder Firma im Contact Search Modus.
            # Dieser Modus ist API-intensiv und sollte langsamer laufen.
            # Nutzt Config.RETRY_DELAY (Block 1).
            pause_duration = getattr(Config, 'RETRY_DELAY', 10) * 0.8 # Laengere Pause, z.B. 80% der Retry-Wartezeit
            self.logger.debug(f"Warte {pause_duration:.2f}s nach Verarbeitung von Zeile {i}...") # <<< GEÄNDERT
            time.sleep(pause_duration)


        # --- Finale Sheet Updates (Hauptblatt) senden ---
        # Sende alle verbleibenden gesammelten Updates in einem letzten Batch-Update.
        if all_sheet_updates:
            rows_in_final_main_sheet_update_batch = len(all_sheet_updates) // 5
            self.logger.info(f"Sende FINALE gesammelte Hauptblatt-Updates ({rows_in_final_main_sheet_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
            # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
            success = self.sheet_handler.batch_update_cells(all_sheet_updates)
            if success:
                self.logger.info(f"FINALES Hauptblatt-Update erfolgreich.") # <<< GEÄNDERT
            # Der Fehlerfall wird von batch_update_cells geloggt


        # --- Finale Kontakte-Zeilen (Contacts Sheet) anfuegen ---
        # Fuege alle gesammelten Kontaktzeilen auf einmal ans Ende des 'Contacts' Blattes an.
        if contacts_sheet and all_contact_rows_to_append:
            self.logger.info(f"Fuege {len(all_contact_rows_to_append)} gesammelte Kontaktzeilen an Blatt 'Contacts' an...") # <<< GEÄNDERT
            try:
                 # append_rows ist effizienter als batch_update fuer viele neue Zeilen am Ende.
                 # Die gspread.Worksheet.append_rows Methode kann Exceptions werfen (z.B. APIError),
                 # die hier gefangen werden koennen, wenn gewuenscht.
                 # Wenn sie eine Exception wirft, wird diese nicht von retry_on_failure auf
                 # process_contact_search behandelt, da append_rows nicht mit @retry_on_failure
                 # dekoriert ist. Sie muessten append_rows selbst in einen try/except Block packen oder
                 # es mit @retry_on_failure dekorieren (falls gspread es unterstuetzt).
                 # Fuer jetzt, fangen wir die Exception hier.
                 contacts_sheet.append_rows(all_contact_rows_to_append, value_input_option='USER_ENTERED') # Standard Option
                 self.logger.info(f"Anfuegen von {len(all_contact_rows_to_append)} Kontaktzeilen erfolgreich.") # <<< GEÄNDERT
            except Exception as e_append:
                 # Fange Fehler beim Anfuegen der Zeilen ab und logge sie.
                 self.logger.error(f"FEHLER beim Anfuegen von Kontaktzeilen an Blatt 'Contacts': {type(e_append).__name__} - {e_append}") # <<< GEÄNDERT
                 # Logge den Traceback.
                 self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
                 pass # Faert fort, der Rest des Skripts sollte nicht blockiert werden


        # Logge den Abschluss des Modus
        self.logger.info(f"Modus 'contact_search' abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {skipped_count} Zeilen uebersprungen.") # <<< GEÄNDERT
        # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt.

    # ==============================================================================
    # Ende DataProcessor Klasse Batch: SerpAPI Suchen & Contacts Block
    # ==============================================================================


    # ==========================================================================
    # === Utility Methods (URL Check & Update) =================================
    # ==========================================================================

    def process_url_check(self, start_sheet_row=None, end_sheet_row=None, limit=None):
        """
        Sucht nach Zeilen, die in Spalte AR mit URL_CHECK_MARKER oder bekannten "k.A. (Fehler...)"
        Mustern markiert sind UND bei denen der AY-Timestamp (SerpAPI Wiki Search Timestamp) leer ist
        (außer bei URL_CHECK_MARKER, der immer eine Suche auslöst).
        Versucht, eine neue URL ueber SerpAPI zu finden.
        Wenn erfolgreich und URL ist NEU: Aktualisiert D, loescht AR, setzt ReEval-Flag (A) und loescht Timestamps.
        Wenn URL identisch oder keine neue URL gefunden: AR wird entsprechend aktualisiert.
        Setzt immer den AY-Timestamp (als Timestamp der URL-Prüfung).

        Args:
            start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (ab Zeile 7).
            end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
            limit (int, optional): Maximale Anzahl ZU VERARBEITENDER Zeilen. Defaults to None (Unbegrenzt).
        """
        self.logger.info(f"Starte Modus 'check_urls'. Sucht nach '{URL_CHECK_MARKER}' oder 'k.A. (Fehler...)' in AR. Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...")
        
        # --- Konfiguration holen ---
        update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) # <<< NEUE ZEILE HINZUGEFÜGT
        
        if not self.sheet_handler.load_data():
             self.logger.error("Fehler beim Laden der Daten fuer URL Check.")
             return

        all_data = self.sheet_handler.get_all_data_with_headers()
        header_rows = self.sheet_handler._header_rows
        total_sheet_rows = len(all_data)

        if start_sheet_row is None: start_sheet_row = header_rows + 1
        if end_sheet_row is None: end_sheet_row = total_sheet_rows

        self.logger.info(f"Suchbereich fuer URL Checks: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}.")
        if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
             self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.")
             return

        required_keys = [
            "Website Rohtext", "CRM Name", "CRM Website", "ReEval Flag",
            "Website Scrape Timestamp", "Timestamp letzte Pruefung",
            "Wikipedia Timestamp", "Wiki Verif. Timestamp", "SerpAPI Wiki Search Timestamp",
            "Version"
        ]
        col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
        if None in col_indices.values():
            missing = [k for k, v in col_indices.items() if v is None]
            self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_url_check: {missing}. Breche ab.")
            return

        ar_letter = self.sheet_handler._get_col_letter(col_indices["Website Rohtext"] + 1)
        d_letter = self.sheet_handler._get_col_letter(col_indices["CRM Website"] + 1)
        a_letter = self.sheet_handler._get_col_letter(col_indices["ReEval Flag"] + 1)
        at_letter = self.sheet_handler._get_col_letter(col_indices["Website Scrape Timestamp"] + 1)
        ao_letter = self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Pruefung"] + 1)
        an_letter = self.sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"] + 1)
        ax_letter = self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"] + 1)
        ay_letter = self.sheet_handler._get_col_letter(col_indices["SerpAPI Wiki Search Timestamp"] + 1) # Timestamp dieser Funktion
        ap_letter = self.sheet_handler._get_col_letter(col_indices["Version"] + 1)

        ka_error_patterns = [
            "k.A.", "k.A. (Extraktion leer)", "k.A. (Nur Cookie-Banner erkannt)",
            "k.A. (Kein Body gefunden)", "k.A. (Fehler Parsing:", "k.A. (Unerwarteter Fehler Task)",
            "k.A. (Fehler Scraping:", "k.A. (Timeout", "k.A. (SSL Fehler",
            "k.A. (Connection Error", "k.A. (HTTP Error", URL_CHECK_MARKER
        ]

        all_sheet_updates = []
        processed_count = 0
        skipped_count = 0
        found_new_url_count = 0
        now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

        for i in range(start_sheet_row, end_sheet_row + 1):
            row_index_in_list = i - 1
            if row_index_in_list >= total_sheet_rows: break

            row = all_data[row_index_in_list]
            if not any(cell and isinstance(cell, str) and cell.strip() for cell in row):
                 skipped_count += 1
                 continue

            ar_value = self._get_cell_value_safe(row, "Website Rohtext").strip()
            ay_timestamp_value = self._get_cell_value_safe(row, "SerpAPI Wiki Search Timestamp").strip() # Verwende den spezifischen Timestamp für diese Funktion

            processing_needed_for_row = False
            is_marker_case = ar_value == URL_CHECK_MARKER
            is_ka_error_case = any(pattern in ar_value for pattern in ka_error_patterns if pattern != URL_CHECK_MARKER)

            if is_marker_case: # URL_CHECK_MARKER löst immer eine Suche aus
                processing_needed_for_row = True
            elif is_ka_error_case and not ay_timestamp_value: # Alte k.A.-Fehler nur, wenn AY-Timestamp noch nicht gesetzt wurde
                processing_needed_for_row = True

            if not processing_needed_for_row:
                 skipped_count += 1
                 continue

            processed_count += 1
            if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit:
                 self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_url_check erreicht.")
                 break

            company_name = self._get_cell_value_safe(row, "CRM Name").strip()
            old_crm_website_url = self._get_cell_value_safe(row, "CRM Website").strip()
            normalized_old_crm_url = simple_normalize_url(old_crm_website_url)

            if not company_name:
                self.logger.warning(f"Zeile {i}: Uebersprungen (kein Firmenname fuer Suche vorhanden).")
                skipped_count += 1
                updates_for_row_skip = [{'range': f'{ay_letter}{i}', 'values': [[now_timestamp_str]]}] # Timestamp trotzdem setzen
                all_sheet_updates.extend(updates_for_row_skip)
                continue

            self.logger.info(f"Zeile {i}: AR='{ar_value[:50]}...'. Suche neue URL für '{company_name[:50]}...' (Aktuell D: '{old_crm_website_url[:50]}...')...")

            updates_for_row = []
            new_url_found_str = "k.A."

            try:
                 new_url_found_str = serp_website_lookup(company_name)
                 normalized_new_url = simple_normalize_url(new_url_found_str)

                 if new_url_found_str != "k.A." and normalized_new_url != "k.A.":
                      if normalized_new_url != normalized_old_crm_url:
                           self.logger.info(f"  -> Neue, andere URL gefunden: {new_url_found_str}. Alte war: '{old_crm_website_url}'. Bereite Update vor.")
                           found_new_url_count += 1
                           updates_for_row.append({'range': f'{d_letter}{i}', 'values': [[new_url_found_str]]})
                           updates_for_row.append({'range': f'{ar_letter}{i}', 'values': [['']]})
                           updates_for_row.append({'range': f'{a_letter}{i}', 'values': [['x']]})
                           updates_for_row.append({'range': f'{at_letter}{i}', 'values': [['']]})
                           updates_for_row.append({'range': f'{ao_letter}{i}', 'values': [['']]})
                           updates_for_row.append({'range': f'{an_letter}{i}', 'values': [['']]})
                           updates_for_row.append({'range': f'{ax_letter}{i}', 'values': [['']]})
                           updates_for_row.append({'range': f'{ay_letter}{i}', 'values': [['']]}) # Wird unten explizit neu gesetzt
                           updates_for_row.append({'range': f'{ap_letter}{i}', 'values': [['']]})
                      else:
                           self.logger.info(f"  -> SerpAPI fand URL '{new_url_found_str}', aber diese ist identisch mit der bereits vorhandenen URL in Spalte D. Keine Änderung in D.")
                           updates_for_row.append({'range': f'{ar_letter}{i}', 'values': [["k.A. (URL via SerpAPI identisch mit alter URL)"]]})
                 else:
                      self.logger.warning(f"  -> Keine neue gueltige URL via SerpAPI für '{company_name[:50]}...' gefunden. Setze AR auf 'k.A. (Keine URL bei Neusuche)'.")
                      updates_for_row.append({'range': f'{ar_letter}{i}', 'values': [["k.A. (Keine URL bei Neusuche)"]]})

            except Exception as e_serp_lookup:
                 self.logger.error(f"FEHLER bei SERP Website Lookup für Zeile {i} ('{company_name[:50]}...'): {e_serp_lookup}")
                 updates_for_row.append({'range': f'{ar_letter}{i}', 'values': [[f"k.A. (Fehler URL Suche)"]]})
                 pass

            updates_for_row.append({'range': f'{ay_letter}{i}', 'values': [[now_timestamp_str]]}) # AY Timestamp immer setzen
            all_sheet_updates.extend(updates_for_row)

            if len(all_sheet_updates) >= update_batch_row_limit * 3 : # Angepasst, da Anzahl Updates variiert
                 self.logger.debug(f"  Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Operationen)...")
                 success = self.sheet_handler.batch_update_cells(all_sheet_updates)
                 if success: self.logger.info(f"  Sheet-Update für {len(all_sheet_updates)} Operationen erfolgreich.")
                 all_sheet_updates = []

            serp_delay = getattr(Config, 'SERPAPI_DELAY', 1.5)
            time.sleep(serp_delay)

        if all_sheet_updates:
            self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Operationen)...")
            success = self.sheet_handler.batch_update_cells(all_sheet_updates)
            if success: self.logger.info(f"FINALES Sheet-Update erfolgreich.")

        self.logger.info(f"Modus 'check_urls' abgeschlossen. {processed_count} Zeilen mit Marker/Fehler verarbeitet, {found_new_url_count} neue URLs gefunden, {skipped_count} Zeilen uebersprungen.")

    def process_repair_sitz_data(self, start_sheet_row=None, end_sheet_row=None, limit=None):
        """
        Liest bestehende Sitz-Stadt/Land-Angaben, wendet die verbesserte Parsing-Logik
        an und aktualisiert das Sheet, falls sich Änderungen ergeben.
        """
        self.logger.info(f"Starte Modus 'Sitz-Daten Reparatur'. Bereich: {start_sheet_row if start_sheet_row is not None else 'Komplett ab Datenstart'}, End: {end_sheet_row if end_sheet_row else 'Sheet-Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...")

        if not self.sheet_handler.load_data():
            self.logger.error("Konnte Sheet-Daten nicht laden für Sitz-Reparatur. Abbruch.")
            return

        all_data = self.sheet_handler.get_all_data_with_headers()
        header_offset = self.sheet_handler._header_rows
        
        stadt_col_idx = COLUMN_MAP.get("Wiki Sitz Stadt")
        land_col_idx = COLUMN_MAP.get("Wiki Sitz Land")
        # Optional: Eine Spalte für den originalen Roh-Sitz-String, falls vorhanden
        # roh_sitz_col_idx = COLUMN_MAP.get("IHRE_ROH_SITZ_SPALTE") 

        if stadt_col_idx is None or land_col_idx is None:
            self.logger.error("Spaltenindizes für 'Wiki Sitz Stadt' oder 'Wiki Sitz Land' nicht in COLUMN_MAP. Abbruch.")
            return

        updates_fuer_sheet = []
        processed_rows_count = 0
        updated_rows_count = 0

        effective_start_row = start_sheet_row if start_sheet_row is not None else header_offset + 1
        effective_end_row = end_sheet_row if end_sheet_row is not None else len(all_data)

        self.logger.info(f"Prüfe Zeilen {effective_start_row} bis {effective_end_row} für Sitz-Reparatur.")

        for row_num_sheet in range(effective_start_row, effective_end_row + 1):
            if limit is not None and processed_rows_count >= limit:
                self.logger.info(f"Limit von {limit} erreichten Zeilen für Sitz-Reparatur erreicht.")
                break

            row_list_idx = row_num_sheet - 1
            if row_list_idx >= len(all_data): break # Ende der Daten erreicht

            row_data = all_data[row_list_idx]
            
            aktuelle_stadt = self._get_cell_value_safe(row_data, "Wiki Sitz Stadt")
            aktuelle_land = self._get_cell_value_safe(row_data, "Wiki Sitz Land")

            # Erzeuge den Input-String für die Parsing-Funktion
            # Besser: Wenn Sie den *ursprünglichen* String aus der Wikipedia Infobox
            # in einer separaten Spalte gespeichert hätten, würden Sie diesen hier verwenden.
            # Als Fallback kombinieren wir aktuelle Stadt und Land.
            input_sitz_string = aktuelle_stadt
            if aktuelle_land and aktuelle_land.lower() not in ["", "k.a."]:
                if input_sitz_string and input_sitz_string.lower() not in ["", "k.a."]:
                    input_sitz_string += f", {aktuelle_land}" # Kombiniere mit Komma
                else:
                    input_sitz_string = aktuelle_land # Wenn Stadt leer/kA, nimm nur Land

            if not input_sitz_string or not input_sitz_string.strip() or input_sitz_string.lower() == 'k.a.':
                # self.logger.debug(f"Zeile {row_num_sheet}: Keine validen aktuellen Sitzdaten ('{aktuelle_stadt}', '{aktuelle_land}') zum Reparieren.")
                continue
            
            processed_rows_count += 1
            
            try:
                # Verwende die neue Parsing-Methode des WikipediaScrapers
                # Stellen Sie sicher, dass self.wiki_scraper eine Instanz von WikipediaScraper ist
                parsed_sitz_info = self.wiki_scraper._parse_sitz_string_detailed(input_sitz_string)
                neue_stadt = parsed_sitz_info.get('sitz_stadt', 'k.A.')
                neues_land = parsed_sitz_info.get('sitz_land', 'k.A.')

                # Nur updaten, wenn sich etwas geändert hat
                if (neue_stadt != aktuelle_stadt and not (neue_stadt == "k.A." and aktuelle_stadt == "")) or \
                   (neues_land != aktuelle_land and not (neues_land == "k.A." and aktuelle_land == "")):
                    self.logger.info(f"Zeile {row_num_sheet}: SITZ-UPDATE. Input: '{input_sitz_string[:60]}...' Alt: '{aktuelle_stadt} / {aktuelle_land}' -> Neu: '{neue_stadt} / {neues_land}'")
                    updates_fuer_sheet.append({
                        'range': f'{self.sheet_handler._get_col_letter(stadt_col_idx + 1)}{row_num_sheet}',
                        'values': [[neue_stadt]]
                    })
                    updates_fuer_sheet.append({
                        'range': f'{self.sheet_handler._get_col_letter(land_col_idx + 1)}{row_num_sheet}',
                        'values': [[neues_land]]
                    })
                    updated_rows_count += 1
                # else:
                    # self.logger.debug(f"Zeile {row_num_sheet}: Keine Änderung bei Sitzdaten für Input '{input_sitz_string[:60]}...'. Alt: '{aktuelle_stadt} / {aktuelle_land}', Neu: '{neue_stadt} / {neues_land}'")


            except Exception as e_parse:
                self.logger.error(f"Fehler beim Parsen des Sitzes für Zeile {row_num_sheet} mit Input '{input_sitz_string}': {e_parse}")

            # Batch-Update Logik
            if len(updates_fuer_sheet) >= getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) * 2: # Mal 2, da zwei Spalten pro Zeile
                self.logger.info(f"Sende Batch-Update für {len(updates_fuer_sheet)//2} Sitzreparaturen...")
                self.sheet_handler.batch_update_cells(updates_fuer_sheet)
                updates_fuer_sheet = []
                # time.sleep(1) # Optionale Pause

        # Letzten Batch senden
        if updates_fuer_sheet:
            self.logger.info(f"Sende finalen Batch-Update für {len(updates_fuer_sheet)//2} Sitzreparaturen...")
            self.sheet_handler.batch_update_cells(updates_fuer_sheet)

        self.logger.info(f"Sitz-Daten Reparatur abgeschlossen. {processed_rows_count} Zeilen geprüft, {updated_rows_count} Zeilen aktualisiert.")



    # Innerhalb der DataProcessor Klasse
    # Innerhalb der DataProcessor Klasse (ersetzen Sie Ihre bestehende Version vollständig hiermit)
    def _get_numeric_value_for_plausi(self, value_str, is_umsatz=False):
        logger = logging.getLogger(__name__ + "._get_numeric_value_for_plausi")
        
        # Schritt 1: Nutze die globale, verbesserte extract_numeric_value, 
        # um einen sauberen Zahlenstring oder "k.A."/"0" zu bekommen.
        # extract_numeric_value liefert Umsatz bereits in Mio, MA absolut.
        extracted_val_str = extract_numeric_value(value_str, is_umsatz)

        if extracted_val_str.lower() in ['k.a.']:
            logger.debug(f"Input '{value_str}' -> extrahiert zu '{extracted_val_str}' -> NaN (als 'unbekannt' interpretiert).")
            return np.nan
        
        # Wenn extract_numeric_value "0" zurückgibt, bedeutet das nach Ihrer Regel "unbekannt".
        if extracted_val_str == "0":
             logger.debug(f"Input '{value_str}' -> extrahiert zu '{extracted_val_str}' -> NaN (als 'unbekannt' interpretiert).")
             return np.nan
             
        try:
            # extracted_val_str sollte jetzt eine saubere Zahl als String sein (z.B. "173", "4380")
            # oder es wurde schon "k.A." zurückgegeben.
            num_val_sheet_unit = float(extracted_val_str) # z.B. 173.0 (Mio) für Umsatz "173"

            final_num_absolute = num_val_sheet_unit
            if is_umsatz:
                # num_val_sheet_unit ist bereits in Mio, konvertiere zu absolutem Euro für Plausi-Schwellenwerte
                final_num_absolute = num_val_sheet_unit * 1000000.0
            # Für Mitarbeiter ist num_val_sheet_unit bereits die absolute Zahl

            return final_num_absolute

        except ValueError:
            logger.debug(f"ValueError bei Konvertierung von '{extracted_val_str}' (aus Input '{value_str}') zu float in _get_numeric_value_for_plausi.")
            return np.nan
        except Exception as e_general:
            logger.error(f"Unerwarteter Fehler in _get_numeric_value_for_plausi für '{value_str[:50]}...': {e_general}")
            return np.nan



    def _check_financial_plausibility(self, row_data_dict):
        results = {
            "plaus_umsatz_flag": "NICHT_PRUEFBAR", "plaus_ma_flag": "NICHT_PRUEFBAR",
            "plaus_ratio_flag": "NICHT_PRUEFBAR", "abweichung_umsatz_flag": "N/A",
            "abweichung_ma_flag": "N/A",
            "plausi_begruendung_final": "Plausibilität OK" 
        }
        temp_begruendungen = [] 

        parent_account_name_d_val = row_data_dict.get("Parent Account Name", "").strip() 
        is_konzern_tochter_laut_d = bool(parent_account_name_d_val and parent_account_name_d_val.lower() != 'k.a.')

        # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
        # +++ NEU/ERWEITERT: Info aus Spalte O und P für Konzernlogik heranziehen +++
        # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
        # Diese Keys müssen im row_data_dict vorhanden sein, wenn diese Funktion aufgerufen wird!
        parent_o_val = row_data_dict.get("System Vorschlag Parent Account", "").strip().lower()
        parent_p_val = row_data_dict.get("Parent Vorschlag Status", "").strip().lower()
        is_konzern_tochter_laut_o_und_p = bool(parent_o_val and parent_o_val != 'k.a.' and parent_p_val == 'x')
        
        # (Das `wiki_stammt_von_parent_explizit` Flag ist optional, wenn Sie den anderen Ansatz verfolgen)
        # wiki_stammt_von_parent_explizit = row_data_dict.get("Wiki Daten von Parent", False) 

        is_part_of_a_group_for_plausi = is_konzern_tochter_laut_d or is_konzern_tochter_laut_o_und_p # or wiki_stammt_von_parent_explizit
        
        log_msg_group_parts = []
        if is_konzern_tochter_laut_d:
            log_msg_group_parts.append(f"D='{parent_account_name_d_val}'")
        if is_konzern_tochter_laut_o_und_p:
            log_msg_group_parts.append(f"O/P='{parent_o_val}/{parent_p_val}'")
        # if wiki_stammt_von_parent_explizit:
        #     log_msg_group_parts.append("WikiParentFlag=True")
        
        if is_part_of_a_group_for_plausi:
            self.logger.debug(f"    PlausiCheck: Unternehmen ist Teil einer Gruppe ({'; '.join(log_msg_group_parts)}). Abweichungs-Checks CRM/Wiki werden als INFO_KONZERN_LOGIK behandelt.")
        # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
        # +++ ENDE NEU/ERWEITERT ++++++++++++++++++++++++++++++++++++++++++++++++++
        # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++


        # --- 1. Plausibilität Finaler Umsatz (BG) ---
        final_umsatz_str = row_data_dict.get("Finaler Umsatz (Wiki>CRM)", "k.A.")
        umsatz_num_absolut = self._get_numeric_value_for_plausi(final_umsatz_str, is_umsatz=True)
        
        exclusion_list_common = ['k.a.', '', 'n/a', '-', '0', '0.0', '0,00', '0.000', '0.00']
        if pd.isna(umsatz_num_absolut):
            if final_umsatz_str.lower().strip() not in exclusion_list_common and not final_umsatz_str.startswith("FEHLER"):
                results["plaus_umsatz_flag"] = "FEHLER_FORMAT"
                temp_begruendungen.append(f"Finaler Umsatz ('{final_umsatz_str}') konnte nicht als Zahl interpretiert werden.")
        else: 
            results["plaus_umsatz_flag"] = "OK"
            if umsatz_num_absolut == 0 and final_umsatz_str != "0": 
                 results["plaus_umsatz_flag"] = "WARNUNG_NULL_WERT"
                 temp_begruendungen.append(f"Finaler Umsatz ist numerisch 0 (aus '{final_umsatz_str}').")
            elif umsatz_num_absolut < getattr(Config, 'PLAUSI_UMSATZ_MIN_WARNUNG', 50000):
                results["plaus_umsatz_flag"] = "WARNUNG_NIEDRIG"
                temp_begruendungen.append(f"Finaler Umsatz ({umsatz_num_absolut:,.0f} €) < Min-Schwelle ({getattr(Config, 'PLAUSI_UMSATZ_MIN_WARNUNG', 50000):,.0f} €).")
            elif umsatz_num_absolut > getattr(Config, 'PLAUSI_UMSATZ_MAX_WARNUNG', 200000000000):
                results["plaus_umsatz_flag"] = "WARNUNG_HOCH"
                temp_begruendungen.append(f"Finaler Umsatz ({umsatz_num_absolut:,.0f} €) > Max-Schwelle ({getattr(Config, 'PLAUSI_UMSATZ_MAX_WARNUNG', 200000000000):,.0f} €).")

        # --- 2. Plausibilität Finale Mitarbeiter (BH) ---
        final_ma_str = row_data_dict.get("Finaler Mitarbeiter (Wiki>CRM)", "k.A.")
        ma_num_absolut = self._get_numeric_value_for_plausi(final_ma_str, is_umsatz=False)
        
        if pd.isna(ma_num_absolut):
            if final_ma_str.lower().strip() not in exclusion_list_common and not final_ma_str.startswith("FEHLER"):
                results["plaus_ma_flag"] = "FEHLER_FORMAT"
                temp_begruendungen.append(f"Finale MA ('{final_ma_str}') konnte nicht als Zahl interpretiert werden.")
        else:
            results["plaus_ma_flag"] = "OK"
            if ma_num_absolut == 0 and final_ma_str != "0":
                 results["plaus_ma_flag"] = "WARNUNG_NULL_WERT"
                 temp_begruendungen.append(f"Finale MA ist numerisch 0 (aus '{final_ma_str}').")
            elif ma_num_absolut < getattr(Config, 'PLAUSI_MA_MIN_WARNUNG_ABS', 1):
                results["plaus_ma_flag"] = "WARNUNG_NIEDRIG"
                temp_begruendungen.append(f"Finale MA ({ma_num_absolut:.0f}) < Min-Schwelle ({getattr(Config, 'PLAUSI_MA_MIN_WARNUNG_ABS', 1):.0f}).")
            elif not pd.isna(umsatz_num_absolut) and umsatz_num_absolut >= getattr(Config, 'PLAUSI_UMSATZ_MIN_SCHWELLE_FUER_MA_CHECK', 1000000) and \
                 ma_num_absolut < getattr(Config, 'PLAUSI_MA_MIN_WARNUNG_BEI_UMSATZ', 3):
                results["plaus_ma_flag"] = "WARNUNG_ZU_WENIG_MA_BEI_UMSATZ"
                temp_begruendungen.append(f"Finale MA ({ma_num_absolut:.0f}) auffällig niedrig für Umsatz ({umsatz_num_absolut:,.0f} €).")
            elif ma_num_absolut > getattr(Config, 'PLAUSI_MA_MAX_WARNUNG', 1000000):
                results["plaus_ma_flag"] = "WARNUNG_HOCH"
                temp_begruendungen.append(f"Finale MA ({ma_num_absolut:.0f}) > Max-Schwelle ({getattr(Config, 'PLAUSI_MA_MAX_WARNUNG', 1000000):,.0f}).")

        # --- 3. Plausibilität Umsatz/MA Ratio (BI) ---
        if not pd.isna(umsatz_num_absolut) and not pd.isna(ma_num_absolut) and umsatz_num_absolut > 0 :
            if ma_num_absolut > 0:
                ratio = umsatz_num_absolut / ma_num_absolut
                results["plaus_ratio_flag"] = "OK"
                if ratio < getattr(Config, 'PLAUSI_RATIO_UMSATZ_PRO_MA_MIN', 25000):
                    results["plaus_ratio_flag"] = "WARNUNG_RATIO_NIEDRIG"
                    temp_begruendungen.append(f"Umsatz/MA Ratio ({ratio:,.0f} €/MA) < Min-Schwelle ({getattr(Config, 'PLAUSI_RATIO_UMSATZ_PRO_MA_MIN', 25000):,.0f} €/MA).")
                elif ratio > getattr(Config, 'PLAUSI_RATIO_UMSATZ_PRO_MA_MAX', 1500000):
                    results["plaus_ratio_flag"] = "WARNUNG_RATIO_HOCH"
                    temp_begruendungen.append(f"Umsatz/MA Ratio ({ratio:,.0f} €/MA) > Max-Schwelle ({getattr(Config, 'PLAUSI_RATIO_UMSATZ_PRO_MA_MAX', 1500000):,.0f} €/MA).")
            elif ma_num_absolut == 0: 
                results["plaus_ratio_flag"] = "FEHLER_MA_NULL_BEI_UMSATZ"
                temp_begruendungen.append("Umsatz vorhanden, aber MA ist 0. Ratio nicht berechenbar.")
        elif pd.isna(umsatz_num_absolut) or umsatz_num_absolut <= 0 : 
            results["plaus_ratio_flag"] = "NICHT_PRUEFBAR_UMSATZ_FEHLT"
        
        # --- 4. Abgleich CRM vs. Wiki (BJ, BK) ---
        crm_umsatz_str = row_data_dict.get("CRM Umsatz", "k.A.")
        wiki_umsatz_str = row_data_dict.get("Wiki Umsatz", "k.A.") 
        crm_ma_str = row_data_dict.get("CRM Anzahl Mitarbeiter", "k.A.")
        wiki_ma_str = row_data_dict.get("Wiki Mitarbeiter", "k.A.")  

        crm_u_abs = self._get_numeric_value_for_plausi(crm_umsatz_str, is_umsatz=True)
        wiki_u_abs = self._get_numeric_value_for_plausi(wiki_umsatz_str, is_umsatz=True)
        crm_m_abs_comp = self._get_numeric_value_for_plausi(crm_ma_str, is_umsatz=False)
        wiki_m_abs_comp = self._get_numeric_value_for_plausi(wiki_ma_str, is_umsatz=False)
        
        abweichung_prozent_config = getattr(Config, 'PLAUSI_ABWEICHUNG_CRM_WIKI_PROZENT', 30) / 100.0

        # Umsatz Abweichung (BJ)
        if pd.notna(crm_u_abs) and pd.notna(wiki_u_abs) and crm_u_abs > 0 and wiki_u_abs > 0 :
            if is_part_of_a_group_for_plausi: # ERWEITERTE BEDINGUNG HIER VERWENDEN
                results["abweichung_umsatz_flag"] = "INFO_KONZERN_LOGIK"
            else: 
                diff_umsatz = abs(crm_u_abs - wiki_u_abs)
                bezugswert_umsatz = max(crm_u_abs, wiki_u_abs) if max(crm_u_abs, wiki_u_abs) > 0 else 1 
                if (diff_umsatz / bezugswert_umsatz) > abweichung_prozent_config:
                    results["abweichung_umsatz_flag"] = "WARNUNG_SIGNIFIKANT"
                    temp_begruendungen.append(f"Umsatz CRM ({crm_u_abs:,.0f} €) vs. Wiki ({wiki_u_abs:,.0f} €) weicht >{abweichung_prozent_config*100:.0f}% ab.")
                else:
                    results["abweichung_umsatz_flag"] = "OK"
        elif pd.notna(crm_u_abs) and crm_u_abs > 0 and (pd.isna(wiki_u_abs) or wiki_u_abs <=0) : results["abweichung_umsatz_flag"] = "WIKI_FEHLT_ODER_NULL"
        elif (pd.isna(crm_u_abs) or crm_u_abs <=0) and pd.notna(wiki_u_abs) and wiki_u_abs > 0 : results["abweichung_umsatz_flag"] = "CRM_FEHLT_ODER_NULL"
        else: results["abweichung_umsatz_flag"] = "BEIDE_FEHLEN_ODER_NULL"
        
        # Mitarbeiter Abweichung (BK)
        if pd.notna(crm_m_abs_comp) and pd.notna(wiki_m_abs_comp) and crm_m_abs_comp > 0 and wiki_m_abs_comp > 0:
            if is_part_of_a_group_for_plausi: # ERWEITERTE BEDINGUNG HIER VERWENDEN
                results["abweichung_ma_flag"] = "INFO_KONZERN_LOGIK"
            else: 
                diff_ma = abs(crm_m_abs_comp - wiki_m_abs_comp)
                bezugswert_ma = max(crm_m_abs_comp, wiki_m_abs_comp) if max(crm_m_abs_comp, wiki_m_abs_comp) > 0 else 1
                if (diff_ma / bezugswert_ma) > abweichung_prozent_config:
                    results["abweichung_ma_flag"] = "WARNUNG_SIGNIFIKANT"
                    temp_begruendungen.append(f"MA CRM ({crm_m_abs_comp:.0f}) vs. Wiki ({wiki_m_abs_comp:.0f}) weicht >{abweichung_prozent_config*100:.0f}% ab.")
                else:
                    results["abweichung_ma_flag"] = "OK"
        elif pd.notna(crm_m_abs_comp) and crm_m_abs_comp > 0 and (pd.isna(wiki_m_abs_comp) or wiki_m_abs_comp <=0): results["abweichung_ma_flag"] = "WIKI_FEHLT_ODER_NULL"
        elif (pd.isna(crm_m_abs_comp) or crm_m_abs_comp <=0) and pd.notna(wiki_m_abs_comp) and wiki_m_abs_comp > 0: results["abweichung_ma_flag"] = "CRM_FEHLT_ODER_NULL"
        else: results["abweichung_ma_flag"] = "BEIDE_FEHLEN_ODER_NULL"

        if temp_begruendungen:
            results["plausi_begruendung_final"] = "; ".join(temp_begruendungen)
            
        return results


    def run_plausibility_checks_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None):
            self.logger.info(f"Starte Modus 'plausi_check_data' (Konsolidierung & Plausi-Checks). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}")

            if not self.sheet_handler.load_data():
                self.logger.error("Konnte Sheet-Daten nicht laden für Plausi-Checks. Abbruch.")
                return

            all_data = self.sheet_handler.get_all_data_with_headers()
            header_offset = self.sheet_handler._header_rows
            total_sheet_rows = len(all_data)

            effective_start_row = start_sheet_row if start_sheet_row is not None else header_offset + 1
            effective_end_row = end_sheet_row if end_sheet_row is not None else total_sheet_rows

            if effective_start_row > effective_end_row or effective_start_row > total_sheet_rows:
                self.logger.info("Start liegt nach Ende oder außerhalb des Sheets. Keine Zeilen zu verarbeiten.")
                return
            
            self.logger.info(f"Verarbeite Zeilen {effective_start_row} bis {effective_end_row} für Konsolidierung und Plausi-Checks.")

            required_keys_for_plausi_mode = [ # Schlüssel wie gehabt
                "CRM Umsatz", "Wiki Umsatz", "CRM Anzahl Mitarbeiter", "Wiki Mitarbeiter", "Parent Account Name",
                "Finaler Umsatz (Wiki>CRM)", "Finaler Mitarbeiter (Wiki>CRM)",
                "Plausibilität Umsatz", "Plausibilität Mitarbeiter", "Plausibilität Umsatz/MA Ratio",
                "Abweichung Umsatz CRM/Wiki", "Abweichung MA CRM/Wiki", "Plausibilität Begründung",
                "Plausibilität Prüfdatum", "CRM Name" # CRM Name für Logging hinzugefügt
            ]
            if not all(key in COLUMN_MAP for key in required_keys_for_plausi_mode):
                missing_k = [k for k in required_keys_for_plausi_mode if k not in COLUMN_MAP]
                self.logger.error(f"Nicht alle benötigten Spalten ({missing_k}) für Modus 'plausi_check_data' in COLUMN_MAP. Abbruch.")
                return

            all_sheet_updates = []
            processed_rows_count = 0
            now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            update_batch_limit_config = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)
            
            for row_num_sheet in range(effective_start_row, effective_end_row + 1):
                if limit is not None and processed_rows_count >= limit:
                    self.logger.info(f"Verarbeitungslimit von {limit} Zeilen erreicht.")
                    break
                
                row_list_idx = row_num_sheet - 1
                if row_list_idx >= total_sheet_rows: break
                row_data = all_data[row_list_idx]

                crm_name_check = self._get_cell_value_safe(row_data, "CRM Name").strip()
                if not crm_name_check:
                    continue
                
                self.logger.debug(f"Zeile {row_num_sheet} ({crm_name_check[:30]}...): Starte Konsolidierung und Plausi-Check.")
                current_row_updates = []

                # 1. Konsolidierung (BD, BE)
                final_umsatz_str_konsolidiert = "k.A." 
                final_ma_str_konsolidiert = "k.A."
                parent_account_name_d_val = self._get_cell_value_safe(row_data, "Parent Account Name").strip()
                parent_o_val_plausi = self._get_cell_value_safe(row_data, "System Vorschlag Parent Account").strip() # Für Plausi-Check holen
                parent_p_val_plausi = self._get_cell_value_safe(row_data, "Parent Vorschlag Status").strip()       # Für Plausi-Check holen


                try:
                    crm_umsatz_val_str = self._get_cell_value_safe(row_data, "CRM Umsatz")
                    # Wiki-Werte direkt aus der Zeile (row_data) lesen
                    wiki_umsatz_val_str_sheet = self._get_cell_value_safe(row_data, "Wiki Umsatz")
                    crm_ma_val_str = self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter")
                    wiki_ma_val_str_sheet = self._get_cell_value_safe(row_data, "Wiki Mitarbeiter")
                    
                    num_crm_umsatz = get_numeric_filter_value(crm_umsatz_val_str, is_umsatz=True)
                    num_wiki_umsatz = get_numeric_filter_value(wiki_umsatz_val_str_sheet, is_umsatz=True) # Verwende _sheet Wert
                    num_crm_ma = get_numeric_filter_value(crm_ma_val_str, is_umsatz=False)
                    num_wiki_ma = get_numeric_filter_value(wiki_ma_val_str_sheet, is_umsatz=False)       # Verwende _sheet Wert

                    if parent_account_name_d_val and parent_account_name_d_val.lower() != 'k.a.':
                        self.logger.debug(f"    -> Parent D ('{parent_account_name_d_val}') ist gesetzt. Konsolidiere primär mit CRM-Daten der Tochter.")
                        final_num_umsatz = num_crm_umsatz if num_crm_umsatz > 0 else num_wiki_umsatz
                        final_num_ma = num_crm_ma if num_crm_ma > 0 else num_wiki_ma
                    else:
                        final_num_umsatz = num_wiki_umsatz if num_wiki_umsatz > 0 else num_crm_umsatz
                        final_num_ma = num_wiki_ma if num_wiki_ma > 0 else num_crm_ma
                    
                    final_umsatz_str_konsolidiert = str(int(round(final_num_umsatz))) if final_num_umsatz > 0 else 'k.A.'
                    final_ma_str_konsolidiert = str(int(round(final_num_ma))) if final_num_ma > 0 else 'k.A.'
                except Exception as e_conso_batch:
                    self.logger.error(f"Fehler bei Konsolidierung in Plausi-Batch für Zeile {row_num_sheet}: {e_conso_batch}")
                    final_umsatz_str_konsolidiert = "FEHLER_KONSO_PLAUSI"
                    final_ma_str_konsolidiert = "FEHLER_KONSO_PLAUSI"

                current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Umsatz (Wiki>CRM)"] + 1)}{row_num_sheet}', 'values': [[final_umsatz_str_konsolidiert]]})
                current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Mitarbeiter (Wiki>CRM)"] + 1)}{row_num_sheet}', 'values': [[final_ma_str_konsolidiert]]})

                # 2. Plausibilitäts-Checks (BG-BM)
                if not final_umsatz_str_konsolidiert.startswith("FEHLER") and not final_ma_str_konsolidiert.startswith("FEHLER"):
                    try:
                        plausi_input_data = {
                            "Finaler Umsatz (Wiki>CRM)": final_umsatz_str_konsolidiert,
                            "Finaler Mitarbeiter (Wiki>CRM)": final_ma_str_konsolidiert,
                            "CRM Umsatz": self._get_cell_value_safe(row_data, "CRM Umsatz"),
                            "Wiki Umsatz": self._get_cell_value_safe(row_data, "Wiki Umsatz"), 
                            "CRM Anzahl Mitarbeiter": self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter"),
                            "Wiki Mitarbeiter": self._get_cell_value_safe(row_data, "Wiki Mitarbeiter"), 
                            "Parent Account Name": parent_account_name_d_val, 
                            "System Vorschlag Parent Account": parent_o_val_plausi, # Variable von oben
                            "Parent Vorschlag Status": parent_p_val_plausi        # Variable von oben
                        }
                        plausi_results = self._check_financial_plausibility(plausi_input_data)




                        current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Umsatz"] + 1)}{row_num_sheet}', 'values': [[plausi_results.get("plaus_umsatz_flag", "ERR_FLAG")]]})
                        current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Mitarbeiter"] + 1)}{row_num_sheet}', 'values': [[plausi_results.get("plaus_ma_flag", "ERR_FLAG")]]})
                        current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Umsatz/MA Ratio"] + 1)}{row_num_sheet}', 'values': [[plausi_results.get("plaus_ratio_flag", "ERR_FLAG")]]})
                        current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Abweichung Umsatz CRM/Wiki"] + 1)}{row_num_sheet}', 'values': [[plausi_results.get("abweichung_umsatz_flag", "ERR_FLAG")]]})
                        current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Abweichung MA CRM/Wiki"] + 1)}{row_num_sheet}', 'values': [[plausi_results.get("abweichung_ma_flag", "ERR_FLAG")]]})
                        current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Begründung"] + 1)}{row_num_sheet}', 'values': [[plausi_results.get("plausi_begruendung_final", "Fehler Begr.")]]})
                    
                    except Exception as e_plausi_run_batch:
                        self.logger.error(f"Fehler im Plausi-Check Aufruf (Batch-Modus) für Zeile {row_num_sheet}: {e_plausi_run_batch}")
                        for key_flag in ["Plausibilität Umsatz", "Plausibilität Mitarbeiter", "Plausibilität Umsatz/MA Ratio", "Abweichung Umsatz CRM/Wiki", "Abweichung MA CRM/Wiki"]:
                            current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP[key_flag] + 1)}{row_num_sheet}', 'values': [['FEHLER_CALL']]})
                        current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Begründung"] + 1)}{row_num_sheet}', 'values': [[f"Systemfehler: {str(e_plausi_run_batch)[:100]}"]]})
                else: # Fehler bei Konsolidierung
                    self.logger.warning(f"Zeile {row_num_sheet}: Überspringe Plausi-Checks wegen Fehler bei Konsolidierung.")
                    for key_flag in ["Plausibilität Umsatz", "Plausibilität Mitarbeiter", "Plausibilität Umsatz/MA Ratio", "Abweichung Umsatz CRM/Wiki", "Abweichung MA CRM/Wiki"]:
                        current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP[key_flag] + 1)}{row_num_sheet}', 'values': [['INPUT_FEHLER_KONSO']]})
                    current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Begründung"] + 1)}{row_num_sheet}', 'values': [["Konsolidierung fehlgeschlagen"]]})

                current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Prüfdatum"] + 1)}{row_num_sheet}', 'values': [[now_timestamp_str]]})
                
                all_sheet_updates.extend(current_row_updates)
                processed_rows_count += 1

                # Batch-Update auslösen, wenn Limit erreicht
                if processed_rows_count % update_batch_limit_config == 0 and processed_rows_count > 0:
                    if all_sheet_updates:
                        self.logger.info(f"Plausi-Batch: Sende {len(all_sheet_updates)} Operationen für {update_batch_limit_config} Zeilen...")
                        self.sheet_handler.batch_update_cells(all_sheet_updates)
                        all_sheet_updates = [] # Liste leeren nach dem Senden
                        time.sleep(0.5) # Kurze Pause nach Batch-Update
            
            # Ende der for-Schleife über die Zeilen

            # Sende verbleibende Updates
            if all_sheet_updates:
                self.logger.info(f"Plausi-Batch: Sende verbleibende {len(all_sheet_updates)} Operationen...")
                self.sheet_handler.batch_update_cells(all_sheet_updates)

            self.logger.info(f"Modus 'plausi_check_data' abgeschlossen. {processed_rows_count} Zeilen verarbeitet.")

    # ==========================================================================
    # === Batch Processing Methods (Parent Account Suggestion) ==============
    # ==========================================================================

    def _suggest_parent_account_openai_task(self, task_data, openai_semaphore):
        """
        Fragt ChatGPT nach einem Parent Account für ein einzelnes Unternehmen.
        Läuft in einem separaten Thread für den Parent-Suggestion-Batch.

        Args:
            task_data (dict): Enthält die Daten für die Zeile 
                              (row_num, crm_name, crm_website, crm_beschreibung, 
                               wiki_url, wiki_absatz, wiki_kategorien, website_zusammenfassung).
            openai_semaphore (threading.Semaphore): Semaphore zur Begrenzung gleichzeitiger OpenAI-Calls.

        Returns:
            dict: {'row_num': int, 'suggested_parent': str, 'justification': str, 'error': str or None}
        """
        logger = logging.getLogger(__name__ + ".suggest_parent_task")
        row_num = task_data['row_num']
        suggested_parent = "k.A."
        justification = "Keine Begründung erhalten."
        error_msg = None
        
        # Bereinige Input-Daten für den Prompt
        crm_name = str(task_data.get('crm_name', 'N/A')).strip()
        crm_website = str(task_data.get('crm_website', 'N/A')).strip()
        crm_beschreibung = str(task_data.get('crm_beschreibung', 'N/A')).strip()[:800] # Gekürzt
        wiki_url = str(task_data.get('wiki_url', 'N/A')).strip()
        wiki_absatz = str(task_data.get('wiki_absatz', 'N/A')).strip()[:800] # Gekürzt
        wiki_kategorien = str(task_data.get('wiki_kategorien', 'N/A')).strip()[:500] # Gekürzt
        website_zusammenfassung = str(task_data.get('website_zusammenfassung', 'N/A')).strip()[:800] # Gekürzt

        prompt_parts = [
            "Du bist ein Wirtschaftsanalyst und recherchierst Unternehmensstrukturen.",
            "Basierend auf den folgenden Informationen, identifiziere bitte den Namen der direkten Muttergesellschaft oder des übergeordneten Konzerns für das genannte Unternehmen.",
            "Wenn keine klare Muttergesellschaft ersichtlich ist oder das Unternehmen selbständig zu sein scheint, antworte mit 'k.A.' für den Parent Account.",
            "Gib deine Antwort ausschließlich im folgenden Format aus (keine Einleitung, kein Schlusssatz):",
            "Vorgeschlagener Parent Account: <Name des Parent Accounts oder k.A.>",
            "Begründung: <Sehr kurze Begründung für deinen Vorschlag oder warum keiner gemacht werden kann. Erwähne die Informationsquelle, falls möglich (z.B. Wikipedia Infobox, Website).>",
            "\n--- Unternehmensinformationen ---",
            f"Unternehmen: {crm_name}",
        ]
        if crm_website and crm_website.lower() != "n/a":
            prompt_parts.append(f"Website: {crm_website}")
        if crm_beschreibung and crm_beschreibung.lower() != "n/a":
            prompt_parts.append(f"CRM Beschreibung: {crm_beschreibung}")
        if wiki_url and "wikipedia.org" in wiki_url.lower():
            prompt_parts.append(f"Wikipedia URL: {wiki_url}")
            if wiki_absatz and wiki_absatz.lower() != "n/a":
                prompt_parts.append(f"Wikipedia Absatz: {wiki_absatz}")
            if wiki_kategorien and wiki_kategorien.lower() != "n/a":
                prompt_parts.append(f"Wikipedia Kategorien: {wiki_kategorien}")
        if website_zusammenfassung and website_zusammenfassung.lower() != "n/a" and not website_zusammenfassung.startswith("k.A. (Fehler"):
            prompt_parts.append(f"Website Zusammenfassung: {website_zusammenfassung}")
        
        prompt_parts.append("\nBitte gib NUR die Antwort im oben genannten Format.")
        prompt = "\n".join(prompt_parts)

        # Token Count (optional, zur Info)
        # try:
        #     pt_count = token_count(prompt)
        #     logger.debug(f"Zeile {row_num}: Prompt für Parent Suggestion ({pt_count} Tokens): {prompt[:200]}...")
        # except Exception: pass

        try:
            with openai_semaphore:
                # call_openai_chat ist mit @retry_on_failure dekoriert
                raw_chat_response = call_openai_chat(prompt, temperature=0.1, model=getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo'))

            if raw_chat_response:
                parsed_parent = "k.A."
                parsed_justification = "Keine Begründung extrahiert."
                
                parent_match = re.search(r"Vorgeschlagener Parent Account:\s*(.*)", raw_chat_response, re.IGNORECASE)
                if parent_match:
                    parsed_parent = parent_match.group(1).strip()
                    if not parsed_parent or parsed_parent.lower() == "k.a.": # Sicherstellen, dass "k.A." korrekt übernommen wird
                        parsed_parent = "k.A."

                justification_match = re.search(r"Begründung:\s*(.*)", raw_chat_response, re.IGNORECASE)
                if justification_match:
                    parsed_justification = justification_match.group(1).strip()
                
                suggested_parent = parsed_parent
                justification = parsed_justification
                logger.debug(f"Zeile {row_num}: ChatGPT Parent Vorschlag='{suggested_parent}', Begründung='{justification[:100]}...'")
            else:
                error_msg = "Leere Antwort von OpenAI erhalten."
                logger.warning(f"Zeile {row_num}: {error_msg}")
                justification = error_msg

        except Exception as e:
            error_msg = f"Fehler bei OpenAI Call für Parent Suggestion (Zeile {row_num}): {type(e).__name__} - {str(e)[:100]}"
            logger.error(error_msg)
            justification = error_msg
        
        return {"row_num": row_num, "suggested_parent": suggested_parent, "justification": justification, "error": error_msg}

    def process_parent_suggestion_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None, re_evaluate_question_mark=False):
        """
        Batch-Prozess zur Generierung von Parent-Account-Vorschlägen mittels ChatGPT.
        Schreibt Ergebnisse in Spalten O, P, Q.
        Bearbeitet nur Zeilen, bei denen Spalte Q (Parent Vorschlag Timestamp) leer ist,
        es sei denn re_evaluate_question_mark ist True und Spalte P ist '?'.

        Args:
            start_sheet_row (int, optional): Die 1-basierte Startzeile.
            end_sheet_row (int, optional): Die 1-basierte Endzeile.
            limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen.
            re_evaluate_question_mark (bool, optional): Wenn True, werden auch Zeilen mit '?'
                                                       in Spalte P (Parent Vorschlag Status) erneut bewertet,
                                                       AUCH WENN Spalte Q bereits einen Timestamp hat.
        """
        self.logger.info(f"Starte Parent Account Suggestion Batch. Bereich: {start_sheet_row if start_sheet_row else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit else 'Unbegrenzt'}, Re-Eval ?: {re_evaluate_question_mark}")

        # --- Daten laden und Startzeile ermitteln ---
        col_o_key = "System Vorschlag Parent Account"
        col_p_key = "Parent Vorschlag Status"
        col_q_key = "Parent Vorschlag Timestamp" # Timestamp-Spalte für die Auswahl

        if start_sheet_row is None:
            self.logger.info(f"Automatische Ermittlung der Startzeile basierend auf leerem '{col_q_key}'...") # Geändert: Start basierend auf leerem Q
            start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key=col_q_key, min_sheet_row=7)
            if start_data_index_no_header == -1:
                self.logger.error("FEHLER bei autom. Startzeilenermittlung. Breche ab.")
                return
            start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1
            self.logger.info(f"Automatisch ermittelte Startzeile: {start_sheet_row}")
        else:
            if not self.sheet_handler.load_data():
                self.logger.error("FEHLER beim Laden der Daten für Parent Suggestion Batch.")
                return

        all_data = self.sheet_handler.get_all_data_with_headers()
        header_rows = self.sheet_handler._header_rows
        total_sheet_rows = len(all_data)

        if end_sheet_row is None: end_sheet_row = total_sheet_rows
        self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}.")
        if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
            self.logger.info("Start liegt nach Ende oder außerhalb des Sheets. Keine Verarbeitung.")
            return

        # --- Indizes ---
        required_keys = [
            col_o_key, col_p_key, col_q_key, "CRM Name", "CRM Website", "CRM Beschreibung",
            "Wiki URL", "Wiki Absatz", "Wiki Kategorien", "Website Zusammenfassung", "Version"
        ]
        if not all(key in COLUMN_MAP for key in required_keys):
            missing = [k for k in required_keys if k not in COLUMN_MAP]
            self.logger.critical(f"FEHLER: Spaltenschlüssel für Parent Suggestion Batch fehlen: {missing}. Abbruch.")
            return
        
        col_o_letter = self.sheet_handler._get_col_letter(COLUMN_MAP[col_o_key] + 1)
        col_p_letter = self.sheet_handler._get_col_letter(COLUMN_MAP[col_p_key] + 1)
        col_q_letter = self.sheet_handler._get_col_letter(COLUMN_MAP[col_q_key] + 1)

        openai_sem = threading.Semaphore(getattr(Config, 'OPENAI_CONCURRENCY_LIMIT', 3))
        max_workers = getattr(Config, 'MAX_BRANCH_WORKERS', 10)
        processing_batch_size = getattr(Config, 'PROCESSING_BRANCH_BATCH_SIZE', 10)
        update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)

        tasks_for_current_openai_batch = []
        all_sheet_updates = []
        processed_count = 0
        skipped_count = 0

        # Funktion zum Verarbeiten und Schreiben eines Batches (bleibt intern gleich)
        def _execute_and_write_openai_batch(current_tasks):
            # ... (Code der inneren Funktion bleibt identisch wie im vorherigen Vorschlag) ...
            nonlocal processed_count
            if not current_tasks:
                return

            batch_start_log_row = current_tasks[0]['row_num']
            batch_end_log_row = current_tasks[-1]['row_num']
            self.logger.debug(f"\n--- Starte Parent Suggestion OpenAI Batch ({len(current_tasks)} Tasks, Zeilen {batch_start_log_row}-{batch_end_log_row}) ---")

            batch_results_list = []
            with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
                future_to_task_map = {executor.submit(self._suggest_parent_account_openai_task, task, openai_sem): task for task in current_tasks}
                for future in concurrent.futures.as_completed(future_to_task_map):
                    task_info_orig = future_to_task_map[future]
                    try:
                        result_data = future.result()
                        batch_results_list.append(result_data)
                    except Exception as e_future:
                        self.logger.error(f"Exception im Future für Parent Suggestion Zeile {task_info_orig['row_num']}: {e_future}")
                        batch_results_list.append({
                            "row_num": task_info_orig['row_num'],
                            "suggested_parent": "FEHLER_TASK",
                            "justification": str(e_future)[:150],
                            "error": str(e_future)
                        })

            self.logger.debug(f"  OpenAI Batch ({batch_start_log_row}-{batch_end_log_row}) abgeschlossen. {len(batch_results_list)} Ergebnisse erhalten.")

            if batch_results_list:
                now_ts_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                updates_for_this_batch = []

                for res_item in batch_results_list:
                    rn = res_item['row_num']
                    parent_val = res_item.get('suggested_parent', 'k.A. (Fehler)')
                    
                    updates_for_this_batch.append({'range': f'{col_o_letter}{rn}', 'values': [[parent_val]]})
                    status_val = "?" if parent_val and parent_val.lower() != "k.a." and not parent_val.startswith("FEHLER") else ""
                    updates_for_this_batch.append({'range': f'{col_p_letter}{rn}', 'values': [[status_val]]})
                    updates_for_this_batch.append({'range': f'{col_q_letter}{rn}', 'values': [[now_ts_str]]})
                    
                    if res_item.get('justification'):
                         self.logger.debug(f"Zeile {rn} - Parent Begründung: {res_item.get('justification')[:200]}...")

                all_sheet_updates.extend(updates_for_this_batch)
                
            processed_count += len(current_tasks) # Zähle hier, wenn Tasks tatsächlich an OpenAI gingen

            if len(all_sheet_updates) >= update_batch_row_limit * 3:
                self.logger.info(f"Sende Batch-Updates für Parent Suggestions ({len(all_sheet_updates)//3} Zeilen)...")
                self.sheet_handler.batch_update_cells(all_sheet_updates)
                all_sheet_updates.clear()
            
            time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.5)
        # Ende der Hilfsfunktion _execute_and_write_openai_batch

        # Hauptschleife über die Zeilen
        for i in range(start_sheet_row, end_sheet_row + 1):
            # Limit-Prüfung erfolgt jetzt innerhalb der _execute_and_write_openai_batch
            # oder besser hier vor dem Sammeln von Tasks, um nicht unnötig zu iterieren.
            if limit is not None and processed_count >= limit:
                self.logger.info(f"Verarbeitungslimit ({limit}) für Parent Suggestions erreicht.")
                break
            
            row_idx_list = i - 1
            if row_idx_list >= total_sheet_rows: break
            row = all_data[row_idx_list]

            if not any(cell and str(cell).strip() for cell in row):
                skipped_count += 1
                continue

            # Kriterien für Verarbeitung
            val_q_timestamp = self._get_cell_value_safe(row, col_q_key).strip() # Timestamp aus Spalte Q
            val_p_status = self._get_cell_value_safe(row, col_p_key).strip()   # Status aus Spalte P

            needs_processing = False
            if not val_q_timestamp: # Spalte Q (Timestamp) ist leer
                needs_processing = True
            elif re_evaluate_question_mark and val_p_status == "?": # Neubewertung für Status "?" auch wenn Timestamp Q gesetzt ist
                needs_processing = True
                self.logger.debug(f"Zeile {i}: Wird trotz vorhandenem Timestamp in Q ('{val_q_timestamp}') verarbeitet, da P='?' und re_evaluate_question_mark=True.")
            
            if not needs_processing:
                skipped_count += 1
                continue

            # Daten für Task sammeln
            task_data = {
                "row_num": i,
                "crm_name": self._get_cell_value_safe(row, "CRM Name"),
                "crm_website": self._get_cell_value_safe(row, "CRM Website"),
                "crm_beschreibung": self._get_cell_value_safe(row, "CRM Beschreibung"),
                "wiki_url": self._get_cell_value_safe(row, "Wiki URL"),
                "wiki_absatz": self._get_cell_value_safe(row, "Wiki Absatz"),
                "wiki_kategorien": self._get_cell_value_safe(row, "Wiki Kategorien"),
                "website_zusammenfassung": self._get_cell_value_safe(row, "Website Zusammenfassung")
            }
            tasks_for_current_openai_batch.append(task_data)

            if len(tasks_for_current_openai_batch) >= processing_batch_size:
                _execute_and_write_openai_batch(tasks_for_current_openai_batch)
                tasks_for_current_openai_batch.clear()
        
        if tasks_for_current_openai_batch:
            _execute_and_write_openai_batch(tasks_for_current_openai_batch)
        
        if all_sheet_updates:
            self.logger.info(f"Sende finale Batch-Updates für Parent Suggestions ({len(all_sheet_updates)//3} Zeilen)...")
            self.sheet_handler.batch_update_cells(all_sheet_updates)

        self.logger.info(f"Parent Account Suggestion Batch abgeschlossen. {processed_count} Zeilen verarbeitet, {skipped_count} Zeilen übersprungen.")



    # ==========================================================================
    # === Utility Methods (ML Data Prep & Training) ============================
    # ==========================================================================

    # --- Methode fuer ML Vorhersage (AU) ---
    # Diese Methode wird in _process_single_row (Block 21) aufgerufen, wenn der ML-Schritt angefordert ist und noetig ist.
    # Sie fuehrt eine Vorhersage des Servicetechniker-Buckets fuer eine einzelne Zeile mit dem trainierten ML-Modell durch.
    # Sie nutzt das geladene Modell und den Imputer (Attribute der DataProcessor Instanz).
    # Nutzt interne Helfer: _get_cell_value_safe, _load_ml_model (denselben Block).
    # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, pandas, numpy, re, clean_text (Block 4), get_numeric_filter_value (Block 5).
    def _predict_technician_bucket(self, row_data):
        company_name = self._get_cell_value_safe(row_data, 'CRM Name').strip()
        self.logger.debug(f"Versuche ML-Schaetzung fuer Zeile ({company_name[:50]}...)")

        if self.model is None or self.imputer is None or self._expected_features is None:
             self.logger.info("Lade ML-Modell, Imputer und Feature-Spalten...")
             self._load_ml_model(MODEL_FILE, IMPUTER_FILE)
             if self.model is None or self.imputer is None or self._expected_features is None:
                  self.logger.error("Laden von Modell, Imputer oder Feature-Spalten fehlgeschlagen.")
                  return "FEHLER Schaetzung (Modell-Laden)"
        try:
            # === Feature Erstellung (muss exakt zum Training passen!) ===
            
            # 1. Konsolidierte numerische Werte holen
            final_umsatz_val_str = self._get_cell_value_safe(row_data, "Finaler Umsatz (Wiki>CRM)")
            final_ma_val_str = self._get_cell_value_safe(row_data, "Finaler Mitarbeiter (Wiki>CRM)")
            
            umsatz_for_pred = get_numeric_filter_value(final_umsatz_val_str, is_umsatz=True)
            ma_for_pred = get_numeric_filter_value(final_ma_val_str, is_umsatz=False)
            
            umsatz_for_pred = np.nan if umsatz_for_pred == 0 else umsatz_for_pred
            ma_for_pred = np.nan if ma_for_pred == 0 else ma_for_pred

            # 2. 'is_part_of_group' Feature erstellen
            parent_d_val = self._get_cell_value_safe(row_data, "Parent Account Name").strip().lower()
            parent_o_val = self._get_cell_value_safe(row_data, "System Vorschlag Parent Account").strip().lower()
            parent_p_val = self._get_cell_value_safe(row_data, "Parent Vorschlag Status").strip().lower()
            cond1_pred = bool(parent_d_val and parent_d_val != 'k.a.')
            cond2_pred = bool(parent_o_val and parent_o_val != 'k.a.' and parent_p_val == 'x')
            is_group_val = 1 if cond1_pred or cond2_pred else 0

            # 3. Zusätzliche Features (Ratio, Log) erstellen
            # Log-Transformationen
            log_umsatz_val = np.log1p(umsatz_for_pred) if pd.notna(umsatz_for_pred) else np.nan
            log_ma_val = np.log1p(ma_for_pred) if pd.notna(ma_for_pred) else np.nan
            
            # Umsatz pro MA
            umsatz_pro_ma_val = np.nan
            if pd.notna(umsatz_for_pred) and pd.notna(ma_for_pred) and ma_for_pred > 0:
                umsatz_pro_ma_val = umsatz_for_pred / ma_for_pred
            
            # 4. Branchen-Feature holen
            # Wichtig: Hier die gleiche Branchenspalte wie im Training verwenden!
            branche_val_str = self._get_cell_value_safe(row_data, "CRM Branche")
            
            # DataFrame mit einer Zeile und den internen Namen (wie in prepare_data_for_modeling) erstellen
            single_row_dict = {
                'Log_Finaler_Umsatz_ML': [log_umsatz_val],
                'Log_Finaler_Mitarbeiter_ML': [log_ma_val],
                'Umsatz_pro_MA_ML': [umsatz_pro_ma_val],
                'is_part_of_group': [is_group_val],
                'branche_crm': [str(branche_val_str).strip() if branche_val_str else 'Unbekannt']
            }
            df_single_row = pd.DataFrame.from_dict(single_row_dict)
            
            # One-Hot Encoding
            df_encoded = pd.get_dummies(df_single_row, columns=['branche_crm'], prefix='Branche', dummy_na=False)
            
            # Angleichung an die im Training verwendeten Features
            # Erstelle einen DataFrame mit einer Zeile und den erwarteten Spalten
            data_for_df_processed = {col: [0] for col in self._expected_features}
            for col in self._expected_features:
                if col in df_encoded.columns:
                    data_for_df_processed[col] = [df_encoded[col].iloc[0]]
            
            df_processed = pd.DataFrame(data_for_df_processed, columns=self._expected_features)
            
            # Imputation und Vorhersage
            df_imputed_array = self.imputer.transform(df_processed)
            
            prediction_proba = self.model.predict_proba(df_imputed_array)
            predicted_bucket_label = self.model.classes_[np.argmax(prediction_proba[0])]

            self.logger.debug(f"  -> ML Vorhersage Ergebnis: '{predicted_bucket_label}'")
            return predicted_bucket_label

        except Exception as e_predict:
            self.logger.exception(f"FEHLER bei der ML-Vorhersage für Zeile ({company_name[:50]}...): {e_predict}")
            return f"FEHLER Schaetzung: {str(e_predict)[:100]}..."





    # --- Methode zum Laden des ML Modells und Imputers ---
    # Diese Methode wird von _predict_technician_bucket (denselben Block) und train_technician_model (denselben Block) aufgerufen.
    # Sie laedt die serialisierten Modelle von der Festplatte.
    # Nutzt globale Helfer: MODEL_FILE (Block 1), IMPUTER_FILE (Block 1), PATTERNS_FILE_JSON (Block 1),
    #                      logger, os, pickle, json.
    def _load_ml_model(self, model_path, imputer_path):
        """
        Laedt das trainierte ML-Modell, den Imputer und die erwarteten Feature-Spalten
        von den definierten Dateipfaden. Speichert sie als Instanzattribute.

        Args:
            model_path (str): Dateipfad zum Modell (.pkl).
            imputer_path (str): Dateipfad zum Imputer (.pkl).
            # Der Pfad zur Feature-Spaltenliste (JSON) wird aus PATTERNS_FILE_JSON (Block 1) geholt.
        """
        # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
        # Setzen Sie die Instanzattribute zunaechst auf None
        self.model = None
        self.imputer = None
        self._expected_features = None # Liste der erwarteten Feature-Spalten fuer Vorhersage

        try:
            # Pruefen Sie, ob die Modelldateien existieren
            if not os.path.exists(model_path):
                 self.logger.error(f"ML-Modell Datei nicht gefunden: {model_path}") # <<< GEÄNDERT
                 return # Beende die Methode, wenn die Datei fehlt
            if not os.path.exists(imputer_path):
                 self.logger.error(f"Imputer Datei nicht gefunden: {imputer_path}") # <<< GEÄNDERT
                 return # Beende die Methode, wenn die Datei fehlt

            # Laden Sie das serialisierte Modell
            with open(model_path, 'rb') as f:
                self.model = pickle.load(f)
                self.logger.info(f"ML-Modell '{model_path}' erfolgreich geladen.") # <<< GEÄNDERT
                # Loggen Sie die Klassen-Labels des geladenen Modells zur Info
                if hasattr(self.model, 'classes_'):
                     self.logger.debug(f"Geladene Modell-Klassen: {self.model.classes_}") # <<< GEÄNDERT
                else:
                     self.logger.debug("Geladenes Modell hat kein 'classes_' Attribut.") # <<< GEÄNDERT


            # Laden Sie den serialisierten Imputer
            with open(imputer_path, 'rb') as f:
                self.imputer = pickle.load(f)
                self.logger.info(f"Imputer '{imputer_path}' erfolgreich geladen.") # <<< GEÄNDERT


            # Laden Sie die Liste der erwarteten Feature-Spalten (JSON-Datei wird empfohlen)
            expected_features_path = PATTERNS_FILE_JSON # Nutzt globale Konstante (Block 1)
            # Pruefen Sie, ob die Feature-Spalten-Datei existiert
            if os.path.exists(expected_features_path):
                 try:
                      # Oeffnen Sie die JSON-Datei
                      with open(expected_features_path, 'r', encoding='utf-8') as f:
                           # Laden Sie die Daten aus der JSON-Datei
                           data = json.load(f)
                           # Annahme: Die JSON-Datei enthaelt eine Liste der Feature-Spalten unter dem Schluessel "feature_columns"
                           self._expected_features = data.get("feature_columns")
                           # Pruefen Sie, ob die geladenen Daten eine nicht-leere Liste sind.
                           if self._expected_features and isinstance(self._expected_features, list):
                                self.logger.info(f"Erwartete Feature-Spalten ({len(self._expected_features)}) aus '{expected_features_path}' geladen.") # <<< GEÄNDERT
                                # Loggen Sie die ersten paar erwarteten Features auf Debug
                                # self.logger.debug(f"Erwartete Features (erste 5): {self._expected_features[:5]}...") # Zu viel Laerm im Debug
                           else:
                                # Wenn die geladenen Daten nicht das erwartete Format haben oder leer sind
                                self.logger.error(f"Formatfehler in '{expected_features_path}' oder Schluessel 'feature_columns' fehlt/ist leer. ML-Vorhersage koennte fehlschlagen.") # <<< GEÄNDERT
                                self._expected_features = None # Setze auf None bei Fehler

                 except Exception as e_json:
                      # Fangen Sie Fehler beim Laden oder Parsen der JSON-Datei ab
                      self.logger.error(f"FEHLER beim Laden oder Parsen der Feature-Spalten Datei '{expected_features_path}': {e_json}") # <<< GEÄNDERT
                      # Logge den Traceback
                      self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
                      self._expected_features = None # Setze auf None bei Fehler

            else:
                 # Wenn die Feature-Spalten-Datei nicht gefunden wird
                 self.logger.warning(f"Datei mit erwarteten Feature-Spalten '{expected_features_path}' nicht gefunden. ML-Vorhersage koennte fehlschlagen.") # <<< GEÄNDERT
                 self._expected_features = None # Setze auf None, da die Datei fehlt


            # Fallback: Wenn expected_features nicht geladen werden konnte, versuchen Sie es aus Imputer/Modell zu extrahieren (wenn die Bibliothek es unterstuetzt)
            if self._expected_features is None:
                 try:
                      # Neuere Scikit-learn Versionen haben oft ein feature_names_in_ Attribut
                      if hasattr(self.imputer, 'feature_names_in_') and self.imputer.feature_names_in_ is not None:
                           self._expected_features = list(self.imputer.feature_names_in_)
                           self.logger.info(f"Erwartete Feature-Spalten ({len(self._expected_features)}) aus Imputer geladen (Fallback).") # <<< GEÄNDERT
                      elif hasattr(self.model, 'feature_names_in_') and self.model.feature_names_in_ is not None:
                           self._expected_features = list(self.model.feature_names_in_)
                           self.logger.info(f"Erwartete Feature-Spalten ({len(self._expected_features)}) aus Modell geladen (Fallback).") # <<< GEÄNDERT
                      else:
                           # Wenn es nirgends gefunden werden konnte
                           self.logger.error("Konnte erwartete Feature-Spalten weder aus Datei noch aus Modell/Imputer extrahieren. ML-Vorhersage wird fehlschlagen.") # <<< GEÄNDERT
                           self._expected_features = None
                 except Exception as e_extract:
                      # Fange Fehler beim Extrahieren aus Modell/Imputer ab
                      self.logger.error(f"FEHLER beim Extrahieren der Feature-Namen aus Modell/Imputer (Fallback): {e_extract}") # <<< GEÄNDERT
                      # Logge den Traceback
                      self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
                      self._expected_features = None


        except Exception as e:
            # Fange alle anderen unerwarteten Fehler waehrend des Ladens ab
            self.logger.exception(f"FEHLER beim Laden von ML-Artefakten: {e}") # <<< GEÄNDERT
            # Setzen Sie die Attribute auf None bei Fehler
            self.model = None
            self.imputer = None
            self._expected_features = None
            # Die Methode endet implizit hier nach dem Fangen der Exception.


    # Methode zur Datenvorbereitung fuer ML (WIRD VON train_technician_model aufgerufen)
    # Diese Methode laedt alle relevanten Daten aus dem Sheet, bereitet sie auf
    # und gibt einen DataFrame fuer das Training zurueck.
    # Basierend auf prepare_data_for_modeling aus Teil 12/13.
    # Nutzt interne Helfer: _get_cell_value_safe.
    # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, pandas, numpy, re,
    #                      clean_text (Block 4), normalize_string (Block 4), get_numeric_filter_value (Block 5),
    #                      load_target_schema (Block 6 - relevant fuer Branchentypen), traceback.
    # Nutzt die uebergeordnete sheet_handler Instanz (Block 14).
    def prepare_data_for_modeling(self):
        """
        Laedt Daten aus dem Google Sheet ueber den sheet_handler,
        bereitet sie fuer das Decision Tree Modell vor:
        - Waehlt relevante Spalten aus und benennt sie um.
        - Konsolidiert Umsatz/Mitarbeiter (Wiki > CRM Prioritaet).
        - Filtert nach gueltiger Technikerzahl (> 0).
        - Erstellt die Zielvariable (Techniker-Bucket).
        - Bereitet Features auf (One-Hot Encoding fuer Branche).
        - Behaelt NaNs in numerischen Features fuer spaetere Imputation.

        Returns:
            pandas.DataFrame: Vorbereiteter DataFrame fuer Training/Test-Split,
                              oder None bei Fehlern oder wenn keine gueltigen Trainingsdaten gefunden wurden.
        """
        # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
        self.logger.info("Starte Datenvorbereitung fuer Modellierung (Training)...") # <<< GEÄNDERT
        # Nutzt den self.sheet_handler der Klasse (Block 15).
        # Pruefen Sie, ob der Sheet Handler initialisiert wurde und Daten hat.
        if not self.sheet_handler or not self.sheet_handler.sheet_values:
             self.logger.error("Fehler: Sheet Handler nicht initialisiert oder keine Daten geladen fuer prepare_data_for_modeling.") # <<< GEÄNDERT
             # Versuchen Sie die Daten einmalig innerhalb dieser Methode zu laden, falls sie fehlen.
             # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2).
             if not self.sheet_handler.load_data():
                  self.logger.critical("Konnte Daten auch nach erneutem Versuch nicht laden. Abbruch der Datenvorbereitung.") # <<< GEÄNDERT
                  return None # Gebe None zurueck, wenn Laden fehlschlaegt


        # Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler.
        all_data = self.sheet_handler.get_all_data_with_headers()
        # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14).
        header_rows = self.sheet_handler._header_rows
        # Pruefe auf ausreichende Zeilenzahl (Header + mindestens eine Datenzeile)
        min_required_rows = header_rows + 1
        # Wenn nicht genuegend Zeilen da sind
        if not all_data or len(all_data) < min_required_rows:
            self.logger.error(f"Fehler: Nicht genuegend Datenzeilen ({len(all_data)}) im Sheet gefunden fuer Modellierung (mindestens {min_required_rows} benoetigt).") # <<< GEÄNDERT
            return None # Gebe None zurueck, wenn nicht genuegend Daten da sind


        # --- Header pruefen und DataFrame erstellen ---
        try:
            # Die erste Zeile sollte die Spaltennamen enthalten.
            headers = all_data[0]
            # Stellen Sie sicher, dass die Header-Zeile auch die erwartete Mindestlaenge hat,
            # um die Spaltenindizes aus COLUMN_MAP (Block 1) zu finden.
            try:
                 max_col_idx_in_map = max(COLUMN_MAP.values()) # Finde den hoechsten Index in COLUMN_MAP
                 # Pruefen Sie, ob die Anzahl der geladenen Spalten im Header ausreicht
                 if len(headers) <= max_col_idx_in_map:
                      # Logge einen kritischen Fehler, wenn das Mapping auf Spalten zeigt, die nicht im Sheet existieren
                      self.logger.critical(f"FEHLER: Header-Zeile ({len(headers)} Spalten) ist kuerzer als der hoechste Index in COLUMN_MAP ({max_col_idx_in_map}). COLUMN_MAP passt nicht zum Sheet.") # <<< GEÄNDERT
                      return None # Beende die Methode
            except ValueError: # Tritt auf, wenn COLUMN_MAP leer ist
                 self.logger.critical("FEHLER: COLUMN_MAP scheint leer zu sein oder enthaelt keine Werte. Kann Max Index nicht ermitteln.") # <<< GEÄNDERT
                 return None # Beende die Methode
            except Exception as e:
                 # Fange andere unerwartete Fehler ab
                 self.logger.critical(f"FEHLER beim Pruefen der Spaltenlaenge der Header-Zeile: {e}") # <<< GEÄNDERT
                 return None # Beende die Methode

        except IndexError:
            # Wenn das Sheet leer ist oder keine erste Zeile hat
            self.logger.critical("FEHLER: Sheet scheint leer zu sein oder hat keine erste Zeile, keine Header gefunden.") # <<< GEÄNDERT
            return None # Beende die Methode
        except Exception as e:
             # Fange andere unerwartete Fehler beim Zugriff auf Header ab
             self.logger.critical(f"FEHLER beim Zugriff auf Header: {e}") # <<< GEÄNDERT
             # Logge den Traceback
             self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
             return None # Beende die Methode


        # Datenzeilen sind alle Zeilen nach den Header-Zeilen
        data_rows = all_data[header_rows:] # Annahme: Die ersten X Zeilen sind Header

        # Erstelle DataFrame aus den Datenzeilen und den Headern
        df = pd.DataFrame(data_rows, columns=headers)
        self.logger.info(f"Initialen DataFrame fuer Modellierung erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.") # <<< GEÄNDERT


        # DACH-Filter (basierend auf CRM Land - Spalte G)
        crm_land_col_header = headers[COLUMN_MAP["CRM Land"]] # Holt den tatsächlichen Spaltennamen
        # Erlaubte Werte für DACH-Länder (Groß- und Kleinschreibung wird durch .str.upper() behandelt)
        dach_countries = ["DE", "CH", "AT", "DEUTSCHLAND", "ÖSTERREICH", "SCHWEIZ", "OESTERREICH"] # OESTERREICH hinzugefügt
        
        # Sicherstellen, dass die Spalte existiert, bevor gefiltert wird
        if crm_land_col_header in df.columns:
            df = df[df[crm_land_col_header].astype(str).str.upper().isin(dach_countries)].copy() # .copy() um Warnung zu vermeiden
            self.logger.info(f"Nach DACH-Filter (basierend auf '{crm_land_col_header}'): {len(df)} Zeilen verbleiben.")
            if df.empty:
                self.logger.error("Keine DACH-Unternehmen im Datensatz nach Filterung.")
                return None
        else:
            self.logger.error(f"Spalte '{crm_land_col_header}' für DACH-Filter nicht im DataFrame gefunden.")
            return None

        # Plausibilitätsfilter (basierend auf Spalten BG und BH)
        plausi_umsatz_col_header = headers[COLUMN_MAP["Plausibilität Umsatz"]]
        plausi_ma_col_header = headers[COLUMN_MAP["Plausibilität Mitarbeiter"]]

        # Sicherstellen, dass die Spalten existieren
        if plausi_umsatz_col_header in df.columns and plausi_ma_col_header in df.columns:
            # Filtere Zeilen, bei denen Plausi-Umsatz oder Plausi-MA einen Fehler anzeigt
            # Hier gehen wir davon aus, dass Fehler mit "FEHLER_" beginnen.
            df = df[~df[plausi_umsatz_col_header].astype(str).str.upper().str.startswith('FEHLER')].copy()
            df = df[~df[plausi_ma_col_header].astype(str).str.upper().str.startswith('FEHLER')].copy()
            self.logger.info(f"Nach Entfernung von FEHLER-Plausi-Fällen (BG, BH): {len(df)} Zeilen verbleiben.")
            if df.empty:
                self.logger.error("Keine Zeilen nach Plausi-Filterung übrig.")
                return None
            # Hier könnten Sie noch spezifischere Filter für bestimmte WARNUNG-Typen einbauen, falls gewünscht
        else:
            self.logger.error(f"Plausibilitätsspalten '{plausi_umsatz_col_header}' oder '{plausi_ma_col_header}' nicht im DataFrame gefunden.")
            return None



        # --- Spaltenauswahl und Umbenennung ---
        # Definiere die notwendigen Spalten anhand ihrer COLUMN_MAP Schluessel (Block 1)
        # und weisen ihnen interne, einfachere Namen zu, die im DataFrame verwendet werden.
        col_keys_mapping = {
            "name": "CRM Name",                     # Zur Identifikation, wird spaeter entfernt
            "branche_ki": "Chat Vorschlag Branche",           # Fuer One-Hot Encoding
            "umsatz_crm": "CRM Umsatz",             # Fuer Konsolidierung
            "umsatz_wiki": "Wiki Umsatz",           # Fuer Konsolidierung
            "ma_crm": "CRM Anzahl Mitarbeiter",     # Fuer Konsolidierung
            "ma_wiki": "Wiki Mitarbeiter",          # Fuer Konsolidierung
            "techniker": "CRM Anzahl Techniker",     # DIE ZIELVARIABLE (Bekannte Technikerzahl)
            "parent_d_raw": "Parent Account Name",                  # Spalte D <- Dies ist Zeile 9012
            "parent_o_raw": "System Vorschlag Parent Account",    # Spalte O
            "parent_p_raw": "Parent Vorschlag Status"             # Spalte P
        }

        # Ueberpruefe, ob alle benoetigten Spalten-Schluessel in der COLUMN_MAP (Block 1) vorhanden sind
        missing_keys_in_map = [key for key in col_keys_mapping.values() if key not in COLUMN_MAP]
        if missing_keys_in_map:
            self.logger.critical(f"FEHLER: Folgende benoetigte Spalten-Schluessel fehlen in COLUMN_MAP fuer prepare_data_for_modeling: {missing_keys_in_map}.") # <<< GEÄNDERT
            return None # Beende die Methode

        # Erstelle das Mapping von tatsaechlichen Header-Namen zu internen Schluesseln.
        # Verwende die Header-Namen aus dem geladenen Sheet und die COLUMN_MAP, um die richtigen Header zu finden.
        header_to_internal_key = {} # Dict zum Umbenennen der Spalten
        cols_to_select_by_header = [] # Liste der Header-Namen, die aus dem DF ausgewaehlt werden

        try:
             # Iteriere ueber das Mapping von internen zu COLUMN_MAP Schluesseln
             for internal_key, column_map_key in col_keys_mapping.items():
                  # Hole den tatsaechlichen Header-Namen aus dem Sheet
                  header_name_from_sheet = headers[COLUMN_MAP[column_map_key]]
                  # Fuege das Mapping hinzu
                  header_to_internal_key[header_name_from_sheet] = internal_key
                  # Fuege den Header-Namen zur Liste der auszuwaehlenden Spalten hinzu
                  cols_to_select_by_header.append(header_name_from_sheet)

             # Waehle nur die benoetigten Spalten im DataFrame aus
             df_subset = df[cols_to_select_by_header].copy() # Kopie erstellen, um SettingWithCopyWarning zu vermeiden
             # Benenne die Spalten um zu den internen Namen
             df_subset.rename(columns=header_to_internal_key, inplace=True)

        except KeyError as e:
             # Dieser Fehler sollte eigentlich durch die obige Pruefung abgefangen werden,
             # tritt aber auf, wenn ein erwarteter Header-Name nicht im geladenen DF ist (selten, wenn COLUMN_MAP korrekt ist).
             self.logger.critical(f"FEHLER beim Auswaehlen/Umbenennen der Spalten (KeyError: '{e}'). Der Header wurde nicht im DataFrame gefunden.") # <<< GEÄNDERT
             self.logger.debug(f"Erwartete Header: {cols_to_select_by_header}. Verfuegbare Header im DF: {list(df.columns)}") # <<< GEÄNDERT
             return None # Beende die Methode
        except IndexError as e:
             # Tritt auf, wenn COLUMN_MAP einen Index > Anzahl Spalten im DF hat
             self.logger.critical(f"FEHLER beim Auswaehlen/Umbenennen der Spalten (IndexError: '{e}'). COLUMN_MAP zeigt auf Spalten, die nicht im geladenen Sheet existieren.") # <<< GEÄNDERT
             self.logger.debug(f"COLUMN_MAP: {COLUMN_MAP}. Sheet hat {len(headers)} Spalten.") # <<< GEÄNDERT
             return None # Beende die Methode
        except Exception as e:
             # Fange andere unerwartete Fehler ab
             self.logger.critical(f"Unerwarteter FEHLER beim Auswaehlen/Umbenennen der Spalten: {e}") # <<< GEÄNDERT
             # Logge den Traceback
             self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
             return None # Beende die Methode


        self.logger.info(f"Benötigte Spalten fuer Modellierung ausgewaehlt und umbenannt: {list(df_subset.columns)}") # <<< GEÄNDERT


        self.logger.info("Erstelle Feature 'is_part_of_group'...")
        
        # Zugreifen auf die Spalten im DataFrame df_subset
        # Die Spaltennamen hier müssen den internen Namen entsprechen, 
        # die in col_keys_mapping definiert wurden (z.B. 'parent_d_raw').
        parent_d_series = df_subset['parent_d_raw'].astype(str).str.strip().str.lower()
        parent_o_series = df_subset['parent_o_raw'].astype(str).str.strip().str.lower()
        parent_p_series = df_subset['parent_p_raw'].astype(str).str.strip().str.lower()

        cond1 = parent_d_series.notna() & (parent_d_series != 'k.a.') & (parent_d_series != '')
        cond2_o = parent_o_series.notna() & (parent_o_series != 'k.a.') & (parent_o_series != '')
        cond2_p = parent_p_series == 'x'
        cond2 = cond2_o & cond2_p
        
        # .loc verwenden, um die neue Spalte sicher zuzuweisen und SettingWithCopyWarning zu vermeiden
        df_subset.loc[:, 'is_part_of_group'] = np.where(cond1 | cond2, 1, 0)
        
        self.logger.info(f"Feature 'is_part_of_group' erstellt. {df_subset['is_part_of_group'].sum()} Unternehmen als Teil einer Gruppe markiert.")
        self.logger.debug(f"Verteilung von 'is_part_of_group':\n{df_subset['is_part_of_group'].value_counts(normalize=True, dropna=False)}")



        # --- Features konsolidieren (Umsatz, Mitarbeiter) ---
        self.logger.debug("Konsolidiere Umsatz und Mitarbeiter für ML-Features...")
        cols_to_process_ml = { 
            'Umsatz': ('umsatz_wiki', 'umsatz_crm', 'Finaler_Umsatz_ML'),
            'Mitarbeiter': ('ma_wiki', 'ma_crm', 'Finaler_Mitarbeiter_ML')
        }
        for base_name, (wiki_col_ml, crm_col_ml, final_col_ml) in cols_to_process_ml.items():
            is_umsatz_flag = (base_name == 'Umsatz')
            wiki_series = df_subset[wiki_col_ml].apply(lambda x: get_numeric_filter_value(x, is_umsatz=is_umsatz_flag))
            crm_series = df_subset[crm_col_ml].apply(lambda x: get_numeric_filter_value(x, is_umsatz=is_umsatz_flag))
            
            # Wähle Wiki-Wert, wenn vorhanden und > 0, sonst CRM-Wert
            df_subset.loc[:, final_col_ml] = np.where(
                (wiki_series.notna()) & (wiki_series > 0), 
                wiki_series,
                crm_series
            )
            # Ersetze 0 explizit durch NaN, damit es von log1p und Imputer korrekt behandelt wird
            df_subset.loc[:, final_col_ml] = df_subset[final_col_ml].replace(0, np.nan)
            self.logger.info(f"  -> {df_subset[final_col_ml].notna().sum()} gueltige '{final_col_ml}' Werte erstellt (von {len(df_subset)} Zeilen).")

        # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
        # +++ NEUER BLOCK: Feature Engineering (Ratio & Log-Transformationen) +++++
        # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
        self.logger.info("Erstelle zusätzliche Features (Ratio, Log-Transformationen)...")
        
        if 'Finaler_Umsatz_ML' in df_subset.columns and 'Finaler_Mitarbeiter_ML' in df_subset.columns:
            # Umsatz pro Mitarbeiter
            ma_for_ratio = df_subset['Finaler_Mitarbeiter_ML'] # Hier sind Nullen schon durch NaN ersetzt
            df_subset.loc[:, 'Umsatz_pro_MA_ML'] = df_subset['Finaler_Umsatz_ML'] / ma_for_ratio
            df_subset['Umsatz_pro_MA_ML'].replace([np.inf, -np.inf], np.nan, inplace=True)
            self.logger.debug(f"  -> Feature 'Umsatz_pro_MA_ML' erstellt.")

            # Log-Transformationen (np.log1p(x) berechnet log(1+x), sicher für NaNs)
            df_subset.loc[:, 'Log_Finaler_Umsatz_ML'] = np.log1p(df_subset['Finaler_Umsatz_ML'])
            df_subset.loc[:, 'Log_Finaler_Mitarbeiter_ML'] = np.log1p(df_subset['Finaler_Mitarbeiter_ML'])
            self.logger.debug(f"  -> Log-transformierte Features erstellt.")
        else:
            self.logger.warning("Konsolidierte Umsatz/Mitarbeiter-Spalten nicht gefunden, Feature Engineering übersprungen.")
        # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
        # +++ ENDE NEUER BLOCK ++++++++++++++++++++++++++++++++++++++++++++++++++++
        # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

        # --- Zielvariable vorbereiten (Technikerzahl) ---
        self.logger.info("Verarbeite Zielvariable 'techniker'...")
        techniker_col_internal = "techniker"
        df_subset.loc[:, 'Anzahl_Servicetechniker_Numeric'] = df_subset[techniker_col_internal].apply(lambda x: get_numeric_filter_value(x, is_umsatz=False))
        
        initial_rows_before_tech_filter = len(df_subset)
        df_filtered = df_subset[
            df_subset['Anzahl_Servicetechniker_Numeric'].notna() & 
            (df_subset['Anzahl_Servicetechniker_Numeric'] > 0)
        ].copy()
        
        removed_rows_tech_filter = initial_rows_before_tech_filter - len(df_filtered)
        if removed_rows_tech_filter > 0:
             self.logger.info(f"{removed_rows_tech_filter} Zeilen entfernt aufgrund fehlender/ungueltiger Technikerzahl.")
        self.logger.info(f"Verbleibende Zeilen fuer Modellierungstraining: {len(df_filtered)}")

        if df_filtered.empty:
            self.logger.error("FEHLER: Keine Zeilen mit gueltiger Technikerzahl (>0) uebrig fuer Modellierungstraining!")
            return None

        # --- Techniker-Buckets erstellen (mit reduzierter Klassenanzahl) ---
        self.logger.info("Erstelle reduzierte Techniker-Buckets (3 Klassen)...")
        bins_new = [-1, 49, 249, float('inf')]
        labels_new = ['Techniker_Klein (0-49)', 'Techniker_Mittel (50-249)', 'Techniker_Gross (250+)']
        
        df_filtered.loc[:, 'Techniker_Bucket'] = pd.cut(
            df_filtered['Anzahl_Servicetechniker_Numeric'], bins=bins_new, labels=labels_new, right=True, include_lowest=True
        )
        self.logger.info(f"Verteilung der neuen Techniker-Buckets:\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True, dropna=False).sort_index().round(3)}")
        
        # --- Kategoriale Features vorbereiten (Branchen-Gruppen) ---
        branche_col_internal = "branche_ki" # Dies ist die Spalte mit den Detail-Branchen
        self.logger.info(f"Verarbeite kategoriales Feature '{branche_col_internal}' und mappe es zu 'Branchen_Gruppe'...")

        if branche_col_internal not in df_filtered.columns:
             self.logger.critical(f"FEHLER: Spalte '{branche_col_internal}' (aus 'Chat Vorschlag Branche') nicht im DataFrame gefunden.")
             return None

        # Normalisiere die Branchennamen aus dem Sheet für das Mapping
        normalized_sheet_branches = df_filtered[branche_col_internal].apply(normalize_for_mapping)

        # Wende das hartcodierte Mapping aus der Config-Klasse an
        df_filtered.loc[:, 'Branchen_Gruppe'] = normalized_sheet_branches.map(Config.BRANCH_GROUP_MAPPING).fillna('Sonstige')
        
        self.logger.info("Mapping zu 'Branchen_Gruppe' durchgeführt.")
        self.logger.debug(f"Verteilung der Branchen-Gruppen:\n{df_filtered['Branchen_Gruppe'].value_counts(normalize=True).sort_index().round(3)}")
        
        # One-Hot Encoding wird jetzt auf der neuen 'Branchen_Gruppe'-Spalte durchgeführt
        df_encoded = pd.get_dummies(df_filtered, columns=['Branchen_Gruppe'], prefix='Gruppe', dummy_na=False)
        self.logger.info(f"One-Hot Encoding fuer 'Branchen_Gruppe' durchgefuehrt.")
        
# --- Finale Auswahl der Features fuer das Modell ---
        feature_columns_ml = [col for col in df_encoded.columns if col.startswith('Gruppe_')]
        feature_columns_ml.extend([
            'Log_Finaler_Umsatz_ML', 
            'Log_Finaler_Mitarbeiter_ML',
            'Umsatz_pro_MA_ML',
            'is_part_of_group'
        ])
        self.logger.info(f"Finale Feature-Auswahl für das Training: {feature_columns_ml}")

        target_column_ml = 'Techniker_Bucket'
        identification_cols_ml = ['name', 'Anzahl_Servicetechniker_Numeric']
        
        final_cols_for_df_ml = identification_cols_ml + feature_columns_ml + [target_column_ml]
        missing_final_cols_ml = [col for col in final_cols_for_df_ml if col not in df_encoded.columns]
        if missing_final_cols_ml:
             self.logger.critical(f"FEHLER: Finale Spalten fuer Modellierung fehlen im DataFrame: {missing_final_cols_ml}")
             return None

        df_model_ready = df_encoded[final_cols_for_df_ml].copy()

        numeric_features_to_convert = [
            'Log_Finaler_Umsatz_ML', 'Log_Finaler_Mitarbeiter_ML', 'Umsatz_pro_MA_ML', 'Anzahl_Servicetechniker_Numeric'
        ]
        for col in numeric_features_to_convert:
             if col in df_model_ready.columns:
                  df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce')

        df_model_ready = df_model_ready.reset_index(drop=True)

        self.logger.info("Datenvorbereitung fuer Modellierung (Training) abgeschlossen.")
        self.logger.info(f"Finaler DataFrame fuer Modellierung hat {len(df_model_ready)} Zeilen und {len(df_model_ready.columns)} Spalten.")
        self.logger.info(f"Anzahl Feature-Spalten: {len(feature_columns_ml)}")

        numeric_features_for_imputation_ml = [
            'Log_Finaler_Umsatz_ML', 
            'Log_Finaler_Mitarbeiter_ML', 
            'Umsatz_pro_MA_ML'
        ]
        existing_numeric_features = [col for col in numeric_features_for_imputation_ml if col in df_model_ready.columns]
        if existing_numeric_features:
            nan_counts = df_model_ready[existing_numeric_features].isna().sum()
            self.logger.info(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}")
            rows_with_nan = df_model_ready[existing_numeric_features].isna().any(axis=1).sum()
            self.logger.info(f"Anzahl Zeilen mit mindestens einem fehlenden numerischen Feature (vor Imputation): {rows_with_nan}")
        
        return df_model_ready


        # Methode zum Trainieren des ML Modells
        # Nutzt interne Methode: prepare_data_for_modeling.
        # Nutzt globale Helfer: MODEL_FILE, IMPUTER_FILE, PATTERNS_FILE_JSON (Block 1),
        #                      logger, pickle, json, os,
        #                      train_test_split, SimpleImputer, DecisionTreeClassifier,
        #                      accuracy_score, classification_report, confusion_matrix, export_text (sklearn).
    def train_technician_model(self, model_out=MODEL_FILE, imputer_out=IMPUTER_FILE, patterns_out=PATTERNS_FILE_JSON):
        self.logger.info("Starte Training des Servicetechniker Decision Tree Modells...")

        # 1. Daten vorbereiten
        df_model_ready = self.prepare_data_for_modeling()
        if df_model_ready is None or df_model_ready.empty:
            self.logger.error("Datenvorbereitung fuer Modelltraining fehlgeschlagen oder keine Daten. Training abgebrochen.")
            return

        # Feature Spalten und Zielspalte definieren
        identification_cols = ['name', 'Anzahl_Servicetechniker_Numeric']
        target_column = 'Techniker_Bucket'
        feature_columns_ml = [col for col in df_model_ready.columns if col not in identification_cols and col != target_column]

        if not feature_columns_ml:
             self.logger.critical("FEHLER: Keine Feature-Spalten nach Datenvorbereitung gefunden. Training nicht moeglich.")
             return

        X = df_model_ready[feature_columns_ml]
        y = df_model_ready[target_column]
        self.logger.info(f"Daten fuer Training vorbereitet. X Shape: {X.shape}, y Shape: {y.shape}")

        # 2. Split in Training und Test Set
        X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)
        self.logger.info(f"Daten gesplittet. Train Set: {len(X_train)} Zeilen, Test Set: {len(X_test)} Zeilen.")

        # 3. Imputation (Fehlende Werte ersetzen)
        imputer = SimpleImputer(strategy='median')
        self.logger.info(f"Fitte Imputer mit Strategie '{imputer.strategy}' auf Trainingsdaten...")
        imputer.fit(X_train) 

        # Speichern Sie den Imputer (wird fuer Vorhersagen benoetigt).
        self.imputer = imputer # Speichern Sie ihn in der Instanz
        try:
            imputer_dir = os.path.dirname(imputer_out)
            if imputer_dir and not os.path.exists(imputer_dir):
                os.makedirs(imputer_dir, exist_ok=True)
            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}")
            self.logger.debug(traceback.format_exc())
            # Training sollte hier nicht unbedingt abbrechen, aber ein Hinweis ist wichtig

        X_train_imputed = imputer.transform(X_train)
        X_test_imputed = imputer.transform(X_test)
        X_train_imputed = pd.DataFrame(X_train_imputed, columns=feature_columns_ml) # feature_columns_ml verwenden
        X_test_imputed = pd.DataFrame(X_test_imputed, columns=feature_columns_ml)   # feature_columns_ml verwenden

        # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
        # +++ ANPASSUNG HIER: GridSearchCV mit Pipeline für SMOTE & RandomForest +
        # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
        # 4. Erstellen einer Pipeline und Definieren des Parameter-Grids
        
        # Schritt 1: SMOTE für Klassen-Balancierung
        # Schritt 2: RandomForestClassifier als Modell
        pipeline = ImbPipeline([
            ('smote', SMOTE(random_state=42)),
            ('classifier', RandomForestClassifier(random_state=42, n_jobs=-1)) # class_weight nicht nötig bei SMOTE
        ])

        # Definieren der Hyperparameter, die getestet werden sollen.
        # WICHTIG: Die Parameternamen müssen mit dem Namen des Schritts in der Pipeline beginnen (z.B. 'classifier__...')
        param_grid = {
            'classifier__n_estimators': [200, 300],         # Anzahl der Bäume
            'classifier__max_depth': [10, 20, None],        # Maximale Tiefe der Bäume (None = unbegrenzt)
            'classifier__min_samples_split': [2, 5],        # Mindestanzahl Samples für einen Split
            'classifier__min_samples_leaf': [1, 2]          # Mindestanzahl Samples in einem Blatt
        }
        # HINWEIS: Dies sind 2 * 3 * 2 * 2 = 24 Kombinationen.
        # Mit cv=3 (siehe unten) werden 24 * 3 = 72 Modelle trainiert. Dies kann dauern!
        # Für einen schnellen Test können Sie die Anzahl der Optionen reduzieren.

        # Initialisieren von GridSearchCV
        # cv=3 bedeutet 3-fache Kreuzvalidierung.
        # scoring='accuracy' bedeutet, dass die beste Kombination anhand der Genauigkeit ausgewählt wird.
        # verbose=2 gibt detaillierte Log-Ausgaben während der Suche.
        grid_search = GridSearchCV(estimator=pipeline, param_grid=param_grid, cv=3, scoring='accuracy', verbose=2, n_jobs=-1)

        self.logger.info("Starte Hyperparameter-Tuning mit GridSearchCV...")
        start_fit_time = time.time()
        # Fitte das Grid auf den (noch nicht resampleten) Trainingsdaten.
        # Die Pipeline kümmert sich intern darum, dass SMOTE nur auf die Trainings-Folds angewendet wird.
        grid_search.fit(X_train_imputed, y_train)
        end_fit_time = time.time()
        self.logger.info(f"GridSearchCV-Suche abgeschlossen. Dauer: {end_fit_time - start_fit_time:.2f} Sekunden.")

        # Beste Parameter und bestes Modell ausgeben und speichern
        self.logger.info(f"Beste gefundene Parameter: {grid_search.best_params_}")
        self.logger.info(f"Beste Cross-Validation Accuracy: {grid_search.best_score_:.4f}")
        
        # Das beste Modell ist das, das mit den besten Parametern auf den *gesamten* Trainingsdaten trainiert wurde.
        best_classifier = grid_search.best_estimator_

        # Modell speichern
        self.model = best_classifier # Zuweisung des besten gefundenen Modells
        try:
            model_dir = os.path.dirname(model_out)
            if model_dir and not os.path.exists(model_dir):
                os.makedirs(model_dir, exist_ok=True)
            with open(model_out, 'wb') as f:
                pickle.dump(best_classifier, f) # Das beste Modell speichern
            self.logger.info(f"Bestes RandomForest Modell erfolgreich gespeichert in '{model_out}'.")
        except Exception as e:
            self.logger.error(f"FEHLER beim Speichern des besten Modells in '{model_out}': {e}")
        # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
        # +++ ENDE ANPASSUNG ++++++++++++++++++++++++++++++++++++++++++++++++++++
        # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++


        # Feature-Liste speichern (bleibt unverändert)
        self._expected_features = feature_columns_ml 
        try:
            # Wir verwenden die .classes_ vom besten gefundenen Modell
            patterns_data = {"feature_columns": self._expected_features, "target_classes": list(best_classifier.classes_)} # << KORRIGIERT
            patterns_dir = os.path.dirname(patterns_out)
            if patterns_dir and not os.path.exists(patterns_dir):
                os.makedirs(patterns_dir, exist_ok=True)
            with open(patterns_out, 'w', encoding='utf-8') as f:
                 json.dump(patterns_data, f, indent=4, ensure_ascii=False)
            self.logger.info(f"Erwartete Feature-Spalten und Klassen erfolgreich gespeichert in '{patterns_out}'.")
        except Exception as e:
            self.logger.error(f"FEHLER beim Speichern der Feature-Spalten in '{patterns_out}': {e}")
        # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
        # +++ ENDE FEATURE-LISTEN SPEICHERUNG +++++++++++++++++++++++++++++++++++
        # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

        # 5. Evaluation (Optional, aber empfohlen, um die Modellleistung zu bewerten)
        self.logger.info("Starte Evaluation des besten Modells auf dem ungesehenen Testset...")
        y_pred = best_classifier.predict(X_test_imputed) # << KORRIGIERT
        accuracy = accuracy_score(y_test, y_pred)
        self.logger.info(f"Finale Modell Genauigkeit auf dem Testset: {accuracy:.4f}")
        
        class_report_labels = list(best_classifier.classes_) # << KORRIGIERT
        class_report = classification_report(y_test, y_pred, zero_division=0, labels=class_report_labels, target_names=[str(c) for c in class_report_labels])
        self.logger.info(f"Klassifikationsbericht auf dem Testset:\n{class_report}")

        cm = confusion_matrix(y_test, y_pred, labels=class_report_labels)
        self.logger.info(f"Konfusionsmatrix auf dem Testset (Zeilen=Wahr, Spalten=Vorhersage):\n{cm}")

        # Block für Feature Importance
        try:
            # Greife auf den Schritt 'classifier' in der Pipeline zu, um das finale Modell zu bekommen
            final_rf_model = best_classifier.named_steps['classifier']
            self.logger.info("Feature Importance des besten Modells (Top 15):")
            importances = final_rf_model.feature_importances_ # << KORRIGIERT
            feature_importance_df = pd.DataFrame({
                'Feature': feature_columns_ml,
                'Importance': importances
            }).sort_values(by='Importance', ascending=False)
            
            self.logger.info(f"\n{feature_importance_df.head(15).to_string(index=False)}")
        except Exception as e_feat_imp:
            self.logger.warning(f"FEHLER beim Berechnen/Anzeigen der Feature Importance: {e_feat_imp}")

        self.logger.info("Modelltraining und -evaluation abgeschlossen.")

    # ==============================================================================
    # Ende DataProcessor Klasse Utility: ML Prep & Training Block
    # ==============================================================================

    # ==========================================================================
    # === Utility Methods (Other Specific Tasks) ===============================
    # ==========================================================================

    # --- Methode fuer experimentelle Website Details ---
    # Diese Methode extrahiert Details von Websites fuer Zeilen mit 'x'.
    # Basierend auf process_website_details_for_marked_rows aus Teil 12.
    # Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter.
    # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger,
    #                      scrape_website_details (Block 13).
    # Nutzt die uebergeordnete sheet_handler Instanz (Block 14).
    def process_website_details(self, start_sheet_row=None, end_sheet_row=None, limit=None):
        """
        EXPERIMENTELL: Extrahiert Website-Details fuer Zeilen, die in Spalte A mit 'x' markiert sind.
        Schreibt die Details in eine definierte Spalte (Website Details oder AR als Fallback).
        Loescht NICHT das 'x'-Flag.

        Args:
            start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (ab Zeile 7).
            end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
            limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht uebersprungener) Zeilen. Defaults to None (Unbegrenzt).
        """
        # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
        # Logge den Start des Modus auf Warning, da es experimentell ist.
        self.logger.warning(f"Starte Modus (EXPERIMENTELL): Website Detail Extraction fuer Zeilen mit 'x' in Spalte A. Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT
        self.logger.warning("Hinweis: Dieser Modus nutzt die globale Funktion 'scrape_website_details' (Block 13), deren Implementierung je nach Zielwebsites angepasst werden muss.") # <<< GEÄNDERT


        # --- Daten laden ---
        # Laden Sie Daten neu. Kein automatischer Startindex-Check noetig hier,
        # da wir explizit nach dem 'x'-Flag suchen.
        # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2).
        if not self.sheet_handler.load_data():
             self.logger.error("Fehler beim Laden der Daten fuer Website Details Extraction.") # <<< GEÄNDERT
             return # Beende die Methode, wenn das Laden fehlschlaegt


        # Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler.
        all_data = self.sheet_handler.get_all_data_with_headers();
        # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14).
        header_rows = self.sheet_handler._header_rows;
        total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet


        # Standard Startzeile, wenn nicht manuell gesetzt
        if start_sheet_row is None: start_sheet_row = header_rows + 1 # Standardmaessig ab erster Datenzeile (Zeile nach Headern)

        # Berechne Endzeile, wenn nicht manuell gesetzt
        if end_sheet_row is None: end_sheet_row = total_sheet_rows # Bis zur letzten Zeile


        # Logge den Suchbereich fuer das 'x'-Flag
        self.logger.info(f"Suchbereich fuer 'x'-Flag: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT

        # Pruefe, ob der Bereich gueltig ist
        if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
             self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
             return # Beende die Methode, wenn der Bereich leer ist


        # --- Indizes und Buchstaben ---
        # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind
        required_keys = ["ReEval Flag", "CRM Website", "CRM Name"] # A, D, B
        # Erstellen Sie ein Dictionary mit Schluesseln und Indizes
        col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}

        # Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden
        if None in col_indices.values():
             missing = [k for k, v in col_indices.items() if v is None]
             self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_website_details: {missing}. Breche ab.") # <<< GEÄNDERT
             return # Beende die Methode bei kritischem Fehler

        # Ermitteln Sie die Indizes
        reeval_col_idx = col_indices["ReEval Flag"] # A
        website_col_idx = col_indices["CRM Website"] # D

        # Bestimme die ZIELSPALTE fuer die Details (Website Details ODER AR als Fallback)
        details_col_idx = COLUMN_MAP.get("Website Details") # Versuche zuerst die dedizierte Spalte (Block 1 Column Map)
        details_col_key_for_logging = "Website Details" # Name fuer Logging
        # Wenn die dedizierte Spalte nicht gefunden wurde
        if details_col_idx is None:
            # Fallback auf 'Website Rohtext' (AR)
            details_col_idx = COLUMN_MAP.get("Website Rohtext") # Block 1 Column Map
            details_col_key_for_logging = "Website Rohtext"
            # Pruefen Sie, ob der Fallback-Schluessel gefunden wurde
            if details_col_idx is None:
                 self.logger.critical("FEHLER: Weder 'Website Details' noch 'Website Rohtext' Spaltenindex in COLUMN_MAP gefunden.") # <<< GEÄNDERT
                 return # Beende die Methode bei kritischem Fehler
            self.logger.warning(f"Keine Spalte 'Website Details' in COLUMN_MAP, nutze '{details_col_key_for_logging}' ({self.sheet_handler._get_col_letter(details_col_idx+1)}) als Fallback.") # <<< GEÄNDERT
        else:
             # Logge die Verwendung der dedizierten Spalte
             self.logger.info(f"Nutze Spalte '{details_col_key_for_logging}' ({self.sheet_handler._get_col_letter(details_col_idx+1)}) fuer Website Details.") # <<< GEÄNDERT


        # Ermitteln Sie den Spaltenbuchstaben der Zielspalte (nutzt interne Helfer _get_col_letter Block 14)
        details_col_letter = self.sheet_handler._get_col_letter(details_col_idx + 1)


        # --- Verarbeitung ---
        # Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1).
        update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)


        all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts)


        processed_count = 0 # Zaehlt Zeilen, die fuer die Verarbeitung in Frage kommen und in den Batch aufgenommen werden (im Rahmen des Limits).
        skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (nicht markiert oder fehlende URL).


        # Iteriere durch die Datenzeilen im definierten Bereich (1-basierte Sheet-Zeilennummer)
        for i in range(start_sheet_row, end_sheet_row + 1):
            row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
            # Pruefen Sie, ob das Ende des Sheets erreicht wurde
            if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht


            row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile


            # Stellen Sie sicher, dass die Zeile nicht leer ist
            if not any(cell and isinstance(cell, str) and cell.strip() for cell in row):
                 #self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug
                 skipped_count += 1 # Zaehlen als uebersprungene Zeile
                 continue # Springe zur naechsten Zeile


            # --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
            # Kriterium: Zeile ist mit 'x' in Spalte A (ReEval Flag) markiert.
            # UND Website URL (D) ist vorhanden und gueltig aussehend.

            # Holen Sie den Wert aus Spalte A (ReEval Flag) (nutzt interne Helfer _get_cell_value_safe)
            cell_a_value = self._get_cell_value_safe(row, "ReEval Flag").strip().lower() # Block 1 Column Map
            # Pruefen Sie, ob die Zelle mit 'x' markiert ist.
            is_marked_for_processing = cell_a_value == "x"

            # Wenn die Zeile nicht mit 'x' markiert ist, ueberspringen
            if not is_marked_for_processing:
                 skipped_count += 1 # Zaehlen als uebersprungene Zeile
                 continue # Springe zur naechsten Zeile


            # Holen Sie den Wert aus Spalte D (CRM Website) (nutzt interne Helfer _get_cell_value_safe)
            website_url = self._get_cell_value_safe(row, "CRM Website").strip() # Block 1 Column Map
            # Pruefen Sie, ob die Website URL (D) vorhanden und gueltig aussehend ist.
            website_url_is_valid_looking = website_url and isinstance(website_url, str) and website_url.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"] # Fuege "http:" hinzu basierend auf Log


            # Verarbeitung ist noetig, wenn die Zeile mit 'x' markiert ist UND die Website URL gueltig ist.
            processing_needed_for_row = is_marked_for_processing and website_url_is_valid_looking


            # Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
            log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
            if log_check:
                 company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map
                 self.logger.debug(f"Zeile {i} ({company_name[:50]}... Website Details Check): A='x'? {is_marked_for_processing}, D gueltig? {website_url_is_valid_looking}. Benoetigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT


            # Wenn die Verarbeitung fuer diese Zeile nicht noetig ist (trotz 'x' fehlte die URL)
            if not processing_needed_for_row:
                 skipped_count += 1 # Zaehlen als uebersprungene Zeile
                 # Optionale Behandlung: Wenn mit 'x' markiert, aber URL fehlt, was tun?
                 # Derzeit wird sie uebersprungen. Ggf. Fehler in Spalte notieren?
                 continue # Springe zur naechsten Zeile


            # --- Wenn Verarbeitung noetig: Fuehre Details-Extraktion aus ---
            processed_count += 1 # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im Rahmen des Limits zaehlen)

            # Pruefe das Limit fuer verarbeitete Zeilen
            if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit:
                 # Wenn das Limit erreicht ist und es ein positives Limit gibt
                 self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_website_details erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
                 break # Brich die Schleife ab


            self.logger.info(f"Zeile {i}: Extrahiere Website Details von {website_url[:100]}...") # <<< GEÄNDERT (war selflogger)


            details = "FEHLER: Funktion 'scrape_website_details' nicht verfuegbar" # Default Fehler, falls die Funktion nicht existiert (Sollte nicht passieren, wenn Block 13 korrekt ist)

            try:
                 # Rufe die globale Funktion scrape_website_details auf (Block 13).
                 # scrape_website_details ist mit retry_on_failure dekoriert (Block 2).
                 # Wenn scrape_website_details fehlschlaegt, wirft sie eine Exception oder gibt einen Fehlerwert zurueck.
                 details = scrape_website_details(website_url) # <<< Ruft globale Funktion (Block 13)

                 # Wenn die Funktion einen Fehler geloggt hat und einen Fehlerstring im Ergebnis zurueckgibt,
                 # wird dies in der 'details' Variable gespeichert.
                 if isinstance(details, str) and (details.startswith("k.A. (Fehler") or details.startswith("FEHLER:")):
                     # Fehler wurde bereits in scrape_website_details geloggt.
                     pass # Details enthaelt bereits den Fehlerstring.

                 elif not isinstance(details, str) or not details.strip():
                      # Wenn die Funktion keinen String oder einen leeren String zurueckgibt.
                      details = "k.A. (Extraktion leer oder ungueltig)" # Standard-Fehlerwert


            except NameError:
                 # Dieser Fehler sollte nicht auftreten, wenn scrape_website_details in Block 13 ist.
                 self.logger.critical("FEHLER: Funktion 'scrape_website_details' ist nicht definiert! Kann Details nicht extrahieren.") # <<< GEÄNDERT
                 # Logge den Traceback.
                 self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
                 details = "FEHLER: Funktion nicht definiert" # Setze spezifischen Fehlerwert

            except Exception as e_detail:
                 # Fange andere unerwartete Fehler ab, die nicht von scrape_website_details behandelt wurden.
                 self.logger.exception(f"Unerwarteter Fehler bei scrape_website_details fuer {website_url[:100]}...: {type(e_detail).__name__} - {e_detail}") # <<< GEÄNDERT
                 details = f"k.A. (Unerwarteter Fehler: {str(e_detail)[:100]}...)" # Signalisiert Fehler (gekuerzt)


            # Fuege Update fuer die Details-Spalte hinzu (nutzt interne Helfer _get_col_letter Block 14)
            # Stellen Sie sicher, dass der Wert ein String ist.
            updates_for_row = [] # Lokale Liste fuer Updates dieser Zeile
            updates_for_row.append({'range': f'{details_col_letter}{i}', 'values': [[str(details)]]}) # Block 1 Column Map
            self.logger.debug(f"Zeile {i}: Details extrahiert und zum Update fuer Spalte {details_col_key_for_logging} ({details_col_letter}{i}) hinzugefuegt.") # <<< GEÄNDERT


            # Sammle die Updates fuer diese Zeile in der globalen Liste all_sheet_updates.
            all_sheet_updates.extend(updates_for_row)


            # Sende gesammelte Sheet Updates wenn das Update-Batch-Limit erreicht ist.
            # update_batch_row_limit wird aus Config geholt (Block 1).
            # Updates pro Zeile ist 1 in diesem Modus. Anzahl der Zeilen = len(all_sheet_updates).
            if len(all_sheet_updates) >= update_batch_row_limit:
                 self.logger.debug(f"  Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
                 # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
                 # Wenn es fehlschlaegt, wird es intern geloggt.
                 success = self.sheet_handler.batch_update_cells(all_sheet_updates)
                 if success:
                      self.logger.info(f"  Sheet-Update fuer {len(all_sheet_updates)} Zellen erfolgreich.") # <<< GEÄNDERT
                   # Der Fehlerfall wird von batch_update_cells geloggt

                 # Leere die gesammelten Updates nach dem Senden.
                 all_sheet_updates = []


            # Kleine Pause nach jeder Extraktion (nutzt Config Block 1).
            # Dieser Modus macht API calls (ueber scrape_website_details und dessen Helfer), also Pause einbauen.
            pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.2
            #self.logger.debug(f"Warte {pause_duration:.2f}s nach Extraktion...") # Zu viel Laerm im Debug
            time.sleep(pause_duration)


        # --- Finale Sheet Updates senden ---
        # Sende alle verbleibenden gesammelten Updates in einem letzten Batch-Update.
        if all_sheet_updates:
            self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
            # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
            success = self.sheet_handler.batch_update_cells(all_sheet_updates)
            if success:
                self.logger.info(f"FINALES Sheet-Update erfolgreich.") # <<< GEÄNDERT
            # Der Fehlerfall wird von batch_update_cells geloggt


        # Logge den Abschluss des Modus
        self.logger.info(f"Modus 'website_details' abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {skipped_count} Zeilen uebersprungen.") # <<< GEÄNDERT
        # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt.


    # --- Methode zum Verarbeiten von Wiki-Updates basierend auf ChatGPT Vorschlaegen ---
    # Diese Methode verarbeitet Zeilen, in denen S gesetzt ist (nicht in Endzustand),
    # prueft ob U eine valide und andere Wiki-URL ist und fuehrt entsprechende Updates durch.
    # Basierend auf process_wiki_updates_from_chatgpt aus Teil 4.
    # Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter.
    # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), time,
    #                      is_valid_wikipedia_article_url (Block 12).
    # Nutzt die uebergeordnete sheet_handler Instanz (Block 14).
    def process_wiki_updates_from_chatgpt(self, start_sheet_row=None, end_sheet_row=None, limit=None):
        """
        Identifiziert Zeilen, in denen Status S gesetzt ist, aber NICHT auf einem Endzustand
        (OK, X (UPDATED/COPIED/INVALID)), prueft ob U eine *valide* und *andere* Wiki-URL ist.
        - Wenn ja: Kopiert U->M, markiert S='X (URL Copied)', U='URL uebernommen', loescht
          abhaengige Wiki-Spalten (N-V, AN, AO, AP, AX), setzt ReEval-Flag A='x'.
        - Wenn nein (U keine URL, U==M, oder U ungueltig): LOESCHT den Inhalt von U und
          markiert S als 'X (Invalid Suggestion)'.
        Verarbeitet maximal limit Zeilen.

        Args:
            start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (ab Zeile 7).
            end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
            limit (int, optional): Maximale Anzahl ZU PRUEFENDER Zeilen. Defaults to None (Unbegrenzt).
        """
        # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
        # Logge die Konfiguration des Modus
        self.logger.info(f"Starte Modus 'wiki_updates_from_chatgpt' (S, U, M, N-V, AN, AO, AX, AP, A). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT


        # --- Daten laden ---
        # Laden Sie Daten neu. Kein automatischer Startindex-Check noetig hier,
        # da wir nach Status S suchen.
        # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2).
        if not self.sheet_handler.load_data():
             self.logger.error("Fehler beim Laden der Daten fuer Wiki Updates.") # <<< GEÄNDERT
             return # Beende die Methode, wenn das Laden fehlschlaegt


        # Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler.
        all_data = self.sheet_handler.get_all_data_with_headers()
        # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14).
        header_rows = self.sheet_handler._header_rows
        total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet


        # Standard Startzeile, wenn nicht manuell gesetzt
        if start_sheet_row is None: start_sheet_row = header_rows + 1 # Standardmaessig ab erster Datenzeile (Zeile nach Headern)

        # Berechne Endzeile, wenn nicht manuell gesetzt
        if end_sheet_row is None: end_sheet_row = total_sheet_rows # Bis zur letzten Zeile


        # Logge den Suchbereich fuer Status S
        self.logger.info(f"Suchbereich fuer Status S: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT

        # Pruefe, ob der Bereich gueltig ist
        if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
             self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
             return # Beende die Methode, wenn der Bereich leer ist


        # --- Indizes und Buchstaben ---
        # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind
        required_keys = [
            "Chat Wiki Konsistenzpruefung", "Chat Vorschlag Wiki Artikel", "Wiki URL", # S, U, M (Pruefkriterien / Daten)
            "Wikipedia Timestamp", "Wiki Verif. Timestamp", "Timestamp letzte Pruefung", "Version", # AN, AX, AO, AP (Spalten zum Loeschen)
            "ReEval Flag", # A (ReEval Flag setzen)
            "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", # N-R (Spalten zum Loeschen)
            "Chat Begruendung Wiki Inkonsistenz", "Begruendung bei Abweichung", # T, V (Spalten zum Loeschen)
             # AY (SerpAPI Wiki Search Timestamp) wird ebenfalls geleert, da abhaengig von M.
             "SerpAPI Wiki Search Timestamp" # AY (Spalte zum Leeren)
        ]
        # Erstellen Sie ein Dictionary mit Schluesseln und Indizes
        col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}

        # Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden
        if None in col_indices.values():
            missing = [k for k, v in col_indices.items() if v is None]
            self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_wiki_updates_from_chatgpt: {missing}. Breche ab.") # <<< GEÄNDERT
            return # Beende die Methode bei kritischem Fehler


        # Ermitteln Sie die Spaltenbuchstaben fuer Updates/Leerung (nutzt interne Helfer _get_col_letter Block 14)
        s_letter = self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzpruefung"] + 1) # Status S
        u_letter = self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"] + 1) # Vorschlag U
        m_letter = self.sheet_handler._get_col_letter(col_indices["Wiki URL"] + 1) # Wiki URL M
        a_letter = self.sheet_handler._get_col_letter(col_indices["ReEval Flag"] + 1) # ReEval Flag A

        # Spalten N-V leeren.
        # N ist Wiki Absatz, V ist Begruendung bei Abweichung.
        n_idx = col_indices["Wiki Absatz"]
        v_idx = col_indices["Begruendung bei Abweichung"]
        # Erstellen Sie den Bereichsnamen (z.B. "N:V")
        n_letter = self.sheet_handler._get_col_letter(n_idx + 1)
        v_letter = self.sheet_handler._get_col_letter(v_idx + 1)
        nv_range_letter = f'{n_letter}:{v_letter}' # z.B. N:V
        # Erstellen Sie eine Liste von leeren Strings fuer diesen Bereich
        empty_nv_values = [''] * (v_idx - n_idx + 1) # Anzahl der Spalten = V_Index - N_Index + 1


        # Timestamps AN, AO, AX, AP, AY leeren.
        an_letter = self.sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"] + 1) # AN (Wiki Extraction TS)
        ao_letter = self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Pruefung"] + 1) # AO (Chat Evaluation TS)
        ap_letter = self.sheet_handler._get_col_letter(col_indices["Version"] + 1) # AP (Version)
        ax_letter = self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"] + 1) # AX (Wiki Verif. TS)
        ay_letter = self.sheet_handler._get_col_letter(col_indices["SerpAPI Wiki Search Timestamp"] + 1) # AY (SerpAPI Wiki TS)


        # --- Verarbeitung ---
        # Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1).
        update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)


        all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts)


        processed_rows_count = 0 # Zaehlt Zeilen, die geprueft werden (im Rahmen des Limits zaehlen).
        skipped_count = 0 # Zaehlt Zeilen, die uebersprungen werden (Status S im Endzustand etc.).
        updated_url_count = 0    # Zaehlt Zeilen, wo U -> M kopiert wurde.
        cleared_suggestion_count = 0 # Zaehlt Zeilen, wo Vorschlag U geloescht wurde.


        # Iteriere durch die Datenzeilen im definierten Bereich (1-basierte Sheet-Zeilennummer)
        for i in range(start_sheet_row, end_sheet_row + 1):
            row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
            # Pruefen Sie, ob das Ende des Sheets erreicht wurde
            if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht


            row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile


            # Stellen Sie sicher, dass die Zeile nicht leer ist
            if not any(cell and isinstance(cell, str) and cell.strip() for cell in row):
                 #self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug
                 skipped_count += 1 # Zaehlen als uebersprungene Zeile
                 continue # Springe zur naechsten Zeile


            # --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
            # Kriterium: Status S ist gesetzt (nicht leer) UND NICHT einer der Endzustaende.
            # Endzustaende: "OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)"

            # Holen Sie den Wert aus Spalte S (Chat Wiki Konsistenzpruefung) (nutzt interne Helfer _get_cell_value_safe)
            s_value = self._get_cell_value_safe(row, "Chat Wiki Konsistenzpruefung").strip() # Block 1 Column Map
            s_value_upper = s_value.upper()

            # Definieren Sie die Endzustaende (Grossbuchstaben)
            s_end_states = ["OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)"]

            # Verarbeitung ist noetig, wenn S nicht leer ist UND S NICHT im Endzustand ist.
            processing_needed_for_row = s_value and s_value_upper not in s_end_states


            # Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
            log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
            if log_check:
                 self.logger.debug(f"Zeile {i} (Wiki Update Check): Status S='{s_value}'. Benoetigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT


            # Wenn die Verarbeitung fuer diese Zeile nicht noetig ist
            if not processing_needed_for_row:
                skipped_count += 1 # Zaehlen als uebersprungene Zeile
                continue # Springe zur naechsten Zeile


            # --- Wenn Verarbeitung noetig: Pruefe Vorschlag U und handle ---
            processed_rows_count += 1 # Zaehle die Zeile, die geprueft wird (im Rahmen des Limits zaehlen).

            # Pruefe das Limit fuer verarbeitete Zeilen
            if limit is not None and isinstance(limit, int) and limit > 0 and processed_rows_count > limit:
                 # Wenn das Limit erreicht ist und es ein positives Limit gibt
                 self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_wiki_updates_from_chatgpt erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
                 break # Brich die Schleife ab


            # Holen Sie die Werte aus Spalte U (Chat Vorschlag Wiki Artikel) und M (Wiki URL) (nutzt interne Helfer _get_cell_value_safe)
            vorschlag_u = self._get_cell_value_safe(row, "Chat Vorschlag Wiki Artikel").strip() # Block 1 Column Map
            url_m = self._get_cell_value_safe(row, "Wiki URL").strip() # Block 1 Column Map


            self.logger.info(f"Zeile {i}: Pruefe Wiki-Vorschlag U='{vorschlag_u[:100]}...' (aktuell M='{url_m[:100]}...')...") # <<< GEÄNDERT

            is_update_candidate = False # Flag, ob U eine gueltige, neue URL ist, die uebernommen werden soll.
            new_url = "" # Die URL, die ggf. in M kopiert wird.


            # Kriterium 1: Ist Vorschlag U ueberhaupt ein String und sieht nach Wikipedia aus?
            condition1_u_is_wiki_url = vorschlag_u and isinstance(vorschlag_u, str) and "wikipedia.org/wiki/" in vorschlag_u.lower() and vorschlag_u.lower().startswith(("http://", "https://")) # Check auf Schema hinzugefuegt


            # Wenn der Vorschlag U wie eine Wikipedia-URL aussieht
            if condition1_u_is_wiki_url:
                new_url = vorschlag_u # Nehme den Vorschlag als potenzielle neue URL
                # Kriterium 2: Unterscheidet sich der Vorschlag U von der aktuellen URL in M?
                # Pruefe, ob die neue URL nicht identisch mit der aktuellen M-URL ist.
                condition2_u_differs_m = new_url != url_m

                # Wenn sich der Vorschlag U von der aktuellen M-URL unterscheidet
                if condition2_u_differs_m:
                    self.logger.debug(f"  -> Vorschlag U ({new_url[:100]}...) unterscheidet sich von M ({url_m[:100]}). Pruefe Validitaet...")
                    try:
                         # Nutze die globale Funktion 'is_valid_wikipedia_article_url' (definiert in Block 12)
                         # Diese Funktion ist bereits mit @retry_on_failure dekoriert.
                         condition3_u_is_valid = is_valid_wikipedia_article_url(new_url, lang=getattr(Config, 'LANG', 'de')) # lang Argument hinzugefügt für Konsistenz

                         if condition3_u_is_valid:
                              is_update_candidate = True
                              self.logger.debug(f"  -> URL '{new_url[:100]}...' ist ein VALIDER Artikel laut API Check.") # <<< GEÄNDERT
                         else:
                              # Wenn die vorgeschlagene URL nicht valide ist
                              self.logger.debug(f"  -> URL '{new_url[:100]}...' ist KEIN valider Artikel laut API Check.") # <<< GEÄNDERT

                    except Exception as e_validity_check:
                         # Wenn die Validierungsfunktion eine Exception wirft (nach Retries)
                         # Der Fehler wird bereits vom retry_on_failure Decorator geloggt.
                         self.logger.error(f"FEHLER bei Validitaetspruefung von Vorschlag U '{new_url[:100]}...': {e_validity_check}") # <<< GEÄNDERT
                         # Bei Fehler bleibt is_update_candidate False.
                         pass # Faert fort


                else:
                     # Wenn der Vorschlag U identisch mit der aktuellen M-URL ist
                     self.logger.debug(f"  -> Vorschlag U ist identisch mit URL M. Wird nicht uebernommen.") # <<< GEÄNDERT

            else:
                 # Wenn der Vorschlag U nicht wie eine Wikipedia-URL aussieht
                 self.logger.debug(f"  -> Vorschlag U ('{vorschlag_u[:100]}...') ist keine Wikipedia URL. Wird nicht uebernommen.") # <<< GEÄNDERT


            # --- Verarbeitung des Kandidaten ODER Loeschen des ungueltigen Vorschlags ---
            updates_for_row = [] # Lokale Liste fuer Updates DIESER Zeile

            if is_update_candidate:
                # Fall 1: Gueltiges Update durchfuehren (Vorschlag U wird in M kopiert)
                self.logger.info(f"Zeile {i}: Update-Kandidat VALIDIERUNG ERFOLGREICH. Kopiere U->M, setze ReEval-Flag 'x', loesche abhaengige Spalten.") # <<< GEÄNDERT
                updated_url_count += 1 # Zaehle die uebernommene URL

                # Updates sammeln (M, S, U, N-V, AN, AO, AP, AX, AY, A) (nutzt interne Helfer _get_col_letter Block 14)
                updates_for_row.append({'range': f'{m_letter}{i}', 'values': [[new_url]]}) # Setze die neue URL in Spalte M (Block 1 Column Map)
                updates_for_row.append({'range': f'{s_letter}{i}', 'values': [["X (URL Copied)"]]}) # Setze Status S auf "X (URL Copied)" (Block 1 Column Map)
                updates_for_row.append({'range': f'{u_letter}{i}', 'values': [["URL uebernommen"]]}) # Schreibe Info in Spalte U (Block 1 Column Map)
                updates_for_row.append({'range': f'{a_letter}{i}', 'values': [["x"]]}) # Setze ReEval Flag (A) auf 'x' (Block 1 Column Map)

                # Leere Spalten N-V.
                # Fuege das Update zum Leeren des Bereichs V-Y hinzu, falls der Bereichsname ermittelt werden konnte.
                if nv_range_letter: # Pruefe, ob der Bereichsname ermittelt werden konnte.
                     updates_for_row.append({'range': f'{n_letter}{i}:{v_letter}{i}', 'values': [empty_nv_values]}) # Block 1 Column Map, lokale Variable
                else:
                     self.logger.warning(f"Konnte Spaltenbereich N-V ({n_letter}:{v_letter}) fuer Leerung nicht ermitteln fuer Zeile {i}. Leerung uebersprungen.") # <<< GEÄNDERT


                # Leere Timestamps AN, AO, AP, AX, AY.
                # Dies setzt die Zeile zurueck, damit andere Schritte sie spaeter bearbeiten.
                updates_for_row.append({'range': f'{an_letter}{i}', 'values': [['']]}) # AN (Wiki Extraction TS) Block 1 Column Map
                updates_for_row.append({'range': f'{ao_letter}{i}', 'values': [['']]}) # AO (Chat Evaluation TS) Block 1 Column Map
                updates_for_row.append({'range': f'{ap_letter}{i}', 'values': [['']]}) # AP (Version) Block 1 Column Map
                updates_for_row.append({'range': f'{ax_letter}{i}', 'values': [['']]}) # AX (Wiki Verif. TS) Block 1 Column Map
                updates_for_row.append({'range': f'{ay_letter}{i}', 'values': [['']]}) # AY (SerpAPI Wiki TS) Block 1 Column Map


            else:
                # Fall 2: Ungueltigen Vorschlag loeschen/markieren
                # Wenn der Vorschlag U nicht uebernommen wird (weil ungueltig oder identisch mit M).
                self.logger.info(f"Zeile {i}: Vorschlag U ('{vorschlag_u[:100]}...') ist ungueltig/identisch. Loesche U und setze Status S auf 'X (Invalid Suggestion)'.") # <<< GEÄNDERT
                cleared_suggestion_count += 1 # Zaehle den bereinigten Vorschlag

                # Updates sammeln (S, U) (nutzt interne Helfer _get_col_letter Block 14)
                updates_for_row.append({'range': f'{s_letter}{i}', 'values': [["X (Invalid Suggestion)"]]}) # Setze Status S auf "X (Invalid Suggestion)" (Block 1 Column Map)
                updates_for_row.append({'range': f'{u_letter}{i}', 'values': [[""]]}) # Loesche den Vorschlag in Spalte U (Block 1 Column Map)
                # KEIN ReEval-Flag (A) setzen in diesem Fall.


            # Sammle die Updates fuer diese Zeile in der globalen Liste all_sheet_updates.
            all_sheet_updates.extend(updates_for_row)


            # Sende gesammelte Sheet Updates wenn das Update-Batch-Limit erreicht ist.
            # update_batch_row_limit wird aus Config geholt (Block 1).
            # Die Anzahl der Updates pro Zeile variiert stark (ca. 2 bei ungueltigem Vorschlag, ca. 10+ bei gueltigem).
            # Pruefen Sie einfach die Laenge der gesammelten Liste.
            if len(all_sheet_updates) >= update_batch_row_limit * 5: # Grobe Schaetzung: im Schnitt 5 Updates pro Zeile
                 self.logger.debug(f"  Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
                 # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
                 # Wenn es fehlschlaegt, wird es intern geloggt.
                 success = self.sheet_handler.batch_update_cells(all_sheet_updates)
                 if success:
                      self.logger.info(f"  Sheet-Update fuer {len(all_sheet_updates)} Zellen erfolgreich.") # <<< GEÄNDERT
                   # Der Fehlerfall wird von batch_update_cells geloggt

                 # Leere die gesammelten Updates nach dem Senden.
                 all_sheet_updates = []


            # Kleine Pause nach jeder geprueften Zeile (nutzt Config Block 1).
            # Dieser Modus macht API calls (ueber is_valid_wikipedia_article_url), also Pause einbauen.
            pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.2
            #self.logger.debug(f"Warte {pause_duration:.2f}s nach Pruefung...") # Zu viel Laerm im Debug
            time.sleep(pause_duration)


        # --- Finale Sheet Updates senden ---
        # Sende alle verbleibenden gesammelten Updates in einem letzten Batch-Update.
        if all_sheet_updates:
            self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
            # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
            success = self.sheet_handler.batch_update_cells(all_sheet_updates)
            if success:
                self.logger.info(f"FINALES Sheet-Update erfolgreich.") # <<< GEÄNDERT
            # Der Fehlerfall wird von batch_update_cells geloggt


        # Logge den Abschluss des Modus
        self.logger.info(f"Modus 'wiki_updates_from_chatgpt' abgeschlossen. {processed_rows_count} Zeilen geprueft, {updated_url_count} URLs kopiert & fuer ReEval markiert, {cleared_suggestion_count} ungueltige Vorschlaege geloescht/markiert, {skipped_count} Zeilen uebersprungen.") # <<< GEÄNDERT
        # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt.


    # --- Methode zur Re-Extraktion von Wiki-Daten bei fehlendem Timestamp AN ---
    # Diese Methode identifiziert Zeilen mit M gefuellt und AN leer und fuehrt _process_single_row (Block 19) fuer diese aus.
    # Nutzt interne Helfer: _get_cell_value_safe, _process_single_row.
    # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger.
    # Nutzt die uebergeordnete sheet_handler Instanz (Block 14).
    def process_wiki_reextract_missing_an(self, start_sheet_row=None, end_sheet_row=None, limit=None):
        """
        Identifiziert Zeilen, bei denen eine Wiki URL (M) vorhanden ist, aber der
        Wikipedia Timestamp (AN) fehlt. Fuehrt _process_single_row fuer diese Zeilen aus,
        beschraenkt auf den 'wiki'-Schritt und mit force_reeval=True, um die Extraktion
        erneut zu versuchen.

        Args:
            start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AN).
            end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
            limit (int, optional): Maximale Anzahl ZU VERARBEITENDER Zeilen. Defaults to None (Unbegrenzt).
        """
        # Verwenden Sie logger, da das Logging jetzt konfiguriert ist
        # Logge die Konfiguration des Modus
        self.logger.info(f"Starte Modus 'wiki_reextract_missing_an' (M gefuellt & AN leer). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT


        # --- Daten laden und Startzeile ermitteln ---
        # Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt.
        # Dieser Modus sucht nach leeren AN mit gefuelltem M. Die automatische Startzeile
        # basierend auf leeren AN ist ein guter Startpunkt.
        if start_sheet_row is None:
             self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AN...") # <<< GEÄNDERT
             # Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AN (Block 1 Column Map).
             # Standardmaessig ab Zeile 7
             start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Wikipedia Timestamp", min_sheet_row=7)

             # Wenn get_start_row_index -1 zurueckgibt (Fehler)
             if start_data_index_no_header == -1:
                  self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche Modus ab.") # <<< GEÄNDERT
                  return # Beende die Methode

             # Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten Daten-Index
             start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut
             self.logger.info(f"Automatisch ermittelte Startzeile (erste leere AN Zelle): {start_sheet_row}") # <<< GEÄNDERT
        else:
            # Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein.
            # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2).
            if not self.sheet_handler.load_data():
                 self.logger.error("Fehler beim Laden der Daten fuer wiki_reextract_missing_an.") # <<< GEÄNDERT
                 return # Beende die Methode, wenn das Laden fehlschlaegt


        # Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler.
        all_data = self.sheet_handler.get_all_data_with_headers();
        # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14).
        header_rows = self.sheet_handler._header_rows;
        total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet


        # Berechne Endzeile, wenn nicht manuell gesetzt
        if end_sheet_row is None:
             end_sheet_row = total_sheet_rows # Bis zur letzten Zeile


        # Logge den verarbeitungsbereich
        self.logger.info(f"Suchbereich fuer M gefuellt & AN leer: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT

        # Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen)
        if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
             self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
             return # Beende die Methode, wenn der Bereich leer ist


        # --- Indizes ---
        # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind
        required_keys = ["Wiki URL", "Wikipedia Timestamp", "CRM Name"] # M, AN, B (Pruefkriterien + Logging)
        # Erstellen Sie ein Dictionary mit Schluesseln und Indizes
        col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}

        # Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden
        if None in col_indices.values():
            missing = [k for k, v in col_indices.items() if v is None]
            self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer wiki_reextract_missing_an: {missing}. Breche ab.") # <<< GEÄNDERT
            return # Beende die Methode bei kritischem Fehler

        # Ermitteln Sie die Indizes
        m_col_idx = col_indices["Wiki URL"]
        an_col_idx = col_indices["Wikipedia Timestamp"]


        # --- Verarbeitung ---
        processed_count = 0 # Zaehlt Zeilen, die an _process_single_row uebergeben wurden (im Rahmen des Limits zaehlen).
        skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden.


        # Iteriere durch die Datenzeilen im definierten Bereich (1-basierte Sheet-Zeilennummer)
        for i in range(start_sheet_row, end_sheet_row + 1):
            row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
            # Pruefen Sie, ob das Ende des Sheets erreicht wurde
            if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht


            row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile


            # Stellen Sie sicher, dass die Zeile nicht leer ist
            if not any(cell and isinstance(cell, str) and cell.strip() for cell in row):
                 #self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug
                 skipped_count += 1 # Zaehlen als uebersprungene Zeile
                 continue # Springe zur naechsten Zeile


            # --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
            # Kriterium: Wiki URL (M) ist vorhanden und gueltig aussehend.
            # UND Wikipedia Timestamp (AN) ist leer.

            # Holen Sie die Werte aus den entsprechenden Spalten (nutzt interne Helfer _get_cell_value_safe)
            m_value = self._get_cell_value_safe(row, "Wiki URL").strip() # Block 1 Column Map
            an_value = self._get_cell_value_safe(row, "Wikipedia Timestamp").strip() # Block 1 Column Map

            # Pruefen Sie, ob M gefuellt und gueltig aussieht.
            is_m_valid_looking = m_value and isinstance(m_value, str) and "wikipedia.org/wiki/" in m_value.lower() and m_value.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"] # Fuege "http:" hinzu basierend auf Log

            # Pruefen Sie, ob AN leer ist.
            is_an_empty = not an_value

            # Verarbeitung ist noetig, wenn M gueltig aussieht UND AN leer ist.
            processing_needed_for_row = is_m_valid_looking and is_an_empty


            # Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
            log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
            if log_check:
                 company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map
                 self.logger.debug(f"Zeile {i} ({company_name[:50]}... Wiki Re-extract Check): M ('{m_value[:50]}...') gueltig? {is_m_valid_looking}, AN leer? {is_an_empty}. Benötigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT


            # Wenn die Verarbeitung fuer diese Zeile nicht noetig ist
            if not processing_needed_for_row:
                skipped_count += 1 # Zaehlen als uebersprungene Zeile
                continue # Springe zur naechsten Zeile


            # --- Wenn Verarbeitung noetig: Rufe _process_single_row auf ---
            processed_count += 1 # Zaehle die Zeile, die an _process_single_row uebergeben wird (im Rahmen des Limits zaehlen)

            # Pruefe das Limit fuer verarbeitete Zeilen
            if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit:
                 # Wenn das Limit erreicht ist und es ein positives Limit gibt
                 self.logger.info(f"Verarbeitungslimit ({limit}) fuer wiki_reextract_missing_an erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
                 break # Brich die Schleife ab


            self.logger.info(f"Zeile {i}: M gefuellt & AN leer. Versuche Wiki-Re-Extraktion ueber _process_single_row...") # <<< GEÄNDERT

            try:
                # RUFE _process_single_row AUF (Block 19).
                # Mit steps_to_run={'wiki'} und force_reeval=True,
                # damit nur der Wiki-Schritt ausgefuehrt wird und Timestamps ignoriert werden.
                # Im Re-Extract Modus loeschen wir das 'x'-Flag NICHT automatisch.
                self._process_single_row(
                    row_num_in_sheet = i,
                    row_data = row, # Uebergibt die aktuellen Rohdaten der Zeile
                    steps_to_run = {'wiki'}, # <<< NUR der Wiki-Schritt soll laufen
                    force_reeval = True, # <<< Erzwingt die Ausfuehrung des 'wiki' Schritts (ignoriert AN, S).
                    clear_x_flag = False # <<< 'x'-Flag wird in diesem Modus NICHT geloescht
                )
                # _process_single_row (Block 19) loggt intern den Abschluss und fuehrt das Sheet-Update durch.

            except Exception as e_proc:
                # Wenn _process_single_row einen Fehler wirft (nachdem interne Retries aufgaben),
                # fangen wir ihn hier, loggen ihn und fahren mit der naechsten Zeile fort.
                self.logger.exception(f"FEHLER bei Verarbeitung von Zeile {i} in wiki_reextract_missing_an: {e_proc}") # <<< GEÄNDERT
                # Hier koennen Sie z.B. einen Fehlerindikator in eine spezielle Spalte im Sheet schreiben lassen.
                # Dieses Update muesste dann separat oder im naechsten Lauf behandelt werden.

            # _process_single_row beinhaltet bereits eine kleine Pause am Ende.
            # Hier ist keine zusaetzliche Pause noetig, wenn _process_single_row erfolgreich war.
            # Wenn _process_single_row eine Exception wirft, kann hier eine kurze Pause sinnvoll sein.
            # time.sleep(0.1) # Optional: Kurze Pause bei Fehler nach Exception


        # Logge den Abschluss des Modus
        self.logger.info(f"Modus 'wiki_reextract_missing_an' abgeschlossen. {processed_count} Zeilen an _process_single_row uebergeben, {skipped_count} Zeilen uebersprungen.") # <<< GEÄNDERT
        # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt.


    # ==============================================================================
    # Ende DataProcessor Klasse Utility: Other Specific Tasks Block
    # ==============================================================================

    # ==============================================================================
    # Ende DataProcessor Klasse
    # ==============================================================================

    # --- Ende der DataProcessor Klasse ---
    # Ein pass statement, um die Klassendefinition abzuschliessen, falls keine weiteren Methoden folgen.
    pass # <-- DIESES pass STATEMENT GEHOERT ZUM ENDE DER KLASSENDEFINITION


    # ==============================================================================
    # Hauptausfuehrungsblock & Globale Funktionen nach Klassen
    # ==============================================================================
    # Der naechste Block (Block 34) enthaelt die main Funktion und den Entry Point.

    # ==============================================================================
    # 6. MAIN FUNCTION (HAUPTEINSTIEGSPUNKT & UI DISPATCHER)
    # ==============================================================================

    # Der globale Root Logger wird in main() konfiguriert
    # logger = logging.getLogger(__name__) # Diesen Logger gibt es schon, keine Neudefinition hier

def main():
    """
    Haupteinstiegspunkt des Skripts.
    Verarbeitet Kommandozeilen-Argumente, richtet Logging ein,
    initialisiert Komponenten und dispatchet zu den passenden Modi.
    """
    # WICHTIG: Globale Variable LOG_FILE wird benoetigt (Initialisierung Block 3)
    global LOG_FILE
    logger = logging.getLogger(__name__) # <<< JETZT AN DER RICHTIGEN STELLE

    # --- Initial Logging Setup (Konfiguration von Level und Format) ---
    # Diese Konfiguration wird wirksam, sobald die Handler hinzugefuegt werden.
    # Standard-Logging Level festlegen (aus Config Block 1)
    log_level = logging.DEBUG if getattr(Config, 'DEBUG', False) else logging.INFO
    log_format = '%(asctime)s - %(levelname)-8s - %(name)-25s - %(message)s' # <<< DIESE ZEILE HINZUFÜGEN/KORRIGIEREN

    # Root-Logger konfigurieren (mit Console Handler, File Handler wird spaeter hinzugefuegt)
    # handlers=[] verhindert default Console Handler, wir fuegen ihn manuell hinzu fuer mehr Kontrolle
    logging.basicConfig(level=log_level, format=log_format, handlers=[]) # log_format wird hier bereits verwendet

    # Console Handler explizit hinzufuegen
    console_handler = logging.StreamHandler()
    console_handler.setLevel(log_level) # Nimm das globale Level
    console_handler.setFormatter(logging.Formatter(log_format))  # Jetzt sollte log_format definiert sein
    # Pruefen, ob nicht schon ein Console Handler vorhanden ist (z.B. bei wiederholten Aufrufen in Tests)
    if not any(isinstance(h, logging.StreamHandler) for h in logging.getLogger('').handlers):
         logging.getLogger('').addHandler(console_handler)


    # Testnachricht (geht nur an Konsole, da File Handler noch fehlt)
    logger.debug("DEBUG Logging initial konfiguriert (nur Konsole).")
    logger.info("INFO Logging initial konfiguriert (nur Konsole).")


    # --- Initialisierung (Argument Parser) ---
    current_script_version = getattr(Config, 'VERSION', 'unknown') # Aus Config Block 1

    parser = argparse.ArgumentParser(
        description=f"Firmen-Datenanreicherungs-Skript {current_script_version}. Automatisiert Anreicherung und Validierung aus Google Sheets.",
        formatter_class=argparse.RawTextHelpFormatter # Behaelt Formatierung im Help-Text
    )

    # Liste der gueltigen Modi - MUSS mit den elif-Zweigen unten uebereinstimmen!
    # Kategorisiert fuer die Menue-Ausgabe
    mode_categories = {
        "Batch-Verarbeitung (Schritt-Optimiert)": [
            "wiki_verify",       # Uebereinstimmend mit process_verification_batch (Block 26)
            "website_scraping",  # Uebereinstimmend mit process_website_scraping_batch (Block 27)
            "summarize_website", # Uebereinstimmend mit process_summarization_batch (Block 28)
            "branch_eval",       # Uebereinstimmend mit process_branch_batch (Block 29)
            "suggest_parents",
        ],
        "Sequentielle Verarbeitung (Zeilenweise)": [
            "full_run",          # Nutzt process_rows_sequentially (Block 24)
        ],
        "Re-Evaluate Markierte Zeilen (Spalte A='x')": [
            "reeval",            # Nutzt process_reevaluation_rows (Block 25)
        ],
        "Einzelne Dienstprogramme / Suchen": [
            "find_wiki_serp",        # Nutzt process_find_wiki_serp (Block 30)
            "website_lookup",      # Nutzt process_serp_website_lookup (Block 30)
            "check_urls",     # <<< NEUER MODUS HIER EINFÜGEN
            "contacts",            # Nutzt process_contact_search (Block 30)
            "update_wiki_suggestions", # Nutzt process_wiki_updates_from_chatgpt (Block 32)
            "wiki_reextract_missing_an", # Nutzt process_wiki_reextract_missing_an (Block 32)
            "website_details",       # EXPERIMENTELL - Nutzt process_website_details (Block 32)
            "train_technician_model", # Nutzt train_technician_model (Block 31)
            "alignment",           # Nutzt globale alignment_demo (Block 14)
            "reparatur_sitz",
            "plausi_check_data" # NEUER MODUS HIER
        ],
         "Kombinierte Laeufe (Vordefiniert)": [
             "combined_all", # Definiert eine Sequenz von Batch-Modi
         ]
    }
    # Erstellen Sie eine flache Liste aller validen Modi fuer die Validierung
    valid_modes = [mode for modes in mode_categories.values() for mode in modes]


    # Dynamisch generieren des Help-Textes fuer den Modus
    mode_help_text = "Betriebsmodus. Waehlen Sie einen der folgenden:\n"
    for category, modes in mode_categories.items():
        mode_help_text += f"\n{category}:\n"
        for mode in modes:
             mode_help_text += f"  - {mode}\n"

    parser.add_argument("--mode", type=str, help=mode_help_text)
    # Hilfsargument fuer die CLI-basierte Modusauswahl (wenn --mode gesetzt ist)
    parser.add_argument("-m", "--cli-mode", dest="mode", action="store_const", const=valid_modes[0] if valid_modes else None, help=argparse.SUPPRESS) # Unterdruecke in --help

    parser.add_argument("--limit", type=int, help="Maximale Anzahl zu verarbeitender Zeilen in den meisten Modi (prueft Zeilen VOR Ueberspringung/Filterung).", default=None)
    # start_sheet_row wird primaer fuer full_run verwendet, kann aber auch fuer Bereiche in Batch nuetzlich sein
    parser.add_argument("--start_sheet_row", type=int, help="Startzeile im Sheet (1-basiert) fuer 'full_run' und einige Batch-Modi. Standard: Automatische Ermittlung basierend auf Timestamp.", default=None)
    # end_sheet_row fuer Bereiche
    parser.add_argument("--end_sheet_row", type=int, help="Endzeile im Sheet (1-basiert) fuer 'full_run' und einige Batch-Modi. Standard: Ende des Sheets.", default=None)


    # Argument fuer den Re-Eval und Full-Run Modus zur Auswahl der Schritte
    # Moegliche Werte fuer die Schritte: 'wiki', 'chat', 'web', 'ml_predict', etc. (entsprechend den step_type Schluesseln in _process_single_row Block 19)
    # Default ist 'all' fuer alle Schritte, oder eine spezifische Liste
    # Dies sind die Schluessel, die _process_single_row (Block 19) in steps_to_run Set erwartet.
    valid_single_row_steps = ['wiki', 'chat', 'web', 'ml_predict'] # Fuegen Sie hier weitere Schritt-Schluessel hinzu, die _process_single_row versteht
    single_row_steps_help = f"Komma-getrennte Liste der Schritte im 'reeval' und 'full_run' Modus (z.B. 'wiki,chat').\nMögliche Schritte: {', '.join(valid_single_row_steps)}.\nStandard: {'all' if valid_single_row_steps else 'keine'}" # Standard: alle verfuegbaren Schritte

    # Standardwert fuer --steps: Alle gueltigen Single-Row Schritte, wenn es welche gibt
    default_steps_arg = ','.join(valid_single_row_steps) if valid_single_row_steps else ''
    parser.add_argument("--steps", type=str, help=single_row_steps_help, default=default_steps_arg)


    # Argumente fuer find_wiki_serp (falls ueber CLI gesteuert)
    parser.add_argument("--min_umsatz", type=float, help="Mindestumsatz in MIO € (CRM Spalte J) fuer find_wiki_serp Filter.", default=200.0) # Float fuer Konsistenz
    parser.add_argument("--min_employees", type=int, help="Mindestmitarbeiterzahl (CRM Spalte K) fuer find_wiki_serp Filter.", default=500)


    # Argumente fuer train_technician_model (Pfade fuer Output-Dateien)
    parser.add_argument("--model_out", type=str, default=MODEL_FILE, help=f"Pfad fuer das trainierte Modell (.pkl). Standard: {MODEL_FILE}") # Block 1 Konstante
    parser.add_argument("--imputer_out", type=str, default=IMPUTER_FILE, help=f"Pfad fuer den trainierten Imputer (.pkl). Standard: {IMPUTER_FILE}") # Block 1 Konstante
    parser.add_argument("--patterns_out", type=str, default=PATTERNS_FILE_JSON, help=f"Pfad fuer die Feature-Spaltenliste (.json). Standard: {PATTERNS_FILE_JSON}") # Block 1 Konstante

    # TODO: Fuegen Sie hier weitere CLI-Argumente hinzu, falls andere Modi Parameter benoetigen

    args = parser.parse_args()


    # --- Konfiguration laden ---
    Config.load_api_keys() # Nutzt jetzt logging intern (print am Anfang Block 1)


    # --- Logdatei-Konfiguration abschliessen ---
    # Bestimmen Sie den Log-Modus Namen basierend auf CLI oder Interaktion
    # Wir nutzen den CLI Modus Namen, wenn --mode gesetzt ist, sonst "interactive".
    log_mode_name = args.mode if args.mode else "interactive"
    LOG_FILE = create_log_filename(log_mode_name) # Nutzt globale Funktion (Block 3)

    # Wenn die Logdatei erfolgreich erstellt wurde
    if LOG_FILE:
        try:
            # Erstellen Sie den FileHandler fuer die Logdatei
            # mode='a' zum Anhaengen, encoding='utf-8' fuer Unicode
            file_handler = logging.FileHandler(LOG_FILE, mode='a', encoding='utf-8')
            file_handler.setLevel(log_level) # Nimm das globale Level
            # Verwenden Sie denselben Formatter wie fuer den Console Handler
            file_handler.setFormatter(logging.Formatter(log_format))
            # Fuege FileHandler zum Root-Logger hinzu
            # Pruefen, ob nicht schon ein File Handler mit demselben Pfad vorhanden ist (z.B. bei wiederholten Aufrufen in Tests)
            if not any(isinstance(h, logging.FileHandler) and h.baseFilename == os.path.abspath(LOG_FILE) for h in logging.getLogger('').handlers):
                logging.getLogger('').addHandler(file_handler)
            logger.info(f"Logging wird jetzt auch in Datei geschrieben: {LOG_FILE}")
        except Exception as e:
             # Logge Fehler nur auf Konsole, da FileHandler fehlgeschlagen ist
             # logger.exception loggt auch an die Konsole, wenn kein FileHandler da ist
             logger.error(f"Konnte FileHandler fuer Logdatei '{LOG_FILE}' nicht erstellen: {e}")
             # Optional: Entfernen Sie evtl. den fehlerhaften Handler aus der Liste
             logging.getLogger('').handlers = [h for h in logging.getLogger('').handlers if not isinstance(h, logging.FileHandler) or h.baseFilename == os.path.abspath(LOG_FILE)] # Entferne nur den fehlerhaften Handler


    # --- JETZT die Startmeldungen loggen (gehen jetzt in Konsole UND Datei) ---
    logger.info(f"===== Skript gestartet =====")
    logger.info(f"Version: {current_script_version}")
    # Logge den tatsaechlichen Pfad der Logdatei oder die Fehlermeldung
    logger.info(f"Logdatei: {LOG_FILE if LOG_FILE else 'FEHLER - Keine Logdatei erstellt'}")
    # Loggen Sie relevante CLI Argumente zur Dokumentation des Laufs
    logger.info(f"CLI Argumente: {args}")


    # --- Vorbereitung (Schema, Handler etc.) ---
    # Laden Sie das Ziel-Branchenschema (Block 6)
    # load_target_schema ist mit retry_on_failure dekoriert (Block 2).
    load_target_schema()


    # Initialisiere GoogleSheetHandler (Block 14)
    sheet_handler = None # Initialisiere Variable
    try:
        # Der GoogleSheetHandler Init (_init_ Methode) baut die Verbindung auf und laedt Daten.
        # Fehler werden dort gefangen und als ConnectionError erneut geworfen.
        sheet_handler = GoogleSheetHandler() #<- Zeile 11362
        logger.info("GoogleSheetHandler erfolgreich initialisiert.")
    except ConnectionError as e:
        # Wenn die Initialisierung des SheetHandlers fehlschlaegt (Verbindungs-/Ladefehler)
        logger.critical(f"FATAL: Initialisierung des GoogleSheetHandlers fehlgeschlagen: {e}")
        logger.critical(f"Bitte ueberpruefen Sie Ihre Google Sheets URL, Credentials und Berechtigungen.")
        logger.critical(f"Bitte Logdatei pruefen fuer Details: {LOG_FILE}")
        return # Beende Skript, wenn Sheet nicht geladen werden kann
    except Exception as e:
        # Fangen Sie andere unerwartete Fehler bei der Initialisierung ab
        logger.critical(f"FATAL: Unerwarteter Fehler bei Initialisierung von GoogleSheetHandler: {e}")
        logger.debug(traceback.format_exc())
        logger.critical(f"Bitte Logdatei pruefen fuer Details: {LOG_FILE}")
        return # Beende Skript


    # Initialisiere WikipediaScraper (Block 14)
    wiki_scraper = None # Initialisiere Variable
    try:
        # Der WikipediaScraper Init (_init_ Methode) konfiguriert die Bibliothek und Requests.
        # Fehler werden dort gefangen und erneut geworfen.
        wiki_scraper = WikipediaScraper()
        logger.info("WikipediaScraper erfolgreich initialisiert.")
    except Exception as e:
         # Wenn die Initialisierung des WikipediaScrapers fehlschlaegt
         logger.critical(f"FATAL: Initialisierung des WikipediaScrapers fehlgeschlagen: {e}")
         logger.debug(traceback.format_exc())
         logger.critical(f"Bitte Logdatei pruefen fuer Details: {LOG_FILE}")
         # Das Skript kann ohne Wiki Scraper viele Modi nicht sinnvoll laufen
         return # Beende Skript


    # TODO: Initialisieren Sie hier weitere Worker-Instanzen, falls Sie separate Klassen haben (z.B. OpenAIHandler, SerpAPIHandler)
    #      openai_handler = OpenAIHandler()
    #      serpapi_handler = SerpAPIHandler()

    logger.info("===== Spalten-Alignment Check =====")
    if sheet_handler and sheet_handler.sheet_values and len(sheet_handler.sheet_values) > 0:
        sheet_headers = sheet_handler.sheet_values[0]
        logger.info(f"Header aus Google Sheet (erste {len(sheet_headers)} Spalten): {sheet_headers}")
        
        # Ausgabe der COLUMN_MAP für den Abgleich (gekürzt für Lesbarkeit im Log)
        logger.info("Aktuelle COLUMN_MAP (Auszug):")
        for i, (key, value) in enumerate(COLUMN_MAP.items()):
            if i < 5 or i > len(COLUMN_MAP) - 6 : # Zeige erste 5 und letzte 5
                logger.info(f"  '{key}': {value}")
            elif i == 5:
                logger.info("  ...")
    else:
        logger.warning("Konnte Header nicht aus Google Sheet laden für Alignment Check.")
    logger.info("===================================")

    # Initialisiere DataProcessor Instanz (Block 15) mit Handlern
    # Uebergeben Sie alle benoetigten Handler an den DataProcessor.
    # Die __init__ Methode des DataProcessor (Block 15) prueft die Typen und wirft Value Error, wenn falsch.
    try:
         data_processor = DataProcessor(sheet_handler=sheet_handler, wiki_scraper=wiki_scraper)
         logger.info("DataProcessor erfolgreich initialisiert.")
    except Exception as e:
         # Fangen Sie Fehler bei der DataProcessor Initialisierung ab.
         logger.critical(f"FATAL: Initialisierung des DataProcessors fehlgeschlagen: {e}")
         logger.debug(traceback.format_exc())
         logger.critical(f"Bitte Logdatei pruefen fuer Details: {LOG_FILE}")
         return # Beende Skript


    # --- Modusauswahl und Ausfuehrung ---
    start_process_time = time.time() # Zeitmessung fuer die Verarbeitung starten
    logger.info(f"Starte Verarbeitung um {datetime.now().strftime('%H:%M:%S')}...")


    selected_mode = None # Variable fuer den tatsaechlich auszufuehrenden Modus

    # --- Ermitteln des zu fuehrenden Modus (CLI hat Prioritaet vor interaktiver Auswahl) ---
    # Wenn das --mode Argument ueber die Kommandozeile gesetzt wurde
    if args.mode:
        selected_mode = args.mode.lower() # Konvertiere zu Kleinbuchstaben
        # Pruefen Sie, ob der gewaehlte Modus in der Liste der validen Modi enthalten ist
        if selected_mode not in valid_modes:
            # Logge einen Fehler und beende das Skript, wenn der Modus ungueltig ist.
            logger.error(f"Ungueltiger Modus '{args.mode}' ueber Kommandozeile angegeben. Gueltige Modi: {', '.join(valid_modes)}")
            print(f"Fehler: Ungueltiger Modus '{args.mode}'. Bitte ueberpruefen Sie die Liste der gueltigen Modi (siehe --help).")
            return # Skript beenden
        logger.info(f"Betriebsmodus (CLI gewaehlt): {selected_mode}")

    # Wenn das --mode Argument NICHT ueber die Kommandozeile gesetzt wurde
    else:
        # --- Interaktive Modusauswahl ueber die Konsole ---
        print("\nBitte waehlen Sie den Betriebsmodus:")
        # Zeigen Sie die Liste der validen Modi kategorisiert an, mit Nummern.
        mode_options_map = {} # Dictionary zum Abbilden von Zahl/Name auf Modusname
        option_counter = 1 # Zaehler fuer die numerischen Optionen
        # Iteriere durch die Kategorien und Modi
        for category, modes in mode_categories.items():
             print(f"\n{category}:")
             for mode in modes:
                  print(f"  {option_counter}: {mode}")
                  mode_options_map[str(option_counter)] = mode # Bilde die numerische Option auf den Modusnamen ab
                  mode_options_map[mode] = mode # Bilde den Modusnamen (kleingeschrieben) auf sich selbst ab (fuer direkte Eingabe)
                  option_counter += 1 # Erhoehe den Zaehler


        # Fuegen Sie eine Option zum Abbrechen hinzu
        print(f"\n  0: Abbrechen")
        mode_options_map['0'] = 'exit' # Bilde 0 auf den speziellen 'exit' Modus ab


        # Schleife, bis ein gueltiger Modus gewaehlt wurde oder der Benutzer abbricht
        while selected_mode is None:
             try:
                  # Lesen Sie die Eingabe vom Benutzer
                  mode_input = input(f"Geben Sie den Modusnamen oder die Zahl ein: ").strip().lower()

                  # Pruefen Sie, ob die Eingabe einer Option in der Map entspricht
                  if mode_input in mode_options_map:
                       selected_mode = mode_options_map[mode_input] # Setzen Sie den gewaehlten Modusnamen

                       # Wenn der 'exit' Modus gewaehlt wurde
                       if selected_mode == 'exit':
                            logger.info("Modus 'exit' gewaehlt. Skript wird beendet.")
                            print("Abgebrochen durch Benutzer.")
                            return # Beende das Skript

                       # Logge den gewaehlten Modus
                       logger.info(f"Betriebsmodus (interaktiv gewaehlt): {selected_mode}")

                  else:
                       # Wenn die Eingabe keinem gueltigen Modus entspricht
                       print("Ungueltige Eingabe. Bitte waehlen Sie eine gueltige Option aus der Liste.")

                  # Wenn selected_mode immer noch None ist, laeuft die Schleife weiter


             except EOFError: # Benutzer hat Ctrl+D gedrueckt (End-of-File)
                  # Fangen Sie das EOFError ab und beenden Sie das Skript sauber.
                  logger.warning("Interaktive Modus-Eingabe abgebrochen (EOFError). Skript wird beendet.")
                  print("\nEingabe abgebrochen.")
                  return # Beende das Skript
             except Exception as e:
                 # Fangen Sie andere unerwartete Fehler bei der Eingabe ab
                 logger.error(f"Fehler bei interaktiver Modus-Eingabe: {e}")
                 logger.debug(traceback.format_exc())
                 print(f"Ein Fehler ist bei der Modus-Eingabe aufgetreten ({e}). Bitte pruefen Sie die Logdatei.")
                 return # Beende das Skript bei unerwartetem Fehler

    # ==============================================================================
    # === NEUER BLOCK: Interaktive Limit-Abfrage ===
    # ==============================================================================
    limit_arg_cli = args.limit # ursprüngliches Limit vom CLI Argument
    final_limit_to_use = limit_arg_cli # Standardmäßig das CLI-Limit verwenden

    # Wenn kein Limit über CLI gesetzt wurde UND der Modus nicht einer der ist,
    # bei denen ein Zeilenlimit typischerweise keinen Sinn macht.
    # (Für 'alignment' und 'train_technician_model' ist ein Zeilen-Durchlauf-Limit meist nicht relevant)
    # 'check_urls' und andere Batch-Modi können aber von einem Limit profitieren.
    skippable_limit_modes = ['alignment', 'train_technician_model'] 

    if final_limit_to_use is None and selected_mode not in skippable_limit_modes:
        while True:
            try:
                limit_input_str = input(f"Maximale Anzahl zu verarbeitender Zeilen für Modus '{selected_mode}' (Enter für Unbegrenzt, aktuell: Unbegrenzt): ").strip()
                if not limit_input_str: # Benutzer drückt Enter
                    final_limit_to_use = None
                    logger.info("Kein Limit für Zeilenverarbeitung gesetzt (interaktiv).")
                    break
                
                # Versuche, in Integer umzuwandeln
                temp_limit = int(limit_input_str)
                
                if temp_limit <= 0:
                    logger.warning(f"Ungültiges Limit '{temp_limit}' (<=0) eingegeben, wird als Unbegrenzt behandelt.")
                    final_limit_to_use = None 
                else:
                    final_limit_to_use = temp_limit
                    logger.info(f"Limit für Zeilenverarbeitung (interaktiv gesetzt): {final_limit_to_use}")
                break # Gültige Eingabe oder Entscheidung für "Unbegrenzt"
            except ValueError:
                print("Ungültige Eingabe. Bitte eine ganze Zahl eingeben oder Enter für Unbegrenzt.")
            except EOFError:
                logger.warning("Interaktive Limit-Eingabe abgebrochen. Nutze kein Limit (oder CLI-Vorgabe, falls vorhanden).")
                # final_limit_to_use behält den Wert von limit_arg_cli (also None, wenn hierher gekommen)
                break
            except Exception as e_limit_input:
                logger.error(f"Unerwarteter Fehler bei interaktiver Limit-Eingabe: {e_limit_input}")
                logger.debug(traceback.format_exc())
                print("Ein Fehler ist bei der Limit-Eingabe aufgetreten. Nutze kein Limit.")
                final_limit_to_use = None
                break
    elif final_limit_to_use is not None: # Wenn ein Limit via CLI gesetzt wurde
        logger.info(f"Verwende Limit aus CLI-Argument: {final_limit_to_use}")
    elif selected_mode in skippable_limit_modes and final_limit_to_use is None:
        logger.info(f"Modus '{selected_mode}' benötigt typischerweise kein Zeilenlimit. Limit-Abfrage übersprungen.")


    # Die Variable `final_limit_to_use` enthält nun das anzuwendende Limit (entweder von CLI, interaktiv oder None)
    # ==============================================================================
    # === ENDE NEUER BLOCK ===
    # ==============================================================================


    # --- Ausfuehrung des gewaehlten Modus ---
    try:
        # Holen Sie die CLI-Argumente fuer Start/End/Limit/Steps
        limit_arg = args.limit
        start_row_arg = args.start_sheet_row
        end_row_arg = args.end_sheet_row

        # Sonderbehandlung fuer --steps Argument (relevant fuer reeval und full_run)
        steps_to_run_set = set() # Initialisiere ein leeres Set
        # Pruefen Sie, ob das --steps Argument gesetzt ist und nicht "all" (case-insensitive)
        if args.steps and isinstance(args.steps, str) and args.steps.strip().lower() != 'all':
             # Teilen Sie den String in Schritte auf und bereinigen Sie Leerzeichen
             steps_list = [step.strip().lower() for step in args.steps.split(',') if step.strip()]
             # Filtern Sie nur erlaubte Schritte (die von _process_single_row verstanden werden Block 19)
             steps_to_run_set = set(step for step in steps_list if step in valid_single_row_steps) # valid_single_row_steps wurde oben definiert

             # Logge eine Warnung, wenn ungueltige Schritte angegeben wurden
             if len(steps_to_run_set) != len(steps_list):
                  invalid_steps = [step for step in steps_list if step not in valid_single_row_steps]
                  logger.warning(f"Ignoriere ungueltige Schritte im --steps Argument: {invalid_steps}. Fuehre nur {steps_to_run_set} aus.")

             # Wenn nach der Filterung keine gueltigen Schritte uebrig sind
             if not steps_to_run_set:
                  logger.error("Keine gueltigen Schritte im --steps Argument gefunden. Re-Eval/Full-Run kann nicht gestartet werden.")
                  print("Fehler: Keine gueltigen Schritte fuer den Modus ausgewaehlt. Bitte ueberpruefen Sie das --steps Argument.")
                  return # Skript beenden, wenn keine Schritte ausgewaehlt sind

        # Wenn das --steps Argument 'all' ist oder nicht gesetzt
        else:
             # Fuhren Sie standardmaessig alle gueltigen Single-Row Schritte aus.
             steps_to_run_set = set(valid_single_row_steps) # valid_single_row_steps wurde oben definiert
             # Logge, welche Schritte ausgewaehlt wurden, wenn es der Standard ist
             if default_steps_arg: # Wenn es ueberhaupt gueltige Schritte gibt
                 logger.debug(f"--steps Argument 'all' oder nicht gesetzt. Standard Schritte: {steps_to_run_set}.")


        # Dispatching basierend auf dem gewaehlten Modus (selected_mode)
        logger.info(f"Starte Ausfuehrung des Modus: {selected_mode}")

        # ---- Kombinierte LÄUFE ----
        if selected_mode == "combined_all":
            # Führt die wichtigsten Batch-Modi nacheinander aus
            logger.info("--- Start Kombinierter Modus: wiki_verify ---")
            # Rufe die Methode der DataProcessor Instanz auf (Block 26)
            data_processor.process_verification_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)
            logger.info("--- Start Kombinierter Modus: website_scraping ---")
            # Rufe die Methode der DataProcessor Instanz auf (Block 27)
            data_processor.process_website_scraping_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)
            logger.info("--- Start Kombinierter Modus: summarize_website ---")
            # Rufe die Methode der DataProcessor Instanz auf (Block 28)
            data_processor.process_summarization_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)
            logger.info("--- Start Kombinierter Modus: branch_eval ---")
            # Rufe die Methode der DataProcessor Instanz auf (Block 29)
            data_processor.process_branch_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)
            # TODO: Fuegen Sie hier weitere Batch-Modi hinzu, falls sie im kombinierten Lauf enthalten sein sollen
            logger.info("--- Kombinierter Modus abgeschlossen ---")


        # ---- Batch-VERARBEITUNG (Schritt-Optimiert) ----
        elif selected_mode == "wiki_verify": # Entspricht dem Batch-Modus Wiki Verifizierung (AX)
             # Rufe die Methode der DataProcessor Instanz auf (Block 26)
             data_processor.process_verification_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)

        elif selected_mode == "website_scraping": # Entspricht dem Batch-Modus Website Scraping (AR, AT, AP)
             # Rufe die Methode der DataProcessor Instanz auf (Block 27)
             data_processor.process_website_scraping_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)

        elif selected_mode == "summarize_website": # Entspricht dem Batch-Modus Website Summarization (AS, AP)
             # Rufe die Methode der DataProcessor Instanz auf (Block 28)
             data_processor.process_summarization_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)

        elif selected_mode == "branch_eval": # Entspricht dem Batch-Modus Branchen-Einstufung (W-Y, AO, AP)
             # Rufe die Methode der DataProcessor Instanz auf (Block 29)
             data_processor.process_branch_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)


        # ---- Sequentielle VERARBEITUNG (Zeilenweise) ----
        elif selected_mode == "full_run": # Nutzt process_rows_sequentially (Block 24)
             # Full_run verarbeitet sequentiell einen Bereich.
             # Startzeile wird vom CLI Argument oder automatisch ermittelt (erste leere AO).
             # Endzeile vom CLI Argument oder bis Ende Sheet.
             # Limit begrenzt die Anzahl der *verarbeiteten* Zeilen im Bereich.

             calculated_start_sheet_row = start_row_arg # Beginne mit CLI Argument start_sheet_row
             # Wenn start_sheet_row nicht ueber CLI gesetzt wurde
             if calculated_start_sheet_row is None:
                  # Automatische Ermittlung der Startzeile (erste Zeile ohne AO)
                  logger.info("Automatische Ermittlung der Startzeile fuer sequenzielle Verarbeitung (erste Zeile ohne AO)...")
                  # get_start_row_index (Block 14) gibt 0-basierten Index in Daten (ohne Header) zurueck.
                  # Prueft auf leeren AO (Block 1 Column Map).
                  start_data_index_no_header = sheet_handler.get_start_row_index(check_column_key="Timestamp letzte Pruefung", min_sheet_row=7)

                  # Wenn get_start_row_index -1 zurueckgibt (Fehler)
                  if start_data_index_no_header == -1:
                       logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Kann Full-Run nicht starten.")
                       return # Beende das Skript

                  # Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten Daten-Index
                  calculated_start_sheet_row = start_data_index_no_header + sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut


             # Berechnen Sie die tatsaechliche Anzahl der zu verarbeitenden Zeilen im Bereich.
             # (basierend auf Endzeile und Limit)
             total_sheet_rows = len(sheet_handler.get_all_data_with_headers()) # Block 14 SheetHandler
             calculated_end_sheet_row = end_row_arg if end_row_arg is not None else total_sheet_rows
             # Stellen Sie sicher, dass die Endzeile nicht vor der Startzeile liegt
             calculated_end_sheet_row = max(calculated_start_sheet_row - 1, calculated_end_sheet_row)


             # Die Anzahl der Zeilen im betrachteten Bereich
             rows_in_range = max(0, calculated_end_sheet_row - calculated_start_sheet_row + 1)

             # num_to_process ist das Limit, angewendet auf die Zeilen im Bereich.
             num_to_process_calc = rows_in_range # Standard: alle Zeilen im Bereich
             # Wenn ein Limit ueber CLI gesetzt wurde und es gueltig ist
             if limit_arg is not None and isinstance(limit_arg, int) and limit_arg >= 0:
                  num_to_process_calc = min(rows_in_range, limit_arg)


             # Wenn es Zeilen zu verarbeiten gibt
             if num_to_process_calc > 0:
                  logger.info(f"'full_run': Verarbeite {num_to_process_calc} Zeilen im Sheet-Bereich [{calculated_start_sheet_row}, {calculated_end_sheet_row}].")
                  # Rufe die sequentielle Verarbeitungsmethode auf (Block 24)
                  # _process_single_row (Block 19) wird intern aufgerufen.
                  data_processor.process_rows_sequentially(
                       start_sheet_row = calculated_start_sheet_row,
                       num_to_process = num_to_process_calc,
                       # Uebergeben Sie die aus dem --steps Argument ermittelten Flags (steps_to_run_set)
                       process_wiki_steps='wiki' in steps_to_run_set,
                       process_chatgpt_steps='chat' in steps_to_run_set,
                       process_website_steps='web' in steps_to_run_set,
                       process_ml_steps='ml_predict' in steps_to_run_set
                       # TODO: Weitere Schritt-Flags hier uebergeben
                       # force_reeval_in_single_row=False # Normalerweise kein Re-Eval im Full-Run
                       # clear_x_flag=False # Normalerweise kein X loeschen im Full-Run
                  )
             else:
                  # Wenn keine Zeilen zu verarbeiten sind
                  logger.info(f"Keine Zeilen fuer 'full_run' zu verarbeiten im Bereich [{calculated_start_sheet_row}, {calculated_end_sheet_row}] mit Limit {limit_arg}.")


        # ---- Re-EVALUATE Markierte Zeilen ----
        elif selected_mode == "reeval": # Nutzt process_reevaluation_rows (Block 25)
            # reeval Modus nutzt immer force_reeval=True in _process_single_row.
            # Das 'x'-Flag wird von _process_single_row (Block 21) geloescht, wenn clear_flag=True uebergeben wird.
            # Das Limit wird direkt an process_reevaluation_rows uebergeben und dort gehandhabt.
            if limit_arg is not None and isinstance(limit_arg, int) and limit_arg <= 0:
                # Wenn ein Limit von 0 oder weniger angegeben wurde
                logger.info(f"Limit {limit_arg} angegeben im Re-Eval Modus. Ueberspringe Verarbeitung.")
            else:
                 # Rufe die Methode der DataProcessor Instanz auf (Block 25)
                 data_processor.process_reevaluation_rows(
                     row_limit=limit_arg, # Uebergibt das Limit (kann None sein)
                     clear_flag=True, # Standardmaessig das 'x'-Flag loeschen
                     # Uebergeben Sie die aus dem --steps Argument ermittelten Schritte (steps_to_run_set)
                     process_wiki_steps='wiki' in steps_to_run_set,
                     process_chatgpt_steps='chat' in steps_to_run_set,
                     process_website_steps='web' in steps_to_run_set,
                     process_ml_steps='ml_predict' in steps_to_run_set
                     # TODO: Weitere Schritt-Flags hier uebergeben
                 )


        # ---- Einzelne DIENSTPROGRAMME / SUCHEN ----
        elif selected_mode == "find_wiki_serp": # Nutzt process_find_wiki_serp (Block 30)
             # find_wiki_serp sucht leere AY mit Groessenfilter. Nutzt limit, min_employees, min_umsatz.
             # Start/Endzeile koennen manuell gesetzt werden oder werden automatisch ermittelt (erste leere AY).
             data_processor.process_find_wiki_serp(
                 start_sheet_row=start_row_arg, # Kann manuell gesetzt werden
                 end_sheet_row=end_row_arg,     # Kann manuell gesetzt werden
                 limit=limit_arg,               # Kann manuell gesetzt werden
                 min_employees=args.min_employees, # Aus CLI Argument
                 min_umsatz=args.min_umsatz     # Aus CLI Argument
             )

        elif selected_mode == "website_lookup": # Nutzt process_serp_website_lookup (Block 30)
             # website_lookup sucht leere D. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden.
             data_processor.process_serp_website_lookup(
                 start_sheet_row=start_row_arg, # Kann manuell gesetzt werden
                 end_sheet_row=end_row_arg,     # Kann manuell gesetzt werden
                 limit=limit_arg                # Kann manuell gesetzt werden
             )

        elif selected_mode == "check_urls":
             data_processor.process_url_check(
                 start_sheet_row=start_row_arg,
                 end_sheet_row=end_row_arg,
                 limit=limit_arg
             )

        elif selected_mode == "contacts": # Nutzt process_contact_search (Block 30)
             # contacts sucht leere AM. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden.
             data_processor.process_contact_search(
                 start_sheet_row=start_row_arg, # Kann manuell gesetzt werden
                 end_sheet_row=end_row_arg,     # Kann manuell gesetzt werden
                 limit=limit_arg                # Kann manuell gesetzt werden
             )

        elif selected_mode == "update_wiki_suggestions": # Nutzt process_wiki_updates_from_chatgpt (Block 32)
             # update_wiki_suggestions prueft Status S. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden.
             data_processor.process_wiki_updates_from_chatgpt(
                 start_sheet_row=start_row_arg, # Kann manuell gesetzt werden
                 end_sheet_row=end_row_arg,     # Kann manuell gesetzt werden
                 limit=limit_arg                # Kann manuell gesetzt werden
             )

        elif selected_mode == "wiki_reextract_missing_an": # Nutzt process_wiki_reextract_missing_an (Block 32)
             # wiki_reextract_missing_an sucht M gefuellt & AN leer. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden.
             # Ruft intern _process_single_row mit steps={'wiki'} und force_reeval=True auf.
             data_processor.process_wiki_reextract_missing_an(
                 start_sheet_row=start_row_arg, # Kann manuell gesetzt werden
                 end_sheet_row=end_row_arg,     # Kann manuell gesetzt werden
                 limit=limit_arg                # Kann manuell gesetzt werden
             )


        elif selected_mode == "website_details": # EXPERIMENTELL - Nutzt process_website_details (Block 32)
             # website_details sucht 'x' in A. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden.
             data_processor.process_website_details(
                 start_sheet_row=start_row_arg, # Kann manuell gesetzt werden
                 end_sheet_row=end_row_arg,     # Kann manuell gesetzt werden
                 limit=limit_arg                # Kann manuell gesetzt werden
             )


        elif selected_mode == "train_technician_model": # Nutzt train_technician_model (Block 31)
             # training braucht keine Zeilenlimits im Sinne eines Bereichs oder der Anzahl zu verarbeitender Zeilen im Sheet.
             # Es nutzt prepare_data_for_modeling (Block 31), die alle relevanten Zeilen filtert.
             # Die output-Pfade werden aus CLI Argumenten genommen (args).
             data_processor.train_technician_model(
                 model_out=args.model_out,     # Aus CLI Argument
                 imputer_out=args.imputer_out, # Aus CLI Argument
                 patterns_out=args.patterns_out # Aus CLI Argument (JSON Datei)
             )

        elif selected_mode == "alignment": # Nutzt globale alignment_demo (Block 14)
             # alignment_demo ist eine globale Funktion, die das sheet Objekt braucht.
             # Sie braucht keine Zeilenlimits oder Start/Ende.
             if sheet_handler and sheet_handler.sheet:
                 alignment_demo(sheet_handler.sheet)
             else:
                 logger.error("Sheet-Handler oder Sheet-Objekt nicht verfuegbar fuer Alignment-Demo.")

        elif selected_mode == "reparatur_sitz": # NEUER BLOCK
             # Hier können Sie Start, Ende und Limit aus args verwenden, falls Sie dafür CLI-Optionen hinzufügen möchten
             # oder feste Werte / interaktive Abfragen für diesen Modus implementieren.
             # Für den Anfang ein kompletter Durchlauf (ab Datenstart):
             data_processor.process_repair_sitz_data(
                 start_sheet_row=None, # Beginnt nach den Headern
                 end_sheet_row=None,   # Bis zum Ende des Sheets
                 limit=final_limit_to_use # Verwendet das global ermittelte Limit
             )
        
        elif selected_mode == "plausi_check_data":
             data_processor.run_plausibility_checks_batch(
                 start_sheet_row=args.start_sheet_row, # Nimmt CLI-Argumente für Bereich
                 end_sheet_row=args.end_sheet_row,
                 limit=final_limit_to_use # VERWENDE das ermittelte Limit
             )


        elif selected_mode == "suggest_parents": # <<< NEUER ELIF-BLOCK
             data_processor.process_parent_suggestion_batch(
                 start_sheet_row=args.start_sheet_row,
                 end_sheet_row=args.end_sheet_row,
                 limit=final_limit_to_use, # Nutzt das ggf. interaktiv abgefragte Limit
                 re_evaluate_question_mark=True # Beispiel: Standardmäßig Fragezeichen neu bewerten
                                                # Sie können hierfür auch ein CLI Argument hinzufügen
             )

        # ---- Modus nicht gefunden (sollte durch Validierung oben abgefangen werden) ----
        else:
            # Dieser Zweig sollte aufgrund der Validierung am Anfang nie erreicht werden.
            logger.error(f"Unerwarteter Modus '{selected_mode}' erreichte das Ausfuehrungsende des Dispatchers.")
            print(f"Interner Fehler: Unbekannter Modus '{selected_mode}'.")


    # --- Ausnahmebehandlung fuer den gesamten Ausfuehrungsblock ---
    except KeyboardInterrupt:
         # Wenn der Benutzer das Skript manuell unterbricht (Ctrl+C)
         logger.warning("Skript durch Benutzer unterbrochen (KeyboardInterrupt).")
         print("\n! Skript wurde manuell beendet.")
    except Exception as e:
        # Dieser Block faengt alle unerwarteten Exceptions ab, die in den aufgerufenen
        # Funktionen/Methoden passieren und nicht intern gefangen und behandelt werden.
        logger.critical(f"FATAL: Unerwarteter Fehler waehrend der Ausfuehrung von Modus '{selected_mode}': {e}")
        # exception() loggt den Fehlertyp, die Nachricht und den vollständigen Traceback.
        logger.exception("Traceback des kritischen Fehlers:")
        # Gebe eine Fehlermeldung an die Konsole aus, die auf das Log verweist.
        print(f"\n! Ein kritischer Fehler ist aufgetreten: {type(e).__name__} - {e}")
        print(f"Bitte pruefen Sie die Logdatei fuer Details: {LOG_FILE}")


    # --- Abschluss der Skriptausfuehrung ---
    end_process_time = time.time() # Ende der Zeitmessung
    duration = end_process_time - start_process_time # Berechne die Gesamtdauer
    logger.info(f"Verarbeitung abgeschlossen um {datetime.now().strftime('%H:%M:%S')}.")
    logger.info(f"Gesamtdauer: {duration:.2f} Sekunden.")
    logger.info(f"===== Skript beendet =====")

    # Schliesse Logging Handler explizit
    # Dies stellt sicher, dass alle gepufferten Logmeldungen in die Datei geschrieben werden.
    logging.shutdown()

    # Logfile Pfad fuer den Nutzer auf der Konsole ausgeben
    if LOG_FILE:
        print(f"\nVerarbeitung abgeschlossen. Logfile: {LOG_FILE}")
    else:
        print("\nVerarbeitung abgeschlossen. Es konnte keine Logdatei erstellt werden.")


    # ==============================================================================
    # 7. ENTRY POINT
    # ==============================================================================

    # Fuehrt die main-Funktion aus, wenn das Skript direkt gestartet wird.
if __name__ == '__main__':
    # Die main() Funktion enthaltet nun die gesamte Logik und Initialisierung.
    # Alle globalen imports und Funktionen MÜSSEN VOR diesem Block definiert sein.
    # Die Klassen MÜSSEN VOR diesem Block definiert sein.

    main()


    # ==============================================================================
    # Ende DataProcessor Klasse
    # ==============================================================================

    # --- Ende der DataProcessor Klasse ---
    # Ein pass statement, um die Klassendefinition abzuschliessen, falls keine weiteren Methoden folgen.
    pass # <-- DIESES pass STATEMENT GEHOERT ZUM ENDE DER KLASSENDEFINITION
