Files
Brancheneinstufung2/brancheneinstufung.py
2025-05-06 12:53:53 +00:00

7453 lines
446 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Automatisiertes Unternehmensbewertungs-Skript - Refactoring v1.7.0
Basierend auf v1.6.x - Umstrukturierung in modulare Klassen und flexibles UI.
Dieses Skript dient der automatisierten Anreicherung, Validierung und Standardisierung
von Unternehmensdaten, primär aus einem Google Sheet, ergänzt durch Web Scraping,
Wikipedia, OpenAI (ChatGPT) und SerpAPI (Google Search, LinkedIn).
Autor: [Ihr Name/Pseudonym]
Version: v1.7.0
Hinweis zur Struktur:
Dieser Code wird in logischen Blöcken übermittelt. In einer realen Projektstruktur
würden diese Blöcke oft separaten .py-Dateien entsprechen (z.B. config.py, utils.py, ...).
Fügen Sie die Blöcke nacheinander in eine einzige Datei ein, achten Sie sorgfältig
auf die Einrückung, insbesondere innerhalb von Klassen und Funktionen.
"""
# ==============================================================================
# 1. IMPORTS
# ==============================================================================
# Standardbibliotheken
import os
import time
import re
import csv
import json
import pickle
import threading
import traceback
import logging
import argparse
from datetime import datetime
from urllib.parse import urlparse, urlencode, unquote # unquote wird später benötigt
# Externe Bibliotheken
import gspread
import wikipedia
import requests
import openai
from bs4 import BeautifulSoup
from oauth2client.service_account import ServiceAccountCredentials # gspread dependency
from difflib import SequenceMatcher
import unicodedata
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.impute import SimpleImputer
from sklearn.tree import DecisionTreeClassifier, export_text
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
import concurrent.futures
# Spezifische externe Tools
import gender_guesser.detector as gender # Für Geschlechtserkennung
# Optional: tiktoken für Token-Zählung (Modus 8)
try:
import tiktoken
print("tiktoken importiert.") # Debugging-Ausgabe
except ImportError:
tiktoken = None
print("tiktoken nicht gefunden. Token-Zählung wird geschätzt.") # Debugging-Ausgabe
# ==============================================================================
# 2. GLOBALE KONSTANTEN UND KONFIGURATION
# (Entspricht logisch etwa '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"
PATTERNS_FILE_JSON = "technician_patterns.json" # Optional
# --- Globale Konfiguration Klasse ---
class Config:
"""Zentrale Konfigurationseinstellungen."""
VERSION = "v1.7.0"
LANG = "de" # Sprache für Wikipedia etc.
# ACHTUNG: SHEET_URL ist hier ein Platzhalter. Ersetzen Sie ihn durch Ihre tatsächliche URL.
SHEET_URL = "https://docs.google.com/spreadsheets/d/1u_gHr9JUfmV1-iviRzbSe3575QEp7KLhK5jFV_gJcgo" # <<< ERSETZEN SIE DIES!
MAX_RETRIES = 5 # Anzahl der Wiederholungen bei API/Netzwerk-Fehlern
RETRY_DELAY = 10 # Basiswartezeit (Sekunden) für Retries (exponentieller Backoff wird im Decorator angewendet)
SIMILARITY_THRESHOLD = 0.65 # Schwelle für Namensähnlichkeit bei Wikipedia Validierung
DEBUG = True # Detailliertes Logging aktivieren/deaktivieren
WIKIPEDIA_SEARCH_RESULTS = 5 # Anzahl Ergebnisse bei Wikipedia Suche über Bibliothek
HTML_PARSER = "html.parser" # Parser für BeautifulSoup
TOKEN_MODEL = "gpt-3.5-turbo" # OpenAI Modell für Token-Zählung/Chat
# --- Konfiguration für Batching & Parallelisierung ---
# Diese Werte steuern die Größe der Verarbeitungsbatches und die Parallelität.
# Passen Sie sie an die Leistung Ihres Systems und die API-Limits an.
PROCESSING_BATCH_SIZE = 20 # Anzahl Zeilen pro Verarbeitungs-Batch (für _process_single_row in Batches)
OPENAI_BATCH_SIZE_LIMIT = 4 # Max. Texte pro OpenAI Call für Zusammenfassung (nur für batch_summarize)
MAX_SCRAPING_WORKERS = 10 # Threads für paralleles Website-Scraping
UPDATE_BATCH_ROW_LIMIT = 50 # Zeilen sammeln für gebündelte Sheet Updates (effizienter)
MAX_BRANCH_WORKERS = 10 # Threads für parallele Branchenbewertung
OPENAI_CONCURRENCY_LIMIT = 3 # Max. gleichzeitige OpenAI Calls (Semaphore)
PROCESSING_BRANCH_BATCH_SIZE = 20 # Batch-Größe für Branch-Evaluierung
SERPAPI_DELAY = 1.5 # Pause zwischen einzelnen SerpAPI-Aufrufen (Sekunden)
# --- API Schlüssel Speicherung ---
API_KEYS = {}
@classmethod
def load_api_keys(cls):
"""Lädt API-Schlüssel aus den definierten Dateien."""
# Der Logger ist hier noch nicht vollständig konfiguriert, verwenden Sie print oder debug_print
# logging.info wird nach Konfiguration des File Handlers korrekt funktionieren
print("Lade API-Schlüssel...")
cls.API_KEYS['openai'] = cls._load_key_from_file(API_KEY_FILE)
cls.API_KEYS['serpapi'] = cls._load_key_from_file(SERP_API_KEY_FILE)
cls.API_KEYS['genderize'] = cls._load_key_from_file(GENDERIZE_API_KEY_FILE)
if cls.API_KEYS.get('openai'):
openai.api_key = cls.API_KEYS['openai']
print("OpenAI API Key erfolgreich geladen.")
else:
print("WARNUNG: OpenAI API Key konnte nicht geladen werden (Datei fehlt oder ist leer?).")
if not cls.API_KEYS.get('serpapi'):
print("WARNUNG: SerpAPI Key konnte nicht geladen werden (Datei fehlt oder ist leer?). Bestimmte Funktionen sind deaktiviert.")
if not cls.API_KEYS.get('genderize'):
print("WARNUNG: Genderize API Key konnte nicht geladen werden (Datei fehlt oder ist leer?). Bestimmte Funktionen sind deaktiviert.")
@staticmethod
def _load_key_from_file(filepath):
"""Hilfsfunktion zum Laden eines Schlüssels aus einer Datei."""
try:
with open(filepath, "r", encoding="utf-8") as f:
key = f.read().strip()
if key:
# print(f"Schlüssel aus '{filepath}' erfolgreich geladen.") # Zu viel Lärm im Debug
return key
else:
print(f"WARNUNG: Datei '{filepath}' ist leer.")
return None
except FileNotFoundError:
# Info, da das Fehlen eines Keys nicht immer ein Fehler sein muss
print(f"INFO: API-Schlüsseldatei '{filepath}' nicht gefunden.")
return None
except Exception as e:
print(f"FEHLER beim Lesen der Schlüsseldatei '{filepath}': {e}")
return None
# --- Globale Spalten-Mapping (WICHTIG: MUSS ZU IHREM SHEET PASSEN!) ---
# Index ist 0-basiert, Spaltenbuchstaben sind 1-basiert (A=1, AW=49)
# Dies sollte die Mapping-Definition aus Ihrem Code (Teil 1) sein, vervollständigt.
COLUMN_MAP = {
"ReEval Flag": 0, # A - Markierungsspalte für manuelle Re-Evaluation
"CRM Name": 1, # B - Unternehmensname aus CRM
"CRM Kurzform": 2, # C - Manuell gepflegte Kurzform
"CRM Website": 3, # D - Website URL aus CRM (kann durch Skript ergänzt werden)
"CRM Ort": 4, # E - Ort aus CRM
"CRM Beschreibung": 5, # F - Beschreibung aus CRM
"CRM Branche": 6, # G - Branche aus CRM
"CRM Beschreibung Branche extern": 7, # H - Externe Branchenbeschreibung (falls vorhanden)
"CRM Anzahl Techniker": 8, # I - Bekannte Anzahl Servicetechniker aus CRM (Zielvariable für ML)
"CRM Umsatz": 9, # J - Umsatz aus CRM
"CRM Anzahl Mitarbeiter": 10, # K - Anzahl Mitarbeiter aus CRM
"CRM Vorschlag Wiki URL": 11, # L - Vorschlag für Wiki URL (kann manuell gepflegt werden)
"Wiki URL": 12, # M - Gefundene oder validierte Wikipedia URL
"Wiki Absatz": 13, # N - Erster Absatz des Wikipedia Artikels
"Wiki Branche": 14, # O - Branche aus Wikipedia Infobox
"Wiki Umsatz": 15, # P - Umsatz aus Wikipedia Infobox
"Wiki Mitarbeiter": 16, # Q - Mitarbeiterzahl aus Wikipedia Infobox
"Wiki Kategorien": 17, # R - Wikipedia Kategorien
"Chat Wiki Konsistenzprüfung": 18, # S - ChatGPT Check: Passt Wiki Artikel zum Unternehmen? ('OK', 'X', '?')
"Chat Begründung Wiki Inkonsistenz": 19, # T - Begründung, wenn S='X'
"Chat Vorschlag Wiki Artikel": 20, # U - ChatGPT Vorschlag für alternativen Wiki Artikel (falls S='X')
"Begründung bei Abweichung": 21, # V - Nicht mehr primär genutzt
"Chat Vorschlag Branche": 22, # W - ChatGPT Vorschlag für Branche (Zielschema)
"Chat Konsistenz Branche": 23, # X - Vergleich W vs. G ('ok', 'X', 'fallback_...')
"Chat Begründung Abweichung Branche": 24, # Y - Begründung für W
"Chat Prüfung FSM Relevanz": 25, # Z - ChatGPT Check: Ist das Unternehmen für FSM relevant?
"Chat Begründung für FSM Relevanz": 26, # AA - Begründung für Z
"Chat Schätzung Anzahl Mitarbeiter": 27, # AB - ChatGPT Schätzung Mitarbeiter
"Chat Konsistenzprüfung Mitarbeiterzahl": 28, # AC - Vergleich AB vs. K/Q
"Chat Begründung Abweichung Mitarbeiterzahl": 29, # AD - Begründung für AB/AC
"Chat Einschätzung Anzahl Servicetechniker": 30, # AE - ChatGPT Schätzung Servicetechniker
"Chat Begründung Abweichung Anzahl Servicetechniker": 31, # AF - Begründung für AE
"Chat Schätzung Umsatz": 32, # AG - ChatGPT Schätzung Umsatz
"Chat Begründung Abweichung Umsatz": 33, # AH - Begründung für AG
"Linked Serviceleiter gefunden": 34, # AI - Anzahl gefundener Kontakte (Serviceleiter)
"Linked It-Leiter gefunden": 35, # AJ - Anzahl gefundener Kontakte (IT-Leiter)
"Linked Management gefunden": 36, # AK - Anzahl gefundener Kontakte (Management)
"Linked Disponent gefunden": 37, # AL - Anzahl gefundener Kontakte (Disponent)
"Contact Search Timestamp": 38, # AM - Timestamp der letzten LinkedIn Suche
"Wikipedia Timestamp": 39, # AN - Timestamp der letzten erfolgreichen Wiki Extraktion (M-R befüllt)
"Timestamp letzte Prüfung": 40, # AO - Timestamp der letzten ChatGPT Evaluationen (W-Y, Z-AD, AE-AH befüllt)
"Version": 41, # AP - Skriptversion, die die Zeile zuletzt bearbeitet hat
"Tokens": 42, # AQ - Anzahl Tokens des letzten OpenAI Calls für diese Zeile (ggf. aggregiert)
"Website Rohtext": 43, # AR - Roh extrahierter Text von der Website
"Website Zusammenfassung": 44, # AS - ChatGPT Zusammenfassung von AR
"Website Scrape Timestamp": 45, # AT - Timestamp des letzten erfolgreichen Website Scrapings (AR, AS befüllt)
"Geschätzter Techniker Bucket": 46, # AU - Ergebnis des ML-Modells (Bucket)
"Finaler Umsatz (Wiki>CRM)": 47,# AV - Konsolidierter Umsatz (Wiki > CRM)
"Finaler Mitarbeiter (Wiki>CRM)": 48, # AW - Konsolidierte Mitarbeiterzahl (Wiki > CRM)
"Wiki Verif. Timestamp": 49, # AX - Timestamp der letzten Wiki-Verifikation (S-U befüllt)
"SerpAPI Wiki Search Timestamp": 50 # AY - Timestamp der letzten SerpAPI-Suche nach fehlender Wiki-URL (Modus find_wiki_serp)
}
# Bestätigen Sie, dass dies Ihre tatsächlichen Spalten sind!
# --- Globale Variablen für Branch Mapping (geladen von load_target_schema) ---
BRANCH_MAPPING = {} # Wird derzeit nicht verwendet, aber beibehalten
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar."
ALLOWED_TARGET_BRANCHES = [] # Liste der erlaubten Kurzformen
# ==============================================================================
# 3. GLOBALE HELPER FUNCTIONS & DECORATORS
# (Entspricht logisch etwa 'utils.py')
# ==============================================================================
# Logger Setup (Wird in main() finalisiert)
# Erhalten Sie eine Logger-Instanz für dieses Modul. Der Root-Logger wird in main() konfiguriert.
logger = logging.getLogger(__name__)
# Zusätzliche Imports, die von globalen Helfern benötigt werden (einige sind bereits am Anfang)
import random # Für Jitter im Backoff
import time # Für sleep
# logging ist bereits importiert
import requests # Für requests.exceptions (RequestException, HTTPError)
import gspread # Für gspread.exceptions (APIError, SpreadsheetNotFound)
import openai # Für openai.error (OpenAIError, AuthenticationError, InvalidRequestError etc.)
import wikipedia # Für wikipedia.exceptions (WikipediaException, PageError, DisambiguationError etc.)
# traceback ist bereits importiert
# re ist bereits importiert
# csv ist bereits importiert
# json ist bereits importiert
# pickle ist bereits importiert
# datetime ist bereits importiert
# urllib.parse (unquote) ist bereits importiert
# difflib (SequenceMatcher) ist bereits importiert
# unicodedata ist bereits importiert
# pandas, numpy sind bereits importiert
# concurrent.futures, threading sind bereits importiert
# gender_guesser ist bereits importiert
# tiktoken ist bereits importiert
# Logger für den Retry Decorator selbst
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 für Methoden zu extrahieren, falls vorhanden
self_arg = args[0] if args and hasattr(args[0], func_name) and isinstance(args[0], object) else None
# Konstruiere einen aussagekräftigeren Funktionsnamen für die Logs
effective_func_name = f"{self_arg.__class__.__name__}.{func_name}" if self_arg else func_name
# Basiswartezeit und maximale Anzahl Versuche aus Config holen
max_retries_config = getattr(Config, 'MAX_RETRIES', 3) # Anzahl der Versuche (nicht Wiederholungen nach dem ersten Fehler)
base_delay = getattr(Config, 'RETRY_DELAY', 5)
# Wenn max_retries_config 0 oder weniger ist, einfach einmal ausführen
if max_retries_config <= 0:
try:
return func(*args, **kwargs)
except Exception as e:
# Fehler loggen und weitergeben, wenn keine Retries konfiguriert sind
decorator_logger.error(f"FEHLER bei '{effective_func_name}' (keine Retries konfiguriert). {type(e).__name__} - {str(e)[:150]}...")
# Log traceback für unerwartete Fehler (nicht die spezifischen API/Netzwerkfehler)
if not isinstance(e, (requests.exceptions.RequestException, gspread.exceptions.APIError, openai.error.OpenAIError, wikipedia.exceptions.WikipediaException)):
decorator_logger.exception("Details zum Fehler:")
raise e # Re-raise the exception
# --- Retry logic for max_retries_config > 0 ---
# Die Schleife läuft max_retries_config mal.
for attempt in range(max_retries_config):
try:
# Logge jeden Versuch, außer den ersten (optional, um Log-Lärm zu reduzieren)
if attempt > 0:
decorator_logger.warning(f"Wiederhole Versuch {attempt + 1}/{max_retries_config} für '{effective_func_name}'...")
return func(*args, **kwargs) # Call the original function
# Spezifische Exceptions, die ein Retry nicht rechtfertigen (permanente Fehler)
except (gspread.exceptions.SpreadsheetNotFound, openai.error.AuthenticationError, ValueError) as e:
# Diese Fehler deuten auf ein permanentes Problem hin (falsche URL, falscher Key, falsche Eingabe)
decorator_logger.critical(f"❌ ENDGÜLTIGER FEHLER bei '{effective_func_name}': Permanentes Problem erkannt. {type(e).__name__} - {str(e)[:150]}...")
decorator_logger.exception("Details:") # Log traceback für permanente Fehler
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"❌ ENDGÜLTIGER FEHLER bei '{effective_func_name}': HTTP Fehler {status_code} erhalten ({e.response.reason}). Nicht wiederholbar. {str(e)[:150]}...")
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, openai.error.OpenAIError, wikipedia.exceptions.WikipediaException) as e:
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, gspread.exceptions.APIError) and hasattr(e, 'response') and e.response is not None and e.response.status_code == 429:
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, requests.exceptions.Timeout):
decorator_logger.warning(f"⏰ 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, openai.error.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"❌ ENDGÜLTIGER 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
# --- Token Count Funktion ---
# Übernommen aus Ihrem Code (Teil 5), leicht angepasst für Logger.
# Der retry_on_failure Decorator ist hier nicht sinnvoll, da es eine lokale Berechnung ist.
def token_count(text, model=None):
"""Zählt Tokens via tiktoken oder schätzt über Leerzeichen."""
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
if not text or not isinstance(text, str): return 0
current_model = model if model else getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo')
if tiktoken:
try:
# Cache encoding object per model
if not hasattr(token_count, 'enc_cache'):
token_count.enc_cache = {}
if current_model not in token_count.enc_cache:
token_count.enc_cache[current_model] = tiktoken.encoding_for_model(current_model)
enc = token_count.enc_cache[current_model]
return len(enc.encode(text))
except Exception as e:
logger.debug(f"Fehler beim Token-Counting mit tiktoken für Modell '{current_model}': {e} - Fallback zur Schätzung.")
# Fallback zur Schätzung
return len(str(text).split()) # Sicherstellen, dass text ein String ist
else:
# Fallback Schätzung
return len(str(text).split()) # Sicherstellen, dass text ein String ist
# --- Logging Helpers ---
# Übernommen aus Ihrem Code (Teil 3), leicht angepasst für Standard-Logger.
# LOG_FILE ist global definiert und wird in main() gesetzt
LOG_FILE = None
def create_log_filename(mode):
"""Erstellt einen zeitgestempelten Logdateinamen im LOG_DIR."""
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist (print am Anfang von main)
log_dir_path = LOG_DIR # Nutzt die globale Konstante
if not os.path.exists(log_dir_path):
try:
os.makedirs(log_dir_path, exist_ok=True) # exist_ok=True verhindert Fehler, wenn Dir existiert
logger.info(f"Log-Verzeichnis '{log_dir_path}' erstellt.")
except Exception as e:
logger.error(f"FEHLER: Konnte Log-Verzeichnis '{log_dir_path}' nicht erstellen: {e}")
# Versuche, die Datei im aktuellen Verzeichnis zu erstellen, wenn LOG_DIR fehlschlägt
log_dir_path = "." # Fallback Verzeichnis
logger.warning(f"Versuche, Logdatei im aktuellen Verzeichnis '{log_dir_path}' zu erstellen.")
try:
now = datetime.now().strftime("%d-%m-%Y_%H-%M")
# Sicherstellen, dass Config.VERSION verfügbar ist, Fallback falls nicht
ver_short = getattr(Config, 'VERSION', 'unknown').replace(".", "")
filename = f"{now}_{ver_short}_Modus{mode}.txt"
return os.path.join(log_dir_path, filename)
except Exception as e_fallback:
logger.error(f"FEHLER: Konnte Logdateinamen auch im Fallback-Verzeichnis '{log_dir_path}' nicht erstellen: {e_fallback}")
return None # Signalisiert Fehler
# debug_print ist nicht mehr notwendig, da wir das Standard-Logging nutzen.
# Alle bisherigen Aufrufe von debug_print werden durch logger.debug, logger.info, logger.warning, logger.error, logger.critical ersetzt.
# --- Text Normalisierung & Reinigung ---
# Übernommen aus Ihrem Code (Teil 3)
def simple_normalize_url(url):
"""Normalisiert URL zu domain.tld oder k.A. (ohne www, ohne Pfad)."""
if not url or not isinstance(url, str): return "k.A."
url = url.strip()
if not url or url.lower() == 'k.A.': return "k.A." # Prüfe auf Kleinbuchstaben "k.A."
# Falls kein Schema vorhanden, hinzufügen (HTTPS bevorzugen)
if not url.lower().startswith(("http://", "https://")): url = "https://" + url
try:
parsed = urlparse(url)
domain_part = parsed.netloc
if not domain_part: return "k.A." # Wenn netloc leer
domain_part = domain_part.split(":", 1)[0] # Port entfernen
if '@' in domain_part: domain_part = domain_part.split('@', 1)[1] # User/Passwort entfernen
# Wandle Punycode (IDN) in Unicode um
try: domain_part = domain_part.encode('ascii').decode('idna')
except UnicodeDecodeError: pass # Behalte Original, wenn IDNA fehlschlägt
domain_part = domain_part.lower() # Kleinschreibung
# Optional: "www." entfernen
if domain_part.startswith("www."): domain_part = domain_part[4:]
# Einfache Prüfung auf mindestens einen Punkt (Basic TLD check)
# Prüfen Sie auch auf leere domain_part nach Bearbeitung
return domain_part if domain_part and '.' in domain_part and domain_part.split('.')[-1].isalpha() else "k.A."
except Exception as e:
logger.error(f"Fehler bei URL-Normalisierung für '{url[:100]}...': {e}")
return "k.A."
def normalize_string(s):
"""Normalisiert Umlaute und Sonderzeichen nach einer definierten Liste."""
if not s or not isinstance(s, str): return ""
# Ersetzungen wie in Teil 3
replacements = { 'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue', 'ß': 'ss', 'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Å': 'A', 'Æ': 'AE', 'à': 'a', 'á': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'æ': 'ae', 'Ç': 'C', 'ç': 'c', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e', 'Ì': 'I', 'Í': 'I', 'Î': 'I', 'Ï': 'I', 'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i', 'Ñ': 'N', 'ñ': 'n', 'Ò': 'O', 'Ó': 'O', 'Ô': 'O', 'Õ': 'O', 'Ø': 'O', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', 'ø': 'o', 'Œ': 'OE', 'œ': 'oe', 'Š': 'S', 'š': 's', 'Ž': 'Z', 'ž': 'z', 'Ý': 'Y', 'ý': 'y', 'ÿ': 'y', 'Đ': 'D', 'đ': 'd', 'č': 'c', 'Č': 'C', 'ć': 'c', 'Ć': 'C', 'ł': 'l', 'Ł': 'L', 'ğ': 'g', 'Ğ': 'G', 'ş': 's', 'Ş': 'S', 'ă': 'a', 'Ă': 'A', 'ı': 'i', 'İ': 'I', 'ň': 'n', 'Ň': 'N', 'ř': 'r', 'Ř': 'R', 'ő': 'o', 'Ő': 'O', 'ű': 'u', 'Ű': 'U', 'ț': 't', 'Ț': 'T', 'ș': 's', 'Ș': 'S' }
# unicodedata Normalisierung zuerst
try: s = unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode('ascii')
except: pass
# Manuelle Ersetzungen
for src, target in replacements.items(): s = s.replace(src, target)
return s
def clean_text(text):
"""Bereinigt Text (Unicode, Referenzen, Whitespace, etc.)."""
if text is None: return "k.A."
try:
text = str(text)
if not text.strip(): return "k.A."
text = unicodedata.normalize("NFC", text)
text = re.sub(r'\[\d+\]', '', text) # [1], [2]
text = re.sub(r'\[\s*Bearbeiten\s*\|\s*Quelltext bearbeiten\s*\]', '', text, flags=re.IGNORECASE) # [Bearbeiten | Quelltext bearbeiten]
text = re.sub(r'\s+', ' ', text).strip() # Multiple Whitespace zu Single Space
return text if text else "k.A."
except Exception as e:
logger.error(f"Fehler bei clean_text für Input '{str(text)[:50]}...': {e}")
return "k.A."
def normalize_company_name(name):
"""Entfernt gängige Rechtsformzusätze etc. für Vergleiche."""
if not name: return ""
name = clean_text(name)
forms = [ r'gmbh', r'ges\.?\s*m\.?\s*b\.?\s*h\.?', r'gesellschaft mit beschränkter haftung', r'ug', r'u\.g\.', r'unternehmergesellschaft', r'haftungsbeschränkt', r'ag', r'a\.g\.', r'aktiengesellschaft', r'ohg', r'o\.h\.g\.', r'offene handelsgesellschaft', r'kg', r'k\.g\.', r'kommanditgesellschaft', r'gmbh\s*&\s*co\.?\s*kg', r'ges\.?\s*m\.?\s*b\.?\s*h\.?\s*&\s*co\.?\s*k\.g\.?', r'ag\s*&\s*co\.?\s*kg', r'a\.g\.?\s*&\s*co\.?\s*k\.g\.?', r'e\.k\.', r'e\.kfm\.', r'e\.kfr\.', r'eingetragene[rn]? kauffrau', r'eingetragene[rn]? kaufmann', r'ltd\.?', 'limited', r'ltd\s*&\s*co\.?\s*kg', r's\.?a\.?r\.?l\.?', 'sàrl', 'sagl', r's\.?a\.?', 'société anonyme', 'sociedad anónima', r's\.?p\.?a\.?', 'società per azioni', r'b\.?v\.?', 'besloten vennootschap', r'n\.?v\.?', 'naamloze vennootschap', r'plc\.?', 'public limited company', 'inc', 'incorporated', r'corp\.?', 'corporation', 'llc', 'limited liability company', r'kgaa', r'kommanditgesellschaft auf aktien', 'se', 'societas europaea', r'e\.?g\.?', 'eingetragene genossenschaft', 'genossenschaft', 'genmbh', r'e\.?v\.?', 'eingetragener verein', 'verein', 'stiftung', 'ggmbh', r'gemeinnützige gmbh', r'gemeinnützige[rn]? gmbh', 'gug', 'partg', 'partnerschaftsgesellschaft', 'partgmbb', 'og', r'o\.g\.', 'offene gesellschaft', r'e\.u\.', 'eingetragenes unternehmen', r'ges\.?n\.?b\.?r\.?', r'gesellschaft nach bürgerlichem recht', 'kollektivgesellschaft', 'einzelfirma', 'gruppe', 'holding', 'international', 'systeme', 'technik', 'logistik', 'solutions', 'services', 'management', 'consulting', 'produktion', 'vertrieb', 'entwicklung', 'maschinenbau', 'anlagenbau'
]
# Pattern für ganze Wörter (case-insensitive)
# Fügen Sie \b hinzu, um sicherzustellen, dass ganze Wörter gematcht werden (z.B. nicht "ag" in "manage")
# Bereinigen Sie die Formen vor dem Join (z.B. re.escape für Sonderzeichen in den Formen)
forms_escaped = [re.escape(form) for form in forms]
pattern = r'\b(?:' + '|'.join(forms_escaped) + r')\b' # ?: für non-capturing group
normalized = re.sub(pattern, '', name, flags=re.IGNORECASE)
# Interpunktion entfernen/ersetzen (außer evtl. &)
normalized = re.sub(r'[.,;:]', '', normalized)
normalized = re.sub(r'[\-/]', ' ', normalized) # Bindestriche etc. durch Leerzeichen ersetzen
normalized = re.sub(r'\s+', ' ', normalized).strip() # Multiple Leerzeichen reduzieren
return normalized.lower()
def fuzzy_similarity(str1, str2):
"""Berechnet Ähnlichkeit zwischen 0 und 1 (case-insensitive)."""
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()
# --- Numerische Extraktion ---
# Übernommen aus Ihrem Code (Teil 4 & Teil 2), leicht angepasst für Logger und Konsistenz.
def extract_numeric_value(raw_value, is_umsatz=False):
"""
Extrahiert und normalisiert Zahlenwerte (Umsatz in Mio, Mitarbeiter).
Berücksichtigt Tausendertrenner (Punkt, Apostroph), Dezimaltrenner (Komma), Einheiten (Tsd, Mio, Mrd)
und gängige Präfixe/Suffixe. Gibt "k.A." zurück, wenn nicht extrahierbar oder <= 0.
"""
if not raw_value: return "k.A."
raw_value_str = str(raw_value).strip()
if not raw_value_str or raw_value_str.lower() in ['k.a.', 'n/a', '-']:
return "k.A." # 0 ist hier wie k.A.
# Bereinigungsschritte wie in clean_text und vorheriger Implementierung
processed_value = clean_text(raw_value_str)
if processed_value == "k.A.": return "k.A."
# logger.debug(f"extract_numeric_value: Verarbeite Wert: '{raw_value_str}' -> '{processed_value}' (is_umsatz={is_umsatz})") # Zu viel Lärm
# Entferne gängige Präfixe/Suffixe und Spannen-Trennzeichen
processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|über|under|mehr als|weniger als|bis zu)\s+', '', processed_value)
processed_value = re.sub(r'[€$£¥]', '', processed_value).strip()
processed_value = re.split(r'\s*(-||bis)\s*', processed_value, 1)[0].strip() # Nimm nur den ersten Teil bei Spannen
# Entferne Tausendertrenner (Punkt, Apostroph) und ersetze Komma durch Punkt für Dezimal
processed_value_no_thousands = processed_value.replace('.', '').replace("'", "")
processed_value_final = processed_value_no_thousands.replace(',', '.')
match = re.search(r'([\d.]+)', processed_value_final)
if not match:
logger.debug(f"extract_numeric_value: Keine numerischen Zeichen gefunden nach Bereinigung von: '{raw_value_str}'")
return "k.A."
num_str = match.group(1)
try:
if not num_str or num_str == '.' or num_str.endswith('.'): # Zusätzliche Prüfungen
raise ValueError("Leerer oder ungültiger Zahlenstring gefunden")
num = float(num_str)
except ValueError as e:
logger.debug(f"Fehler bei Float-Umwandlung des extrahierten Strings '{num_str}' (aus '{raw_value_str}'): {e}")
return "k.A."
# --- Einheiten-Skalierung basierend auf ORIGINALSTRING ---
original_lower = raw_value_str.lower()
multiplier = 1.0
if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower):
multiplier = 1000000000.0
# logger.debug(" -> Einheit: Mrd gefunden")
elif re.search(r'\bmio\s*\b|\bmillionen\s*\b|\bmill.\s*\b', original_lower):
multiplier = 1000000.0
# logger.debug(" -> Einheit: Mio gefunden")
elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower):
multiplier = 1000.0
# logger.debug(" -> Einheit: Tsd gefunden")
num = num * multiplier
# Konvertiere zu Zielformat und runde ggf.
# Rückgabe als String, wie im Sheet erwartet
if is_umsatz:
# Umsatz wird in Millionen € gespeichert (gerundet auf ganze Mio)
# Rückgabe als String
umsatz_mio = round(num / 1000000.0)
return str(int(umsatz_mio)) if umsatz_mio > 0 else "k.A." # Nur positive Ergebnisse
else:
# Mitarbeiterzahl wird als ganze Zahl gespeichert (gerundet)
# Rückgabe als String
mitarbeiter_int = round(num)
return str(int(mitarbeiter_int)) if mitarbeiter_int > 0 else "k.A." # Nur positive Ergebnisse
# --- Numerische Extraktion für FILTERLOGIK (gibt 0 statt k.A. zurück) ---
# Übernommen aus Ihrem Code (Teil 2), leicht angepasst für Logger und Konsistenz mit extract_numeric_value.
def get_numeric_filter_value(value_str, is_umsatz=False):
"""
Extrahiert und normalisiert Zahlenwerte für die Filterlogik (Umsatz in Mio, Mitarbeiter int).
Gibt 0.0 (für Umsatz) oder 0 (für Mitarbeiter) zurück, wenn der Wert leer, k.A., nicht numerisch ist, oder 0 ergibt.
Beachtet Einheiten (Tsd, Mio, Mrd) für Umsatz.
"""
if value_str is None or pd.isna(value_str) or str(value_str).strip() == '':
return 0.0 if is_umsatz else 0 # Leer oder k.A. -> 0
raw_value_str = str(value_str).strip()
if raw_value_str.lower() in ['k.a.', 'n/a', '-']:
return 0.0 if is_umsatz else 0
try:
processed_value = clean_text(raw_value_str)
if processed_value == "k.A.": return 0.0 if is_umsatz else 0
processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|über|under|mehr als|weniger als|bis zu)\s+', '', processed_value)
processed_value = re.sub(r'[€$£¥]', '', processed_value).strip()
processed_value = re.split(r'\s*(-||bis)\s*', processed_value, 1)[0].strip()
processed_value_no_thousands = processed_value.replace('.', '').replace("'", "")
processed_value_final = processed_value_no_thousands.replace(',', '.')
match = re.search(r'([\d.]+)', processed_value_final)
if not match:
return 0.0 if is_umsatz else 0
num_str = match.group(1)
if not num_str or num_str == '.' or num_str.endswith('.'): return 0.0 if is_umsatz else 0
num = float(num_str)
# --- Einheiten-Skalierung basierend auf ORIGINALSTRING ---
original_lower = raw_value_str.lower()
multiplier = 1.0
if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower):
multiplier = 1000000000.0
elif re.search(r'\bmio\s*\b|\bmillionen\s*\b|\bmill.\s*\b', original_lower):
multiplier = 1000000.0
elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower):
multiplier = 1000.0
num = num * multiplier
# Das Ergebnis muss 0 oder positiv sein für die Filterlogik
result_num = num if num > 0 else 0 # Werte <= 0 zählen nicht
if is_umsatz:
# Rückgabe als Wert in Millionen (Float)
return result_num / 1000000.0
else: # Mitarbeiterzahl
# Rückgabe als ganze Zahl
return round(result_num)
except Exception as e:
logger.debug(f"Fehler in get_numeric_filter_value für Wert '{raw_value_str[:50]}...': {e}")
return 0.0 if is_umsatz else 0
# --- Gender und Email Helpers ---
# Übernommen aus Ihrem Code (Teil 4), leicht angepasst für Logger.
# Annahme: gender_guesser ist installiert
# Initialisieren Sie den Detector einmal global
try:
gender_detector = gender.Detector()
logger.debug("gender_guesser.Detector initialisiert.")
except ImportError:
gender_detector = None
logger.warning("gender_guesser Bibliothek nicht gefunden. Geschlechtserkennung deaktiviert.")
except Exception as e:
gender_detector = None
logger.error(f"Fehler bei Initialisierung von gender_guesser: {e}. Geschlechtserkennung deaktiviert.")
def get_gender(firstname):
"""Ermittelt Geschlecht via gender-guesser und Fallback Genderize API."""
if not firstname or not isinstance(firstname, str): return "unknown"
firstname_clean = firstname.strip().split(" ")[0] # Nur den ersten Teil des Vornamens
if not firstname_clean: return "unknown"
# 1. Versuch: gender-guesser (nutzt globale Instanz)
result_gg = "unknown"
if gender_detector:
try:
result_gg = gender_detector.get_gender(firstname_clean)
# logger.debug(f"GenderGuesser für '{firstname_clean}': {result_gg}") # Zu viel Lärm
except Exception as e_gg:
logger.warning(f"Fehler bei gender-guesser für '{firstname_clean}': {e_gg}")
result_gg = "unknown" # Fallback bei Fehler
# 2. Fallback: Genderize API (nur wenn gender-guesser unsicher ist)
if result_gg in ["andy", "unknown", "mostly_male", "mostly_female"]:
genderize_key = Config.API_KEYS.get('genderize')
if not genderize_key:
# logger.debug("Genderize API-Schlüssel nicht verfügbar, Fallback nicht möglich.") # Zu viel Lärm
return result_gg if result_gg.startswith("mostly_") else "unknown" # Gib bestenfalls mostly zurück
# API Call nutzt den retry_on_failure Decorator
@retry_on_failure
def call_genderize(name, api_key):
params = {"name": name, "apikey": api_key, "country_id": "DE"} # DE als Standardland
# logger.debug(f"Genderize API-Anfrage für '{name}'...") # Zu viel Lärm
response = requests.get("https://api.genderize.io", params=params, timeout=5) # Kurzer Timeout
response.raise_for_status() # Wirft HTTPError für schlechte Antworten
data = response.json()
# logger.debug(f" -> Genderize Antwort: {data}") # Zu viel Lärm
return data
try:
genderize_data = call_genderize(firstname_clean, genderize_key)
api_gender = genderize_data.get("gender")
probability = genderize_data.get("probability", 0)
count = genderize_data.get("count", 0) # Anzahl der Datenpunkte für diesen Namen
# Nur bei ausreichender Sicherheit und wenn Genderize ein Ergebnis liefert
# Prüfen Sie auch die Anzahl der Datenpunkte (count > 0)
if api_gender and probability is not None and probability > 0.7 and count > 0:
logger.debug(f" -> Übernehme Genderize Ergebnis '{api_gender}' (Prob: {probability}, Count: {count}) für '{firstname_clean}'")
return api_gender
else:
# logger.debug(f" -> Genderize unsicher/kein Ergebnis. Nutze Fallback: '{result_gg}'") # Zu viel Lärm
return result_gg if result_gg.startswith("mostly_") else "unknown"
except Exception as e: # retry_on_failure wirft Exception im Fehlerfall erneut
logger.error(f"FEHLER bei der Genderize API-Anfrage für '{firstname_clean}': {e}")
return result_gg if result_gg.startswith("mostly_") else "unknown"
else:
return result_gg
def get_email_address(firstname, lastname, website):
"""Generiert E-Mail: vorname.nachname@domain.tld."""
if not all([firstname, lastname, website]) or not all(isinstance(x, str) for x in [firstname, lastname, website]):
return ""
domain = simple_normalize_url(website)
if domain == "k.A." or not '.' in domain: return ""
# Normalisiere Vor- und Nachname, Kleinbuchstaben, nur erlaubte Zeichen
normalized_first = normalize_string(firstname).lower()
normalized_last = normalize_string(lastname).lower()
# Ersetze Leerzeichen und mehrere Bindestriche durch einen einzelnen
normalized_first = re.sub(r'\s+', '-', normalized_first)
normalized_last = re.sub(r'\s+', '-', normalized_last)
# Erlauben: alphanumerische Zeichen, Bindestrich
# Entfernen Sie alle Zeichen, die NICHT alphanumerisch oder Bindestrich sind
normalized_first = re.sub(r'[^\w\-]+', '', normalized_first)
normalized_last = re.sub(r'[^\w\-]+', '', normalized_last)
# Entferne führende/endende Bindestriche, falls nach Bereinigung entstanden
normalized_first = normalized_first.strip('-')
normalized_last = normalized_last.strip('-')
if normalized_first and normalized_last and domain:
return f"{normalized_first}.{normalized_last}@{domain}"
else:
return ""
# --- Schema Loading (Ziel-Branchenschema) ---
# Übernommen aus Ihrem Code (Teil 4), leicht angepasst für Logger.
# Annahmen: BRANCH_MAPPING, TARGET_SCHEMA_STRING, ALLOWED_TARGET_BRANCHES sind globale Variablen
BRANCH_MAPPING = {} # Wird derzeit nicht verwendet, aber beibehalten
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar."
ALLOWED_TARGET_BRANCHES = [] # Liste der erlaubten Kurzformen
def load_target_schema(csv_filepath=BRANCH_MAPPING_FILE):
"""Lädt Liste erlaubter Ziel-Branchen (Kurzformen) aus Spalte A der CSV."""
global BRANCH_MAPPING, TARGET_SCHEMA_STRING, ALLOWED_TARGET_BRANCHES
# BRANCH_MAPPING wird in dieser Version nicht mehr primär verwendet,
# da wir strikt gegen ALLOWED_TARGET_BRANCHES (die Kurzformen) validieren.
BRANCH_MAPPING = {} # Zurücksetzen
allowed_branches_set = set()
logger.info(f"Lade Ziel-Schema (Kurzformen) aus '{csv_filepath}' Spalte A...")
line_count = 0
try:
# Verwenden Sie 'utf-8-sig' für Dateien mit BOM
with open(csv_filepath, "r", encoding="utf-8-sig") as f:
reader = csv.reader(f)
# Versuche, die erste Zeile als Header zu überspringen
try:
header_row = next(reader)
# logger.debug(f"Überspringe Header-Zeile: {header_row}") # Zu viel Lärm
except StopIteration:
logger.warning(f"Schema-Datei '{csv_filepath}' ist leer.")
ALLOWED_TARGET_BRANCHES = []
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar (Datei leer)."
return # Datei leer, nichts zu tun
for row in reader:
line_count += 1
# logger.debug(f"Schema-Laden: Lese Zeile {line_count}: {row}") # Zu viel Lärm
if len(row) >= 1:
target = row[0].strip()
if target: # Nur nicht-leere Einträge hinzufügen
allowed_branches_set.add(target)
# logger.debug(f" -> '{target}' zum Set hinzugefügt.") # Zu viel Lärm
except FileNotFoundError:
logger.critical(f"FEHLER: Schema-Datei '{csv_filepath}' nicht gefunden.")
ALLOWED_TARGET_BRANCHES = []
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar (Datei nicht gefunden)."
return # Fehler, Abbruch der Ladefunktion
except Exception as e:
logger.critical(f"FEHLER beim Laden des Ziel-Schemas aus '{csv_filepath}' (Zeile {line_count if line_count > 0 else 'vor erster Zeile'}): {e}")
ALLOWED_TARGET_BRANCHES = []
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar (Fehler beim Lesen)."
return # Fehler, Abbruch der Ladefunktion
ALLOWED_TARGET_BRANCHES = sorted(list(allowed_branches_set), key=str.lower)
logger.info(f"Ziel-Schema geladen. {len(ALLOWED_TARGET_BRANCHES)} eindeutige Zielbranchen gefunden.")
if ALLOWED_TARGET_BRANCHES:
# logger.debug(f"Erste 10 geladene Zielbranchen: {ALLOWED_TARGET_BRANCHES[:10]}") # Zu viel Lärm
# Erstelle den String für den Prompt
schema_lines = ["Ziel-Branchenschema: Folgende Branchenbereiche sind gültig (Kurzformen):"]
schema_lines.extend(f"- {branch}" for branch in ALLOWED_TARGET_BRANCHES)
schema_lines.append("\nBitte ordne das Unternehmen ausschließlich in einen dieser Bereiche ein. Gib NUR den exakten Kurznamen der Branche zurück (keine Präfixe oder zusätzliche Erklärungen außer im 'Begründung'-Feld).") # Strengere Anweisung
schema_lines.append("Antworte ausschließlich im folgenden Format (keine Einleitung, kein Schlusssatz):")
schema_lines.append("Branche: <Exakter Kurzname der Branche aus der Liste>")
schema_lines.append("Übereinstimmung: <ok oder X (Vergleich deines Vorschlags mit der extrahierten Kurzform der CRM-Referenz)>")
schema_lines.append("Begründung: <Sehr kurze Begründung für deinen Branchenvorschlag>")
TARGET_SCHEMA_STRING = "\n".join(schema_lines)
# logger.debug(f"Generierter TARGET_SCHEMA_STRING:\n{TARGET_SCHEMA_STRING}") # Zu viel Lärm
else:
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar (Keine gültigen Branchen in Datei gefunden)."
logger.warning("Keine gültigen Zielbranchen im Schema gefunden. Branchenbewertung ist nicht möglich.")
# map_external_branch ist in dieser Struktur nicht mehr notwendig,
# da die Branchenevaluation über ChatGPT (evaluate_branche_chatgpt)
# direkt gegen ALLOWED_TARGET_BRANCHES validiert.
# --- OpenAI / CHATGPT FUNCTIONS ---
# Übernommen aus Ihrem Code (Teil 7), angepasst als globale Funktionen.
@retry_on_failure
def call_openai_chat(prompt, temperature=0.3, model=None):
"""Zentrale Funktion für OpenAI Chat API Aufrufe."""
if not Config.API_KEYS.get('openai'):
logger.error("Fehler: OpenAI API Key nicht konfiguriert.")
# Anstatt None zurückzugeben, werfen Sie eine Exception, damit retry_on_failure dies behandelt (oder nicht, je nach Config)
raise openai.error.AuthenticationError("OpenAI API Key nicht konfiguriert.")
if not prompt:
logger.error("Fehler: Leerer Prompt für OpenAI.")
# Werfen Sie einen Value Error Exception
raise ValueError("Leerer Prompt für OpenAI.")
current_model = model if model else getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo')
try:
# Token zählen vor dem Senden (optional, gut für Debugging/Monitoring)
# try: prompt_tokens = token_count(prompt, model=current_model); logger.debug(f"Sende Prompt an OpenAI ({current_model}, geschätzt {prompt_tokens} Tokens)...");
# except Exception as e_tc: logger.debug(f"Fehler beim Token-Zählen: {e_tc}"); # Logge Fehler beim Token-Zählen
# Der OpenAI Call selbst kann Exceptions werfen (APIError, RateLimitError, InvalidRequestError etc.)
# Diese werden vom @retry_on_failure Decorator behandelt.
response = openai.ChatCompletion.create(
model=current_model,
messages=[{"role": "user", "content": prompt}],
temperature=temperature
)
# Überprüfen Sie die Antwort auf Fehler (z.B. leere choices Liste)
if not response or not response.choices:
logger.error("OpenAI Call erfolgreich, aber keine Choices in der Antwort erhalten.")
# Werfen Sie eine spezifische Exception
raise openai.error.APIError("Keine Choices in OpenAI Antwort erhalten.")
# Extrahieren Sie den Inhalt der ersten (und einzigen) Antwort
result = response.choices[0].message.content.strip()
# Token zählen für die Antwort (optional)
# try: completion_tokens = token_count(result, model=current_model); total_tokens = response.usage.total_tokens; logger.debug(f"OpenAI Antwort erhalten ({completion_tokens} Completion Tokens, {total_tokens} Gesamt).");
# except Exception as e_tc: logger.debug(f"Fehler beim Token-Zählen der Antwort: {e_tc}"); # Logge Fehler beim Token-Zählen
return result # Gibt den bereinigten Antwortstring zurück
# Die spezifischen OpenAI Exceptions werden vom retry_on_failure gefangen.
# Nur andere unerwartete Exceptions kommen hier direkt an.
except Exception as e:
# Loggen Sie den unerwarteten Fehler
logger.error(f"Allgemeiner Fehler während OpenAI-Aufruf: {type(e).__name__} - {e}")
# Werfen Sie die Exception erneut, damit der retry_on_failure Decorator sie fangen kann.
raise e
def summarize_website_content(raw_text):
"""Erstellt Zusammenfassung von Website-Rohtext via OpenAI."""
if not raw_text or str(raw_text).strip() == "" or str(raw_text).strip().lower() in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]:
logger.debug("summarize_website_content skipped: No valid raw text.")
return "k.A."
# Kürze den Rohtext, falls er sehr lang ist, um Token zu sparen/Limits zu vermeiden
# Die maximale Länge des Prompts ist das Limit minus der erwarteten Antwortlänge.
# Eine konservative Schätzung für den Text sind 3000 Zeichen.
max_raw_length = 3000
if len(str(raw_text)) > max_raw_length:
logger.debug(f"Kürze Rohtext für Zusammenfassung von {len(str(raw_text))} auf {max_raw_length} Zeichen.")
raw_text = str(raw_text)[:max_raw_length]
prompt = (
"Du bist ein KI-Assistent, der Webinhalte analysiert.\n"
"Fasse den folgenden Text einer Unternehmenswebsite prägnant zusammen. "
"Konzentriere dich dabei auf:\n"
"- Haupttätigkeitsfeld des Unternehmens\n"
"- Wichtigste Produkte und/oder Dienstleistungen\n"
"- Zielgruppe (falls erkennbar)\n\n"
f"Website-Text:\n```\n{raw_text}\n```\n\n"
"Zusammenfassung (max. 100 Wörter):"
)
# Call_openai_chat nutzt den retry_on_failure Decorator.
# Wenn call_openai_chat nach Retries eine Exception wirft, wird diese hier nicht gefangen,
# sondern weitergereicht (z.B. an _process_single_row), was gut ist.
try:
summary = call_openai_chat(prompt, temperature=0.2)
return summary if summary and summary.strip() else "k.A. (Keine Zusammenfassung erhalten)"
except Exception as e:
# Fehler beim OpenAI Call (wird vom retry_on_failure geloggt)
# Geben Sie einen Fehlerwert zurück
return f"k.A. (Fehler Zusammenfassung: {str(e)[:50]}...)"
# Übernommen aus summarize_batch_openai in Teil 7/9, angepasst als globale Funktion.
@retry_on_failure # Anwenden des Decorators auf die Batch-Funktion
def summarize_batch_openai(tasks_data):
"""
Fasst eine Liste von Rohtexten in einem einzigen OpenAI API Call zusammen.
Die Prüfung auf das Token-Limit wird jetzt primär der API überlassen.
Args:
tasks_data (list): Eine Liste von Dictionaries, jedes enthält:
{'row_num': int, 'raw_text': str}
Returns:
dict: Ein Dictionary, das Zeilennummern auf ihre Zusammenfassungen mappt.
z.B. {2122: "Zusammenfassung A", 2123: "Zusammenfassung B"}
Bei Fehlern oder fehlenden Zusammenfassungen wird ein Fehlerstring verwendet.
Wirft Exception bei API-Fehlern nach Retries.
"""
if not tasks_data: return {}
# Filtere Tasks, die gültigen Text haben.
# Achten Sie darauf, dass die Filterkriterien konsistent sind mit summarize_website_content.
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)"]]
if not valid_tasks:
logger.debug("Keine gültigen Rohtexte für Batch-Zusammenfassung gefunden.")
# Geben Sie ein Ergebnisdict zurück, das dies für alle Zeilen widerspiegelt
return {t['row_num']: "k.A. (Kein gültiger Rohtext im Batch)" for t in tasks_data}
logger.debug(f"Starte Batch-Zusammenfassung für {len(valid_tasks)} gültige Texte (Zeilen: {[t['row_num'] for t in valid_tasks]})...")
# --- Aggregierten Prompt erstellen ---
prompt_parts = [
"Du bist ein KI-Assistent, der Webinhalte analysiert.",
"Fasse für JEDEN der folgenden Texte einer Unternehmenswebsite prägnant zusammen. "
"Konzentriere dich dabei auf:\n"
"- Haupttätigkeitsfeld des Unternehmens\n"
"- Wichtigste Produkte und/oder Dienstleistungen\n"
"- Zielgruppe (falls erkennbar)\n\n"
"Gib das Ergebnis für JEDEN Text im folgenden Format aus, auf einer neuen Zeile:\n"
"RESULTAT <Zeilennummer>: <Zusammenfassung für diese Zeilennummer>\n\n"
"Halte jede Zusammenfassung kurz, max. 100 Wörter.\n\n",
"--- Texte zur Zusammenfassung ---"
]
text_block = ""
row_numbers_in_batch = [] # Zeilen, die tatsächlich im Prompt landen
# Baue den Textblock zusammen. Kürze jeden einzelnen Text, um das Gesamtprompt-Limit nicht zu sprengen.
max_chars_per_single_text_in_batch = 1500 # Zeichenlimit für jeden Text innerhalb des Batch-Prompts
for task in valid_tasks:
row_num = task['row_num']
raw_text = str(task['raw_text']) # Sicherstellen, dass es ein String ist
raw_text_short = raw_text[:max_chars_per_single_text_in_batch] # Kürzen für den Prompt
entry_text = f"\n--- TEXT Zeile {row_num} ---\n{raw_text_short}\n--- ENDE TEXT Zeile {row_num} ---\n"
text_block += entry_text
row_numbers_in_batch.append(row_num) # Füge die Zeilennummer hinzu
if not row_numbers_in_batch:
# Sollte nur passieren, wenn valid_tasks leer war, was oben abgefangen wird
logger.error("Logikfehler: Keine Zeilen in row_numbers_in_batch trotz valid_tasks.")
return {t['row_num']: "FEHLER (Batch-Erstellung)" for t in tasks_data}
prompt_parts.append(text_block)
prompt_parts.append("\n--- Ende der Texte ---")
prompt_parts.append("\nBitte gib NUR die 'RESULTAT <Zeilennummer>: ...' Zeilen zurück.")
final_prompt = "\n".join(prompt_parts)
# Optional: Token zählen zur Info, aber nicht zur Blockade
# try: prompt_tokens = token_count(final_prompt, model=getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo')); logger.debug(f"Geschätzt Prompt-Tokens für Batch: {prompt_tokens}.");
# except Exception as e_tc: logger.debug(f"Fehler beim Token-Zählen: {e_tc}");
# --- OpenAI API Call ---
# call_openai_chat nutzt den retry_on_failure Decorator und wirft bei endgültigem Fehler eine Exception.
# Der retry_on_failure Decorator DIESER summarize_batch_openai Funktion fängt die Exception
# und führt die Retries für die GESAMTE Batch-Funktion durch.
try:
chat_response = call_openai_chat(final_prompt, temperature=0.2)
# Wenn call_openai_chat erfolgreich ist, gibt es den String zurück.
# Exceptions werden nach Retries geworfen und vom äußeren retry_on_failure dieser Funktion gefangen.
if not chat_response:
# Dieser Fall sollte nach der Änderung in call_openai_chat nicht mehr auftreten (würde Exception werfen)
logger.error("call_openai_chat gab unerwarteterweise None zurück für Batch-Zusammenfassung.")
# Werfen Sie eine spezifische Exception, damit der äußere Decorator sie fängt
raise openai.error.APIError("Keine Antwort von OpenAI erhalten für Batch-Zusammenfassung.")
except Exception as e:
# Wenn call_openai_chat oder der äußere retry_on_failure eine Exception wirft
# Die Exception wird hier gefangen, bevor sie an den Aufrufer (DataProcessor Methode) weitergeleitet wird.
logger.error(f"Endgültiger FEHLER beim OpenAI-Batch-Aufruf für Zusammenfassung (innerhalb Batch Decorator): {e}")
# Geben Sie ein Dictionary zurück, das signalisiert, dass für alle Zeilen im Batch ein Fehler aufgetreten ist
return {row_num: f"FEHLER API: {str(e)[:100]}" for row_num in row_numbers_in_batch}
# --- Antwort parsen ---
summaries = {} # Initialize with empty dict
lines = chat_response.strip().split('\n')
parsed_count = 0
for line in lines:
# Matcht "RESULTAT <Nummer>:" und den Rest der Zeile
match = re.match(r"RESULTAT (\d+): (.*)", line.strip())
if match:
row_num = int(match.group(1))
summary_text = match.group(2).strip()
# Stellen Sie sicher, dass die Zeilennummer im ursprünglichen Batch war
if row_num in row_numbers_in_batch:
summaries[row_num] = summary_text
parsed_count += 1
# else: logger.debug(f"Warnung: Antwort für unerwartete Zeilennummer {row_num} im Batch erhalten.") # Zu viel Lärm
logger.debug(f"Batch-Zusammenfassung: {parsed_count} von {len(row_numbers_in_batch)} Zeilen erfolgreich geparst.")
# Fügen Sie einen Fehlerwert für Zeilen hinzu, die nicht geparst werden konnten
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"
# Füge k.A. für Tasks hinzu, die ungültigen Rohtext hatten (aus valid_tasks gefiltert)
# Diese waren nie Teil des OpenAI Prompts
original_row_nums = {t['row_num'] for t in tasks_data}
for row_num in original_row_nums:
if row_num not in summaries:
summaries[row_num] = "k.A. (Kein gültiger Rohtext im Batch)"
return summaries # Rückgabe des Dictionarys mit Ergebnissen oder Fehlern
# Übernommen aus evaluate_branche_chatgpt in Teil 4/7, angepasst als globale Funktion.
# Nutzt globale ALLOWED_TARGET_BRANCHES und TARGET_SCHEMA_STRING.
@retry_on_failure # Anwenden des Decorators auf die Funktion, die call_openai_chat aufruft
def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kategorien, website_summary):
"""
Ordnet das Unternehmen basierend auf den angegebenen Informationen exakt einer Branche
aus dem Ziel-Branchenschema (nur Kurzformen) zu. Validiert den ChatGPT-Vorschlag
strikt gegen die erlaubten Kurzformen und führt einen Fallback auf die (extrahierte)
CRM-Kurzform durch, falls der Vorschlag ungültig ist.
Args:
crm_branche (str): Branche laut CRM (kann noch Präfix enthalten).
beschreibung (str): Unternehmensbeschreibung (CRM).
wiki_branche (str): Branche aus Wikipedia (falls vorhanden).
wiki_kategorien (str): Wikipedia-Kategorien.
website_summary (str): Zusammenfassung des Website-Inhalts.
Returns:
dict: Enthält "branch" (die finale, gültige Kurzform oder Fehler),
"consistency" ('ok', 'X', 'fallback_crm_valid', 'fallback_invalid', 'error_...'),
"justification" (Begründung von ChatGPT oder Fallback-Info).
Wirft Exception bei API-Fehlern (von call_openai_chat nach Retries).
"""
global ALLOWED_TARGET_BRANCHES, TARGET_SCHEMA_STRING
# Grundlegende Prüfung: Ist das Schema überhaupt geladen?
if not ALLOWED_TARGET_BRANCHES:
logger.critical("FEHLER in evaluate_branche_chatgpt: Ziel-Branchenschema (ALLOWED_TARGET_BRANCHES) ist leer. Kann Branchen nicht validieren.")
# Geben Sie ein Fehlerergebnis zurück
return {"branch": "FEHLER - SCHEMA FEHLT", "consistency": "error_schema_missing", "justification": "Fehler: Ziel-Schema nicht geladen"}
# Erstelle Lookup für erlaubte Branches (case-insensitive)
allowed_branches_lookup = {b.lower(): b for b in ALLOWED_TARGET_BRANCHES}
# --- Prompt für ChatGPT erstellen ---
# Beginne mit den Regeln und der Liste der gültigen Kurzformen
prompt_parts = [TARGET_SCHEMA_STRING] # Enthält bereits die Liste und Anweisungen
prompt_parts.append("\nOrdne das Unternehmen anhand folgender Angaben exakt einer Branche des Ziel-Branchenschemas (Kurzformen) zu:")
# Füge nur vorhandene Informationen hinzu und kürze sie ggf.
# Stellen Sie sicher, dass die Werte keine None-Typen sind
if crm_branche and str(crm_branche).strip() and str(crm_branche).strip() != "k.A.": prompt_parts.append(f"- CRM-Branche (Referenz): {str(crm_branche).strip()}")
if beschreibung and str(beschreibung).strip() and str(beschreibung).strip() != "k.A.": prompt_parts.append(f"- Beschreibung: {str(beschreibung).strip()[:500]}...") # Kürzen
if wiki_branche and str(wiki_branche).strip() and str(wiki_branche).strip() != "k.A.": prompt_parts.append(f"- Wikipedia-Branche: {str(wiki_branche).strip()[:300]}...") # Kürzen
if wiki_kategorien and str(wiki_kategorien).strip() and str(wiki_kategorien).strip() != "k.A.": prompt_parts.append(f"- Wikipedia-Kategorien: {str(wiki_kategorien).strip()[:500]}...") # Kürzen
if website_summary and str(website_summary).strip() and str(website_summary).strip() != "k.A.": prompt_parts.append(f"- Website-Zusammenfassung: {str(website_summary).strip()[:500]}...") # Kürzen
# Fallback, wenn zu wenige Infos da sind (mindestens 2 relevante Zeilen im Prompt neben dem Schema)
# Der Prompt hat immer mindestens 1 Zeile (Schema) + 1 Zeile (Instruktion "Ordne zu...").
# Prüfen wir, ob mindestens 2 Info-Zeilen hinzugefügt wurden.
if len(prompt_parts) < 3: # 1 (Schema) + 1 (Instruktion) + <2 (Infos)
logger.warning("Warnung in evaluate_branche_chatgpt: Zu wenige Informationen (<2 Quellen) für Branchenevaluierung.")
# Geben Sie ein Fehlerergebnis zurück, verwenden Sie die CRM-Branche als Fallback
return {"branch": crm_branche, "consistency": "error_no_info", "justification": "Fehler: Zu wenige Informationen für eine Einschätzung"}
# Prompt für das Antwortformat ist bereits in TARGET_SCHEMA_STRING enthalten.
prompt = "\n".join(prompt_parts)
# logger.debug(f"Erstellter Prompt für Branchenevaluierung:\n---\n{prompt}\n---") # Zu viel Lärm
# --- ChatGPT aufrufen ---
# call_openai_chat nutzt den retry_on_failure Decorator und wirft bei endgültigem Fehler eine Exception
chat_response = None
try:
chat_response = call_openai_chat(prompt, temperature=0.0) # Niedrige Temperatur für konsistente Zuordnung
if not chat_response:
# Dieser Fall sollte nach der Änderung in call_openai_chat nicht mehr auftreten (würde Exception werfen)
logger.error("call_openai_chat gab unerwarteterweise None zurück für Branchenevaluation.")
raise openai.error.APIError("Keine Antwort von OpenAI erhalten für Branchenevaluation.") # Wirf eine Exception
except Exception as e:
# Wenn call_openai_chat nach Retries eine Exception wirft
logger.error(f"Endgültiger FEHLER beim OpenAI-Aufruf für Branchenevaluation: {e}")
# Geben Sie ein Fehlerergebnis zurück, verwenden Sie die CRM-Branche als Fallback
# Hängen Sie die Fehlermeldung an die Begründung an.
return {"branch": crm_branche, "consistency": "error_api_failed", "justification": f"Fehler API: {str(e)[:100]}"}
# --- Antwort parsen ---
lines = chat_response.strip().split("\n")
# Initialisiere Ergebnisdict mit Fallback-Werten oder leeren Strings
result = {"branch": None, "consistency": None, "justification": ""}
suggested_branch = ""
parsed_branch = False
for line in lines:
line_lower = line.lower()
if line_lower.startswith("branche:"):
# Extrahiere die vorgeschlagene Branche, bereinige Leerzeichen und Anführungszeichen
suggested_branch = line.split(":", 1)[1].strip().strip('"\'')
parsed_branch = True
elif line_lower.startswith("übereinstimmung:"):
# Wir überschreiben die Konsistenz später basierend auf unserer Logik, ignorieren Sie die KI-Antwort hier
pass
elif line_lower.startswith("begründung:"):
# Erfasse die Begründung. Wenn es mehrere Begründungszeilen gibt, hänge sie an.
if result["justification"]: result["justification"] += " " + line.split(":", 1)[1].strip()
else: result["justification"] = line.split(":", 1)[1].strip()
# Behandle andere mögliche unerwartete Zeilen (optional)
# elif line_lower.startswith(("resultat", "eintrag", "antwort")):
# logger.warning(f"Unerwartete Zeile im Branchen-Prompt gefunden: {line[:100]}...")
if not parsed_branch or not suggested_branch: # Prüfe, ob Branch geparst wurde UND nicht leer ist
logger.error(f"Fehler in evaluate_branche_chatgpt: Konnte 'Branche:' nicht oder nur leer aus Antwort parsen: {chat_response[:500]}...") # Logge Anfang der Antwort
# Geben Sie ein Fehlerergebnis zurück, verwenden Sie die CRM-Branche als Fallback
return {"branch": crm_branche, "consistency": "error_parsing", "justification": f"Fehler Parsing: Antwortformat unerwartet."}
# --- Validierung des ChatGPT-Vorschlags ---
final_branch = None
suggested_branch_lower = suggested_branch.lower()
# 1. Ist der vorgeschlagene Branch EXAKT im Ziel-Schema enthalten?
if suggested_branch_lower in allowed_branches_lookup:
final_branch = allowed_branches_lookup[suggested_branch_lower] # Nimm die korrekte Schreibweise aus der Liste
logger.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist gültig ('{final_branch}').")
result["consistency"] = "pending_comparison" # Temporärer Status vor Vergleich mit CRM
else:
# --- Fallback-Logik, wenn Vorschlag ungültig ist ---
logger.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist NICHT im Ziel-Schema ({len(ALLOWED_TARGET_BRANCHES)} Einträge). Starte Fallback...")
# Versuche Kurzform aus CRM-Branche zu extrahieren
crm_short_branch = "k.A." # Default
if crm_branche and ">" in str(crm_branche):
crm_short_branch = str(crm_branche).split(">", 1)[1].strip()
elif crm_branche and str(crm_branche).strip() and str(crm_branche).strip() != "k.A.": # Wenn CRM schon Kurzform sein könnte
crm_short_branch = str(crm_branche).strip()
logger.debug(f" Fallback: Prüfe extrahierte CRM-Kurzform: '{crm_short_branch}'")
crm_short_branch_lower = crm_short_branch.lower()
# 2. Ist die extrahierte CRM-Kurzform EXAKT im Ziel-Schema enthalten?
if crm_short_branch != "k.A." and crm_short_branch_lower in allowed_branches_lookup:
final_branch = allowed_branches_lookup[crm_short_branch_lower] # Nimm korrekte Schreibweise
result["consistency"] = "fallback_crm_valid" # Setze Fallback-Status
# Kombiniere ChatGPT Begründung (falls vorhanden) mit Fallback-Info
fallback_reason = f"Fallback: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}'). Gültige CRM-Kurzform '{final_branch}' verwendet."
result["justification"] = f"{fallback_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})"
logger.info(f"Fallback auf gültige CRM-Kurzform erfolgreich: '{final_branch}'")
else:
# 3. Wenn auch CRM-Kurzform ungültig
final_branch = suggested_branch # Behalte ungültigen Vorschlag
result["consistency"] = "fallback_invalid" # Setze Fehler-Fallback-Status
error_reason = f"Fehler: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}') und keine gültige CRM-Kurzform ('{crm_short_branch}') als Fallback verfügbar."
result["justification"] = f"{error_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})"
logger.warning(f"Fallback fehlgeschlagen. Ungültiger Vorschlag: '{final_branch}', Ungültige CRM-Kurzform: '{crm_short_branch}'")
# Alternativ: Setze final_branch auf einen expliziten Fehlerwert, um es im Sheet hervorzuheben
# final_branch = "FEHLER - UNGÜLTIGE ZUWEISUNG"
# Setze den finalen Branch im Ergebnis-Dictionary
# Verwenden Sie einen Standard-Fehlerwert, falls final_branch aus irgendeinem Grund immer noch None ist
result["branch"] = final_branch if final_branch else "FEHLER"
# --- Konsistenzprüfung (Finale Bewertung des final_branch vs. CRM-Kurzform) ---
# Extrahiere CRM-Kurzform für den Vergleich (erneut oder Variable von oben)
crm_short_to_compare = "k.A."
if crm_branche and ">" in str(crm_branche):
crm_short_to_compare = str(crm_branche).split(">", 1)[1].strip()
elif crm_branche and str(crm_branche).strip() and str(crm_branche).strip() != "k.A.":
crm_short_to_compare = str(crm_branche).strip()
# Vergleiche finalen Branch (falls nicht FEHLER) mit CRM-Kurzform (case-insensitive)
# Aktualisiere den Consistency-Status, WENN er noch 'pending_comparison' ist.
# Fallback-Status ('fallback_crm_valid', 'fallback_invalid') sollen erhalten bleiben.
if result["consistency"] == "pending_comparison" and result["branch"] != "FEHLER":
if result["branch"].lower() == crm_short_to_compare.lower():
result["consistency"] = "ok" # Übereinstimmung mit CRM
else:
result["consistency"] = "X" # Keine Übereinstimmung mit CRM
# Entferne den temporären Status, falls er noch da ist (sollte nicht passieren)
if result["consistency"] == "pending_comparison":
logger.warning("Konsistenzprüfung blieb im Status 'pending_comparison', setze auf 'error_comparison_failed'.")
result["consistency"] = "error_comparison_failed"
elif result["consistency"] is None: # Sollte nicht passieren
logger.error("Konsistenz blieb unerwartet None, setze auf 'error_unknown_state'.")
result["consistency"] = "error_unknown_state"
# Debug-Ausgabe des finalen Ergebnisses vor Rückgabe
logger.debug(f"Finale Branch-Evaluation Ergebnis: Branch='{result.get('branch')}', Consistency='{result.get('consistency')}', Justification='{result.get('justification', '')[:100]}...'")
return result # Rückgabe des Ergebnis-Dictionarys
# --- SERP API / LINKEDIN FUNCTIONS ---
# Übernommen aus Ihrem Code (Teil 10), angepasst als globale Funktionen.
# serp_wikipedia_lookup ist bereits in Teil 1/18 enthalten (oder sollte es sein, da es direkt nach retry_on_failure kam)
@retry_on_failure
def serp_website_lookup(company_name):
"""
Ermittelt die offizielle Website eines Unternehmens über SerpAPI (Google Suche).
Gibt die normalisierte URL zurück oder "k.A.".
"""
serp_key = Config.API_KEYS.get('serpapi')
if not serp_key:
logger.error("Fehler: SerpAPI Key nicht verfügbar für Website Lookup.")
# Werfen Sie eine Exception, damit retry_on_failure dies behandelt (oder nicht, je nach Config)
raise ConnectionRefusedError("SerpAPI Key nicht konfiguriert.")
if not company_name or str(company_name).strip() == "":
logger.warning("serp_website_lookup: Kein Firmenname angegeben.")
# Werfen Sie einen ValueError
raise ValueError("Kein Firmenname für SerpAPI Website Lookup angegeben.")
# Blacklist unerwünschter Domains (kann in Config verschoben werden)
blacklist = ["bloomberg.com", "northdata.de", "finanzen.net", "handelsblatt.com", "wikipedia.org", "linkedin.com", "xing.com", "youtube.com", "facebook.com", "twitter.com", "instagram.com"]
query = f'{company_name} offizielle Website' # Präzisere Query
params = {
"engine": "google",
"q": query,
"api_key": serp_key,
"hl": "de", # Host Language (Sprache der Benutzeroberfläche)
"gl": "de", # Geo Location (Land)
"safe": "active" # SafeSearch aktivieren
}
api_url = "https://serpapi.com/search"
try:
# Der Requests Call wird vom retry_on_failure Decorator behandelt
response = requests.get(api_url, params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) # Konfigurierbarer Timeout
response.raise_for_status() # Wirft HTTPError für schlechte Antworten
data = response.json()
# 1. Knowledge Graph prüfen (oft die offizielle Seite)
if "knowledge_graph" in data and "website" in data["knowledge_graph"]:
kg_url = data["knowledge_graph"].get("website")
if kg_url:
# Prüfen Blacklist VOR Normalisierung
if any(bad_domain in kg_url.lower() for bad_domain in blacklist):
logger.debug(f" -> SerpAPI Website Lookup: KG URL '{kg_url}' auf Blacklist. Übersprungen.")
else:
normalized_url = simple_normalize_url(kg_url) # Nutzt globale Funktion
if normalized_url != "k.A.":
logger.info(f"SERP Lookup: Website '{normalized_url}' aus Knowledge Graph für '{company_name}' gefunden.")
return normalized_url # Erfolgreich gefunden und zurückgegeben
# 2. Organische Ergebnisse prüfen
if "organic_results" in data:
# Iteriere durch die ersten Ergebnisse
for result in data["organic_results"][:5]: # Prüfe nur die Top 5 organischen Ergebnisse
url = result.get("link", "")
title = result.get("title", "") # Titel kann Kontext geben
snippet = result.get("snippet", "") # Snippet kann Kontext geben
# Filtere: Muss gültige URL sein, darf nicht auf Blacklist sein, muss http/https starten
if url and url.lower().startswith(("http://", "https://")) and not any(bad_domain in url.lower() for bad_domain in blacklist):
normalized_url = simple_normalize_url(url) # Nutzt globale Funktion
if normalized_url != "k.A.":
# Zusätzliche Plausibilitätsprüfung: Enthält die Domain Teile des Firmennamens?
# Oder ist der Firmenname im Titel/Snippet?
# normalize_company_name nutzt globale Funktion
normalized_company = normalize_company_name(company_name)
domain_part_normalized = normalized_url.replace('www.', '').split('.')[0] # Erster Teil der Domain
title_lower = title.lower()
snippet_lower = snippet.lower()
# Prüfe, ob der normalisierte Domain-Teil im normalisierten Firmennamen enthalten ist
domain_name_match = domain_part_normalized in normalized_company
# Prüfe, ob der normalisierte Firmenname im Titel oder Snippet vorkommt
name_in_result_text = normalized_company in title_lower or normalized_company in snippet_lower
# Definieren Sie Kriterien für einen guten Treffer im organischen Ergebnis
if domain_name_match or name_in_result_text:
logger.info(f"SERP Lookup: Website '{normalized_url}' aus Organic Results für '{company_name}' gefunden (Domain/Name Match).")
return normalized_url # Erfolgreich gefunden und zurückgegeben
else:
# Loggen Sie, warum die URL übersprungen wurde (nur auf Debug)
logger.debug(f" -> SerpAPI Website Lookup: URL '{normalized_url}' übersprungen (Domain/Name Match fehlgeschlagen). Domain='{domain_part_normalized}', Name='{normalized_company}'.")
# Fahren Sie fort, um den nächsten organischen Treffer zu prüfen
# Wenn die Schleife durchläuft und keine passende URL gefunden wurde
logger.info(f"SERP Lookup: Keine passende Website für '{company_name}' gefunden nach Prüfung KG und Top Organic Results.")
return "k.A." # Signalisiert, dass keine passende URL gefunden wurde
except Exception as e: # retry_on_failure wirft Exception im Fehlerfall erneut
# Loggen Sie den Fehler (wird vom retry_on_failure geloggt)
logger.error(f"FEHLER bei der SerpAPI Website Suche für '{company_name}': {e}")
# Geben Sie einen Fehlerwert zurück oder "k.A."
return "k.A. (Fehler Suche)" # Signalisiert Fehler bei der Suche
@retry_on_failure
def search_linkedin_contacts(company_name, website, position_query, crm_kurzform, num_results=10):
"""
Sucht LinkedIn Kontakte für ein Unternehmen und eine Position via SerpAPI (Google).
Gibt eine Liste von Kontakt-Dictionaries zurück.
"""
serp_key = Config.API_KEYS.get('serpapi')
if not serp_key:
logger.error("Fehler: SerpAPI Key nicht verfügbar für LinkedIn Suche.")
raise ConnectionRefusedError("SerpAPI Key nicht konfiguriert.")
if not all([company_name, position_query, crm_kurzform]) or not all(isinstance(x, str) for x in [company_name, position_query, crm_kurzform]):
logger.warning(f"search_linkedin_contacts: Fehlende oder ungültige Eingabedaten (Name, Position, Kurzform).")
raise ValueError("Fehlende oder ungültige Eingabedaten für LinkedIn Suche.")
# Query anpassen für bessere Ergebnisse
# Suche nach "[Position]" UND "[Firmenkurzform]" auf der LinkedIn /in/ Seite
# crm_kurzform ist oft im Titel oder der Beschreibung
query = f'site:linkedin.com/in/ "{position_query}" "{crm_kurzform}"'
# Optional: Fügen Sie den vollen Firmennamen hinzu, kann aber die Ergebnisse einschränken
# query = f'site:linkedin.com/in/ "{position_query}" "{crm_kurzform}" "{company_name}"'
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
}
api_url = "https://serpapi.com/search"
found_contacts = [] # Liste zur Sammlung der gefundenen Kontakte
try:
# Der Requests Call wird vom retry_on_failure Decorator behandelt
response = requests.get(api_url, params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) # Konfigurierbarer Timeout
response.raise_for_status() # Wirft HTTPError für schlechte Antworten
data = response.json()
if "organic_results" in data:
# Gehe durch die organischen Suchergebnisse
for result in data["organic_results"]:
title = result.get("title", "")
linkedin_url = result.get("link", "")
snippet = result.get("snippet", "") # Snippet kann Position oder Firma enthalten
# Filtere: Muss eine LinkedIn Profil-URL sein und die Kurzform muss im Titel vorkommen
# oder eine hohe Namensähnlichkeit aufweisen
if not linkedin_url or "linkedin.com/in/" not in linkedin_url or "/sales/" in linkedin_url:
#logger.debug(f" -> LinkedIn Treffer übersprungen (kein Profil-URL): {linkedin_url}") # Zu viel Lärm
continue
# Prüfe, ob die Firmenkurzform im Titel oder Snippet vorkommt
# Oder ob der Titel eine hohe Ähnlichkeit mit "[Name] - [Position] bei [Kurzform]" hat
title_lower = title.lower()
snippet_lower = snippet.lower()
crm_kurzform_lower = crm_kurzform.lower()
position_query_lower = position_query.lower()
kurzform_in_text = crm_kurzform_lower in title_lower or crm_kurzform_lower in snippet_lower
# Vereinfachte Namens-/Positionsextraktion aus dem Titel
name_part = ""
pos_part = position_query # Fallback
# Versuche gängige Trennzeichen im Titel (z.B. Name - Position | Firma)
separators = ["", "-", "|", " at ", " bei "]
title_cleaned = title.replace("...", "").strip()
found_sep = False
for sep in separators:
if sep in title_cleaned:
parts = title_cleaned.split(sep, 1)
name_part = parts[0].strip()
# Versuche, LinkedIn/Profil etc. aus Namen zu entfernen
name_part = re.sub(r'[\s|\-]*LinkedIn[\s|\-]*Profile.*$', '', name_part, flags=re.IGNORECASE).strip()
name_part = re.sub(r'[\s|\-]*LinkedIn$', '', name_part, flags=re.IGNORECASE).strip()
# Positionsteil ist alles nach dem ersten Trenner
potential_pos_company = parts[1].strip()
# Versuche, Firmennamen-Teile (Kurzform) und LinkedIn-Suffixe zu entfernen
pos_company_cleaned = re.sub(r'[\s|\-]*LinkedIn[\s|\-]*Profile.*$', '', potential_pos_company, flags=re.IGNORECASE).strip()
pos_company_cleaned = re.sub(r'[\s|\-]*LinkedIn$', '', pos_company_cleaned, flags=re.IGNORECASE).strip()
# Entferne die Firmenkurzform, wenn sie im Positionsteil vorkommt
if crm_kurzform_lower in pos_company_cleaned.lower():
# Ersetze nur die erste gefundene Instanz der Kurzform (ganzes Wort)
pos_company_cleaned = re.sub(r'\b' + re.escape(crm_kurzform_lower) + r'\b', '', pos_company_cleaned, flags=re.IGNORECASE).strip()
pos_company_cleaned = re.sub(r'\s+', ' ', pos_company_cleaned).strip() # Leerzeichen reduzieren nach Entfernung
pos_part = pos_company_cleaned if pos_company_cleaned else position_query
found_sep = True
break
if not found_sep: # Kein Trennzeichen gefunden, versuche andere Muster
# Muster: "[Name] [Position_Query] - LinkedIn"
if position_query_lower in title_lower:
# Split am Position_Query, nimm den Teil davor als Namen
name_before_pos = title_lower.split(position_query_lower, 1)[0].strip()
name_part = title_cleaned[:len(name_before_pos)].strip() # Nimm Originaltext bis zur Position
# Teile Namen in Vor- und Nachname (einfache Annahme)
firstname = ""
lastname = ""
name_parts = name_part.split()
if len(name_parts) > 1:
firstname = name_parts[0]
lastname = " ".join(name_parts[1:])
elif len(name_parts) == 1:
firstname = name_parts[0] # Nur Vorname gefunden?
if not firstname or not name_part: # Wenn Name nicht extrahiert werden konnte, überspringe
# self.logger.debug(f"LinkedIn Treffer übersprungen: Name konnte nicht extrahiert werden aus Titel '{title}'") # Zu viel Lärm
continue
# Zusätzliche Plausibilitätsprüfung: Position Query muss im Titel oder Snippet vorkommen ODER Kurzform muss im Titel/Snippet sein
position_in_text = position_query_lower in title_lower or position_query_lower in snippet_lower
# Akzeptiere den Kontakt, wenn (Position oder Kurzform in Text) UND Name extrahiert wurde
if position_in_text or kurzform_in_text:
contact_data = {
"Firmenname": company_name, # Originalname für Kontext
"CRM Kurzform": crm_kurzform,
"Website": website, # Website der Firma
"Vorname": firstname,
"Nachname": lastname,
"Position": pos_part, # Extrahierte oder Fallback Position
"LinkedInURL": linkedin_url
}
found_contacts.append(contact_data)
# self.logger.debug(f"Gefundener LinkedIn Kontakt: {firstname} {lastname} - {pos_part} (URL: {linkedin_url})") # Zu viel Lärm
# else: self.logger.debug(f"LinkedIn Treffer übersprungen (kein Position/Kurzform Match in Text): '{title}'") # Zu viel Lärm
logger.info(f"LinkedIn Suche für '{position_query}' bei '{crm_kurzform}' ergab {len(found_contacts)} Kontakte.")
return found_contacts # Gibt die Liste der gefundenen Kontakte zurück
except Exception as e: # retry_on_failure wirft Exception im Fehlerfall erneut
# Loggen Sie den Fehler (wird vom retry_on_failure geloggt)
logger.error(f"FEHLER bei der SerpAPI LinkedIn Suche (Query: '{position_query}', Firma: '{crm_kurzform}'): {e}")
# Geben Sie eine leere Liste zurück, da keine Kontakte gefunden wurden
return [] # Signalisiert Fehler bei der Suche
# --- Experimentelle Website Details Scraping Funktion ---
# Diese Funktion wurde in DataProcessor.process_website_details aufgerufen.
# Sie ist hier global platziert, da sie nicht spezifisch von DataProcessor state abhängt,
# sondern nur von globalen Helfern und Requests.
# Ihre Implementierung hängt stark von der Struktur der Zielwebsites ab.
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.
"""
logger.warning(f"Ausführe 'scrape_website_details' für URL {url}.")
# Beispiel: Einfaches Abrufen des <title> Tags
try:
# Hilfsfunktion zum Abrufen des Soup-Objekts mit Retry
@retry_on_failure
def get_soup_for_details(target_url):
response = requests.get(target_url, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15))
response.raise_for_status()
response.encoding = response.apparent_encoding
return BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser'))
soup = get_soup_for_details(url)
if soup:
title = soup.find('title')
meta_desc = soup.find('meta', attrs={'name': 'description'})
h1 = soup.find('h1')
details_list = []
# clean_text nutzt globale Funktion
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:
return " | ".join(details_list)
else:
return "k.A. (Keine Standard-Details gefunden)"
else:
# Fehler wurde bereits in get_soup_for_details oder retry geloggt
return "k.A. (Scraping fehlgeschlagen)"
except Exception as e: # retry_on_failure wirft am Ende Exception
# Dieser Fehler wird bereits vom retry_on_failure geloggt
logger.error(f"FEHLER in scrape_website_details für {url}: {e}")
return f"FEHLER: {str(e)[:100]}" # Rückgabe der Fehlermeldung
# --- Globale Funktion zum Scrapen des Website Rohtextes ---
# Übernommen aus get_website_raw in Teil 7. Global platziert.
# Nutzt globale Helfer: simple_normalize_url, clean_text, re, requests, BeautifulSoup, Config, getattr.
@retry_on_failure
def get_website_raw(url, max_length=20000, verify_cert=True): # Längeres Default Limit, SSL-Zertifikat standardmäßig prüfen
"""
Holt Textinhalt von einer Website, versucht Cookie-Banner zu umgehen.
Gibt den Rohtext zurück oder einen Fehlerwert ("k.A.", "k.A. (Fehler)", etc.).
"""
if not url or not isinstance(url, str) or url.strip().lower() in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"]: # Füge "http:" hinzu basierend auf Log
logger.debug(f"get_website_raw skipped: Ungültige oder leere URL '{url}'.")
return "k.A."
# Falls kein Schema vorhanden ist, hinzufügen (HTTPS bevorzugen)
if not url.lower().startswith(("http://", "https://")):
#logger.debug(f"Kein Schema in URL '{url}', füge https:// hinzu.") # Zu viel Lärm
url = "https://" + url
# Verwenden Sie eine Requests Session oder requests direkt.
# Eine Session in DataProcessor könnte besser sein, aber globale Funktion nutzt requests direkt.
headers = {
"User-Agent": getattr(Config, 'USER_AGENT', 'Mozilla/5.0 (compatible; UnternehmenSkript/1.0; +http://www.example.com/bot)') # Nutzt Config oder Fallback
}
try:
# Der Requests Call wird vom retry_on_failure Decorator behandelt.
# Timeout sollte aus Config kommen.
response = requests.get(url, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15), headers=headers, verify=verify_cert)
response.raise_for_status() # Wirft HTTPError für 4xx/5xx Antworten. Wird vom Decorator gefangen.
# Versuche, das Encoding aus dem Header oder dem Content zu erraten
response.encoding = response.apparent_encoding
soup = BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser')) # Nutzt Config oder Fallback
# --- Versuch 1: Hauptinhalt-Tags finden ---
# Verwenden Sie eine Liste von Selektoren
content_selectors = [
'main', 'article', '#content', '#main-content', '.main-content', '.content',
'div[role="main"]', 'div.page-content', 'div.container' # Weitere gängige Selektoren
]
content_area = None
for selector in content_selectors:
content_area = soup.select_one(selector)
if content_area:
#logger.debug(f"Gezielten Inhaltsbereich gefunden mit Selektor '{selector}' für {url}.") # Zu viel Lärm
break # Ersten gefundenen Bereich nehmen
if not content_area:
# --- Fallback: Body nehmen, ABER Banner versuchen zu entfernen ---
#logger.debug(f"Kein spezifischer Inhaltsbereich gefunden für {url}. Nutze Body und versuche Banner zu entfernen.") # Zu viel Lärm
content_area = soup.find('body')
if content_area:
# Versuche, häufige Cookie-Banner Strukturen zu entfernen
# Diese Selektoren sollten angepasst werden, wenn spezifische Banner Probleme machen
banner_selectors = [
'[id*="cookie"]', '[class*="cookie"]', '[id*="consent"]', '[class*="consent"]',
'.cookie-banner', '.consent-banner', '.modal', '#modal', '.popup', '#popup',
'[role="dialog"]', '[aria-modal="true"]'
]
banners_removed_count = 0
# Gehe rückwärts durch die gefundenen Elemente, um Decompose sicher zu machen
for selector in banner_selectors:
try:
# select findet alle passenden Elemente
potential_banners = content_area.select(selector)
for banner in potential_banners:
# Zusätzliche Prüfung: Enthält das Element typischen Banner-Text?
# Vermeiden Sie das Entfernen von echtem Inhalt, der zufällig das Wort "cookie" enthält.
banner_text = banner.get_text(" ", strip=True).lower()
keywords = ["cookie", "zustimm", "ablehnen", "einverstanden", "datenschutz", "privacy", "akzeptier", "einstellung", "partner", "analyse", "marketing"]
# Prüfe, ob ein Keyword im Text ODER im class/id Namen vorkommt
if any(keyword in banner_text for keyword in keywords) or any(keyword in (banner.get('id', '') + banner.get('class', '')).lower() for keyword in keywords):
#logger.debug(f"Entferne potenzielles Banner ({selector}) mit Text: {banner_text[:100]}...") # Zu viel Lärm
banner.decompose() # Entferne das Element aus dem Baum
banners_removed_count += 1
except Exception as e_select:
# Logge Fehler bei der Banner-Entfernung, aber fahre fort
logger.debug(f"Fehler beim Versuch Banner mit Selektor '{selector}' zu entfernen: {e_select}")
if banners_removed_count > 0:
logger.debug(f"{banners_removed_count} potenzielle Banner-Elemente für {url} entfernt.")
# --- Text extrahieren aus gefundenem Bereich (oder Body) ---
if content_area:
# Entferne Skripte und Styles, bevor der Text extrahiert wird
for script_or_style in content_area(["script", "style"]):
script_or_style.decompose()
# Extrahiere Text mit Leerzeichen als Trenner
text = content_area.get_text(separator=' ', strip=True)
text = re.sub(r'\s+', ' ', text).strip() # Normalisiere und trimme Whitespace
# --- Zusätzliche Prüfung: Ist der extrahierte Text *nur* Banner-Text? ---
# Diese Heuristik ist eine Fallback-Maßnahme, wenn die Decompose-Logik nicht perfekt war.
banner_keywords_strict = ["cookie", "zustimmen", "ablehnen", "einverstanden", "datenschutz", "privacy", "akzeptier", "einstellung", "partner", "analyse", "marketing"]
text_lower = text.lower()
keyword_hits = sum(1 for keyword in banner_keywords_strict if keyword in text_lower)
# Heuristik: Wenn der Text kurz ist UND viele Banner-Keywords enthält -> Verwerfen
# Passen Sie die Schwellenwerte an
if len(text) < 500 and keyword_hits >= 3: # Wenn Text kürzer als 500 Zeichen und >= 3 Keywords
logger.warning(f"WARNUNG: Extrahierter Text für {url} scheint nur Cookie-Banner zu sein (Länge {len(text)}, {keyword_hits} Keywords). Verwerfe Text.")
return "k.A. (Nur Cookie-Banner erkannt)"
# Wenn der Text nach Bereinigung immer noch sehr kurz ist (z.B. nur ein paar Worte)
if len(text.split()) < 10 or len(text) < 50:
#logger.debug(f"Extrahierter Text für {url} ist sehr kurz ({len(text.split())} Worte, {len(text)} Zeichen).") # Zu viel Lärm
# Kann immer noch valide sein, aber ist oft kein relevanter Inhalt.
# Geben wir ihn trotzdem zurück, gekürzt.
pass # Behalte den Text, keine weitere Filterung
# Begrenzen Sie die Länge des zurückgegebenen Rohtextes
result = text[:max_length]
logger.debug(f"Website {url} erfolgreich gescrapt. Extrahierter Text (Länge {len(result)}).")
# logger.debug(f"Extrahierter Text Anfang: {result[:100]}...") # Zu viel Lärm
return result if result else "k.A. (Extraktion leer)" # Rückgabe des gekürzten Textes
else:
logger.warning(f"Kein <body> oder spezifischer Inhaltsbereich gefunden in {url}.")
return "k.A. (Kein Body gefunden)"
# Exceptions (wie RequestsErrors) werden vom retry_on_failure Decorator behandelt.
# Wenn eine Exception hier durchkommt, hat der Decorator aufgegeben.
except Exception as e: # Fangen Sie alle verbleibenden Exceptions, die nicht vom Decorator behandelt wurden
logger.error(f"Allgemeiner Fehler beim Scraping von {url}: {type(e).__name__} - {e}")
# Die Exception wurde bereits vom Decorator geloggt
return f"k.A. (Fehler: {str(e)[:100]}...)" # Signalisiert Fehler
# TODO: Weitere globale Helferfunktionen (z.B. für FSM, Emp, Umsatz Schätzung Prompts und Parsing) müssen hier implementiert werden,
# falls sie nicht in den DataProcessor integriert wurden. Platzhalter wurden in DataProcessor._process_single_row hinzugefügt.
# ==============================================================================
# 4. GOOGLE SHEET HANDLER CLASS
# (Entspricht logisch etwa 'google_sheet_handler.py')
# ==============================================================================
class GoogleSheetHandler:
"""
Kapselt die Interaktionen mit dem Google Sheet, inklusive Verbindung,
Daten laden und Batch-Updates. Nutzt den retry_on_failure Decorator.
"""
def __init__(self):
"""
Initialisiert den Handler, stellt die Verbindung her und lädt die Daten.
"""
self.sheet = None
# Daten werden hier als Instanzvariable gespeichert, um nicht bei jedem Zugriff neu laden zu müssen
self.sheet_values = []
# header_rows sind fix, aber wir können sie hier zur Klarheit definieren
self._header_rows = 5 # Annahme: Die ersten 5 Zeilen sind Header
logger.info("Initialisiere GoogleSheetHandler...")
try:
# Verbindung wird bei der Initialisierung aufgebaut
self._connect()
# Daten werden ebenfalls bei der Initialisierung geladen
if self.sheet:
self.load_data() # Erste Datenladung nach erfolgreicher Verbindung
else:
# Wenn die Verbindung fehlschlug, aber keine Exception geworfen wurde
logger.critical("GoogleSheetHandler Init FEHLER: Verbindung konnte nicht hergestellt werden.")
# Hier wird keine Exception geworfen, da _connect und load_data Exceptions werfen,
# die von retry_on_failure oder der aufrufenden main-Funktion behandelt werden.
except Exception as e:
# Fehler bei der Initialisierung werden hier gefangen und erneut geworfen,
# damit die main-Funktion entsprechend reagieren kann.
logger.critical(f"FATAL: Fehler bei Initialisierung von GoogleSheetHandler: {e}")
raise ConnectionError(f"Google Sheet Handler Init failed: {e}") # Signalisiert Verbindungsproblem
@retry_on_failure
def _connect(self):
"""Stellt Verbindung zum Google Sheet her."""
self.sheet = None # Setze sheet vor dem Versuch auf None
logger.info("Versuche Verbindung mit Google Sheets herstellen...")
try:
# Stellen Sie sicher, dass CREDENTIALS_FILE korrekt ist
if not os.path.exists(CREDENTIALS_FILE):
raise FileNotFoundError(f"Credential-Datei nicht gefunden: {CREDENTIALS_FILE}")
scope = ["https://www.googleapis.com/auth/spreadsheets"]
creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope)
gc = gspread.authorize(creds)
sh = gc.open_by_url(Config.SHEET_URL) # Nutzt die URL aus Config
self.sheet = sh.sheet1 # Greift auf das erste Blatt zu (Index 0)
logger.info("Verbindung zu Google Sheets erfolgreich.")
# Spezifische Fehlerbehandlung für gspread/requests Fehler, die vom Decorator behandelt werden
except (gspread.exceptions.APIError, requests.exceptions.RequestException) as e:
# Der Decorator wird diese Fehler loggen und wiederholen.
# Werfen Sie den Fehler erneut, damit der Decorator ihn fangen kann.
raise e
except FileNotFoundError as e:
# Dieser Fehler sollte nicht wiederholt werden, aber geloggt werden.
logger.critical(f"FEHLER bei der Google Sheets Verbindung: {type(e).__name__} - {e}")
raise e # Wirf ihn trotzdem, damit der Aufrufer (main) es sieht
except Exception as e:
# Logge andere unerwartete Fehler
logger.error(f"FEHLER bei der Google Sheets Verbindung: {type(e).__name__} - {e}")
# Werfen Sie den Fehler erneut, damit der Decorator oder Aufrufer ihn behandeln kann
raise e
@retry_on_failure
def load_data(self):
"""Lädt alle Daten aus dem Sheet und aktualisiert self.sheet_values."""
if not self.sheet:
logger.error("Fehler: Keine Sheet-Verbindung zum Laden der Daten.")
self.sheet_values = [] # Stelle sicher, dass die Datenliste leer ist
return False # Signalisiert Fehler
logger.info("Lade Daten aus Google Sheet...")
try:
# Nutze get_all_values() für alle Daten
self.sheet_values = self.sheet.get_all_values()
if not self.sheet_values:
logger.warning("Google Sheet scheint leer zu sein oder get_all_values() lieferte keine Daten.")
# Wenn die erste Zeile nicht geladen werden kann (z.B. leeres Sheet), headers ist leer
self.headers = []
return True # Ladevorgang war technisch erfolgreich, aber keine Daten
# Logge die Anzahl der Zeilen und Spalten, die geladen wurden
num_rows = len(self.sheet_values)
num_cols = len(self.sheet_values[0]) if num_rows > 0 else 0
logger.info(f"Daten neu geladen: {num_rows} Zeilen, {num_cols} Spalten.")
# Optional: Überprüfen Sie, ob die Anzahl der Spalten mindestens dem höchsten Index in COLUMN_MAP entspricht
try:
max_expected_cols = max(COLUMN_MAP.values()) + 1
if num_cols < max_expected_cols:
logger.warning(f"Geladenes Sheet hat {num_cols} Spalten, erwartet werden aber mindestens {max_expected_cols} basierend auf COLUMN_MAP. Das COLUMN_MAP passt möglicherweise nicht zum Sheet!")
except Exception as e:
logger.error(f"Fehler bei der Prüfung der Spaltenanzahl gegen COLUMN_MAP: {e}")
return True # Signalisiert Erfolg
# Spezifische Fehlerbehandlung
except (gspread.exceptions.APIError, requests.exceptions.RequestException) as e:
# Der Decorator wird diese Fehler loggen und wiederholen.
raise e # Werfen Sie den Fehler erneut
except Exception as e:
logger.error(f"Allgemeiner Fehler beim Laden der Google Sheet Daten: {e}")
raise e # Werfen Sie den Fehler erneut
def get_data(self):
"""
Gibt die aktuell im Handler gespeicherten Datenzeilen zurück
(ohne die ersten N Header-Zeilen).
"""
if not self.sheet_values or len(self.sheet_values) <= self._header_rows:
# Logge nur auf Debug, da dies oft passiert, wenn das Sheet leer ist
logger.debug(f"get_data: Keine Datenzeilen verfügbar (geladen: {len(self.sheet_values) if self.sheet_values else 0} Zeilen, {self._header_rows} Header).")
return []
# Gibt eine Slice der Liste zurück (Kopie, um unbeabsichtigte Änderungen am Original zu vermeiden)
return self.sheet_values[self._header_rows:].copy()
def get_all_data_with_headers(self):
"""Gibt alle aktuell im Handler gespeicherten Daten inklusive Header zurück."""
if not self.sheet_values:
logger.debug("get_all_data_with_headers: Keine Daten im Handler gespeichert.")
return []
return self.sheet_values.copy() # Rückgabe als Kopie
def _get_col_letter(self, col_idx_1_based):
"""
Konvertiert einen 1-basierten Spaltenindex in den entsprechenden
Google Sheets Spaltenbuchstaben (A, B, ..., Z, AA, ...).
"""
if not isinstance(col_idx_1_based, int) or col_idx_1_based < 1:
# Logge den Fehler
logger.error(f"Ungültiger Spaltenindex ({col_idx_1_based}) für _get_col_letter erhalten.")
return None # Ungültiger Index
string = ""
n = col_idx_1_based
while n > 0:
n, remainder = divmod(n - 1, 26)
string = chr(65 + remainder) + string
return string
def get_start_row_index(self, check_column_key, min_sheet_row=7):
"""
Findet den 0-basierten Index in der DATENliste (ohne Header),
ab einer Mindestzeilennummer im Sheet, in der der Wert in der
Spalte (definiert durch check_column_key) EXAKT LEER ("") ist.
Lädt die Daten vor der Prüfung neu.
Args:
check_column_key (str): Der Schlüssel in COLUMN_MAP für die zu prüfende Spalte.
min_sheet_row (int): Die 1-basierte Zeilennummer im Sheet, ab der gesucht werden soll.
Returns:
int: Der 0-basierte Index in der Datenliste (ohne Header),
oder -1 bei Fehler (z.B. Schlüssel nicht gefunden),
oder der Index nach der letzten Datenzeile, wenn alle gefüllt sind.
(Ein Rückgabewert >= len(data_rows) bedeutet, dass keine leere Zelle im Suchbereich gefunden wurde).
"""
# Daten neu laden, um sicherzustellen, dass sie aktuell sind
if not self.load_data():
logger.error("Fehler beim Laden der Daten für get_start_row_index.")
return -1 # Signalisiert Fehler
data_rows = self.get_data() # Datenzeilen ohne Header
if not data_rows:
logger.info("Keine Datenzeilen im Sheet gefunden. Startindex ist 0 (erste Datenzeile).")
return 0 # Wenn keine Daten da sind, ist 0 der Start
check_column_index = COLUMN_MAP.get(check_column_key)
if check_column_index is None:
logger.critical(f"FEHLER: Schlüssel '{check_column_key}' nicht in COLUMN_MAP gefunden für get_start_row_index!")
return -1 # Signalisiert Fehler
actual_col_letter = self._get_col_letter(check_column_index + 1)
# Berechne den Startindex in der 0-basierten 'data_rows' Liste
# min_sheet_row (1-basiert) -> 0-basierten Index in all_data -> 0-basierten Index in data_rows
# min_sheet_row - 1 = 0-basierter Index in all_data
# (min_sheet_row - 1) - self._header_rows = 0-basierter Index in data_rows
search_start_index_in_data = max(0, (min_sheet_row - 1) - self._header_rows)
logger.info(f"get_start_row_index: Suche ab Daten-Index {search_start_index_in_data} (Sheet-Zeile {search_start_index_in_data + self._header_rows + 1}) nach EXAKT LEEREM Wert (=='') in Spalte '{check_column_key}' ({actual_col_letter})...")
if search_start_index_in_data >= len(data_rows):
logger.warning(f"Start-Suchindex in Daten ({search_start_index_in_data}) liegt hinter der letzten Datenzeile ({len(data_rows)}). Keine leere Zelle gefunden im Suchbereich.")
# Rückgabe der Länge der Datenliste signalisiert, dass keine leere Zelle gefunden wurde
return len(data_rows)
# Iteriere über die Datenzeilen ab dem berechneten Startindex
for i in range(search_start_index_in_data, len(data_rows)):
row = data_rows[i]
current_sheet_row = i + self._header_rows + 1 # 1-basierte Sheet-Zeilennummer
cell_value = ""; is_exactly_empty = True
# Überprüfe, ob die Zeile lang genug ist, um auf die Spalte zuzugreifen
if len(row) > check_column_index:
cell_value = row[check_column_index]
if cell_value != "": is_exactly_empty = False
else:
# Wenn die Zeile nicht lang genug ist, gilt die Zelle in der Spalte als leer
is_exactly_empty = True
# Logge die ersten paar Zeilen und jede 1000. Zeile oder wenn eine leere Zelle gefunden wird
log_debug = (i < search_start_index_in_data + 5) or (i % 1000 == 0) or is_exactly_empty
if log_debug:
logger.debug(f" -> Prüfe Daten-Index {i} (Sheet {current_sheet_row}): Wert in {actual_col_letter}='{str(cell_value).strip()}' (Roh='{cell_value}' Typ: {type(cell_value)}). Ist exakt leer ('')? {is_exactly_empty}")
if is_exactly_empty:
logger.info(f"Erste Zeile ab Sheet-Zeile {min_sheet_row} mit EXAKT LEEREM Wert in Spalte {actual_col_letter} gefunden: Zeile {current_sheet_row} (Daten-Index {i})")
return i # Gebe den 0-basierten Index in der Datenliste zurück
# Wenn die Schleife durchläuft, ohne eine leere Zelle zu finden
last_data_index = len(data_rows)
logger.info(f"Alle Zeilen ab Daten-Index {search_start_index_in_data} im Suchbereich haben einen nicht-leeren Wert in Spalte {actual_col_letter}. Nächster Daten-Index wäre {last_data_index}.")
return last_data_index # Signalisiert, dass keine leere Zelle gefunden wurde
@retry_on_failure
def batch_update_cells(self, update_data):
"""
Führt ein Batch-Update im Google Sheet durch. Beinhaltet robustere
Fehlerbehandlung.
Args:
update_data (list): Eine Liste von Dictionaries, jedes mit 'range' (str)
und 'values' (list of lists).
z.B. [{'range': 'A1', 'values': [['Wert']]}, ...]
Returns:
bool: True bei Erfolg (nach allen Retries), False bei endgültigem Fehler.
"""
if not self.sheet:
logger.error("FEHLER: Keine Sheet-Verbindung für Batch-Update.")
return False
if not update_data:
# logger.debug("Keine Daten für Batch-Update vorhanden.") # Zu viel Lärm
return True # Nichts zu tun ist technisch ein Erfolg
# Die retry_on_failure Logik kümmert sich um die Wiederholung und das Werfen
# der Exception im Fehlerfall. Wir müssen hier nur den Aufruf machen und
# das Ergebnis (oder die Exception) weitergeben.
try:
# Verwende len() des update_data um Anzahl der Operationen zu schätzen,
# aber die tatsächliche Anzahl der Zellen ist die Summe der items in values.
total_cells_to_update = sum(len(row) for item in update_data for row in item.get('values', []))
logger.debug(f" -> Versuche sheet.batch_update mit {len(update_data)} Anfragen ({total_cells_to_update} Zellen)...")
# Die gspread-Methode batch_update wirft bei Fehlern Exceptions,
# die vom @retry_on_failure Decorator gefangen werden.
# value_input_option='USER_ENTERED' interpretiert die Eingaben wie ein Nutzer.
self.sheet.batch_update(update_data, value_input_option='USER_ENTERED')
# Wenn keine Exception aufgetreten ist, war der Aufruf (ggf. nach Retries) erfolgreich.
# logger.debug(f" -> sheet.batch_update erfolgreich abgeschlossen.") # Zu viel Lärm
return True # Signalisiert Erfolg
# Exceptions werden von retry_on_failure gefangen und (im Fehlerfall) neu geworfen.
# Wenn eine Exception hier durchkommt, hat retry_on_failure aufgegeben.
except Exception as e:
# Der endgültige Fehler wurde bereits vom Decorator geloggt.
# Wir fangen ihn hier nur, um False zurückzugeben, wie in der Signatur versprochen.
# Das re-raising im Decorator sorgt dafür, dass wir hier landen, wenn der Decorator aufgibt.
logger.error(f"Endgültiger Fehler beim Batch-Update nach Retries. Kann {len(update_data)} Operationen nicht durchführen.")
# Der Traceback wurde bereits vom Decorator (im except Exception Fall) geloggt.
return False # Signalisiert endgültigen Fehler
# ==============================================================================
# 5. WIKIPEDIA SCRAPER CLASS
# (Entspricht logisch etwa 'wikipedia_scraper.py')
# ==============================================================================
class WikipediaScraper:
"""
Handhabt das Suchen von Wikipedia-Artikeln und das Extrahieren relevanter
Unternehmensdaten. Beinhaltet Validierungslogik für Artikel.
Nutzt die wikipedia-Bibliothek und Requests für direktes HTML-Scraping.
"""
def __init__(self, user_agent=None):
"""
Initialisiert den Scraper mit einer Requests-Session und konfigurierter
Wikipedia-Bibliothek.
Args:
user_agent (str, optional): Der User-Agent für Requests.
Defaults to a script-specific one.
"""
# Erhalten Sie eine Logger-Instanz für diese Klasse
self.logger = logging.getLogger(__name__ + ".WikipediaScraper")
self.logger.debug("WikipediaScraper initialisiert.")
# User-Agent für Requests (nutzt Config, Fallback wenn nicht gesetzt)
self.user_agent = user_agent or getattr(Config, 'USER_AGENT', 'Mozilla/5.0 (compatible; UnternehmenSkript/1.0; +http://www.example.com/bot)') # Beispiel URL anpassen
self.session = requests.Session()
self.session.headers.update({'User-Agent': self.user_agent})
self.logger.debug(f"Requests Session mit User-Agent '{self.user_agent}' initialisiert.")
# Keywords für die Infobox-Extraktion
self.keywords_map = {
'branche': ['branche', 'wirtschaftszweig', 'industry', 'tätigkeit', 'sektor', 'produkte', 'leistungen'],
'umsatz': ['umsatz', 'erlös', 'revenue', 'jahresumsatz', 'konzernumsatz', 'ergebnis'],
'mitarbeiter': ['mitarbeiter', 'mitarbeiterzahl', 'beschäftigte', 'employees', 'number of employees', 'personal', 'belegschaft']
}
# Konfiguriere die wikipedia-Bibliothek
try:
wiki_lang = getattr(Config, 'LANG', 'de')
wikipedia.set_lang(wiki_lang)
# Aktivieren Sie Rate Limiting, um die Wikipedia-API nicht zu überlasten
wikipedia.set_rate_limiting(True, min_wait=0.1) # Minimum 0.1 Sekunden warten
self.logger.info(f"Wikipedia library language set to '{wiki_lang}'. Rate limiting enabled (min_wait=0.1).")
except Exception as e:
self.logger.warning(f"Fehler beim Setzen der Wikipedia-Sprache oder Rate Limiting: {e}")
# --- Interne Helfermethoden ---
def _get_full_domain(self, website):
"""Extrahiert die normalisierte Domain (ohne www, ohne Pfad) aus einer URL."""
# Diese Funktion kann die globale simple_normalize_url nutzen, ist aber hier dupliziert
# für die Unabhängigkeit der Klasse. Ggf. Refactoring-Entscheidung: globale Funktion nutzen
# Behalten wir die Kopie hier, da sie leicht abweichend implementiert ist (keine Fehlerbehandlung, kein k.A.)
if not website or not isinstance(website, str): return ""
website_lower = website.lower().strip()
if not website_lower or website_lower == 'k.a.': return ""
# Entferne Schema, @-Teil, Port
website_lower = re.sub(r'^https?:\/\/', '', website_lower)
if '@' in website_lower: website_lower = website_lower.split('@', 1)[1]
if ':' in website_lower: website_lower = website_lower.split(':', 1)[0]
# Entferne www.
if website_lower.startswith('www.'): website_lower = website_lower[4:]
# Nimm nur den Domain-Teil vor dem ersten Schrägstrich
domain = website_lower.split('/')[0]
# Einfache Prüfung auf mindestens einen Punkt (Basic TLD check)
return domain if '.' in domain else ""
def _generate_search_terms(self, company_name, website):
"""
Generiert eine Liste von Suchbegriffen für die Wikipedia-Suche,
inklusive normalisiertem Namen, Kurzformen und Domain.
"""
if not company_name: return []
terms = set()
# Fügen Sie den originalen Namen hinzu
original_name_cleaned = company_name.strip()
if original_name_cleaned:
terms.add(original_name_cleaned)
# Fügen Sie die normalisierte Namen und Teile hinzu (nutzt globale Funktion)
normalized_name = normalize_company_name(company_name) # Annahme: normalize_company_name global
if normalized_name:
terms.add(normalized_name)
name_parts = normalized_name.split()
if len(name_parts) > 0: terms.add(name_parts[0]) # Erstes Wort
if len(name_parts) > 1: terms.add(" ".join(name_parts[:2])) # Erste zwei Wörter
# Fügen Sie die Domain hinzu (nutzt interne Methode)
full_domain = self._get_full_domain(website)
if full_domain: terms.add(full_domain)
# Entferne leere Strings und limitiere die Anzahl der Begriffe
final_terms = [term for term in list(terms) if term][:getattr(Config, 'WIKIPEDIA_SEARCH_RESULTS', 5)] # Limitiere auf Anzahl der Suchergebnisse
self.logger.debug(f"Generierte Suchbegriffe für '{company_name}': {final_terms}")
return final_terms
@retry_on_failure # Nutzt den globalen Decorator
def _get_page_soup(self, url):
"""Holt HTML von einer URL und gibt ein BeautifulSoup-Objekt zurück."""
if not url or not isinstance(url, str) or not url.lower().startswith(("http://", "https://")):
self.logger.warning(f"_get_page_soup: Ungültige URL '{url}'.")
return None
try:
self.logger.debug(f"_get_page_soup: Rufe URL ab: {url}")
# Verwenden Sie die Instanz Session
response = self.session.get(url, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) # Konfigurierbarer Timeout
response.raise_for_status() # Wirft HTTPError für schlechte Antworten (4xx oder 5xx)
response.encoding = response.apparent_encoding # Versuche, Encoding zu erraten
# Nutzt den HTML_PARSER aus Config
soup = BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser'))
self.logger.debug(f"_get_page_soup: Parsen von {url} erfolgreich.")
return soup
except requests.exceptions.Timeout:
self.logger.error(f"_get_page_soup: Timeout beim Abrufen von {url}")
raise # Exception weitergeben für Retry
except requests.exceptions.RequestException as e:
self.logger.error(f"_get_page_soup: Netzwerk-/HTTP-Fehler beim Abrufen von HTML von {url}: {e}")
raise e # Exception weitergeben für Retry
except Exception as e:
# Fängt andere unerwartete Fehler ab (z.B. Probleme mit BeautifulSoup)
self.logger.error(f"_get_page_soup: Fehler beim Parsen von HTML von {url}: {type(e).__name__} - {e}")
raise e # Exception weitergeben (könnte auch retry triggern)
# --- Überarbeitete Validierungsmethode ---
def _validate_article(self, page, company_name, website):
"""
Validiert, ob ein Wikipedia-Artikel zum Unternehmen passt.
Prüft Titelähnlichkeit (gewichtete Anfangsworte), Domain-Match in Links
und passt Schwellenwerte dynamisch an.
Args:
page (wikipedia.WikipediaPage): Das geladene Wikipedia Page Objekt.
company_name (str): Der Name des Unternehmens.
website (str): Die Website des Unternehmens.
Returns:
bool: True, wenn der Artikel validiert wurde, sonst False.
"""
if not page or not company_name: return False # Grundlegende Prüfung
# page.title ist der Titel des Wikipedia-Artikels
self.logger.debug(f"Validiere Artikel '{page.title}' (URL: {page.url}) für Firma '{company_name}' (Website: {website})...")
# Normalisiere Namen (nutzt globale Funktion)
normalized_company = normalize_company_name(company_name)
normalized_title = normalize_company_name(page.title)
if not normalized_company or not normalized_title:
self.logger.warning("Validierung nicht möglich, da Normalisierung eines Namens fehlschlug.")
return False
# Basisschwelle für Ähnlichkeit
standard_threshold = getattr(Config, 'SIMILARITY_THRESHOLD', 0.65)
# 1. Titelähnlichkeit (Gesamt)
similarity = SequenceMatcher(None, normalized_title, normalized_company).ratio()
self.logger.debug(f" -> Gesamt-Ähnlichkeit (normalized): {similarity:.2f} ('{normalized_title}' vs '{normalized_company}')")
# 2. Ähnlichkeit der ersten Worte (Normalisiert)
company_tokens = normalized_company.split()
title_tokens = normalized_title.split()
first_word_match = False
first_two_words_match = False
if len(company_tokens) > 0 and len(title_tokens) > 0:
if company_tokens[0] == title_tokens[0]:
first_word_match = True
# self.logger.debug(" -> Erstes normalisiertes Wort stimmt überein.")
if len(company_tokens) > 1 and len(title_tokens) > 1:
if company_tokens[1] == title_tokens[1]:
first_two_words_match = True
# self.logger.debug(" -> Erste zwei normalisierte Worte stimmen überein.")
# 3. Link-Prüfung (Domain-Match im Artikel-HTML)
domain_found = False
full_domain = self._get_full_domain(website) # Nutzt interne Methode
if full_domain and full_domain != "k.A.":
self.logger.debug(f" -> Suche nach Domain '{full_domain}' in externen Links des Artikels...")
try:
# Direkte Abfrage über wikipedia.page.html() kann schneller sein als erneuter Requests Call
article_html = page.html()
if article_html:
soup = BeautifulSoup(article_html, getattr(Config, 'HTML_PARSER', 'html.parser'))
# Suche nach externen Links, die die Domain enthalten
# Schließe Wikipedia-eigene Domains aus
external_links = soup.select('a[href^="http"]') # Links, die mit http starten
relevant_links = [link for link in external_links if full_domain in self._get_full_domain(link.get('href', '')) and not any(exclude in link.get('href', '') for exclude in ['wikipedia.org', 'wikimedia.org', 'wikidata.org', 'archive.org'])]
if relevant_links:
# Optional: Prüfe, ob der Link in der Infobox ist oder typischen Text hat
# Dies kann komplex sein und zu Fehlern führen. Einfacher ist, nur den Link zu prüfen.
domain_found = True
# logger.debug(f" -> Domain '{full_domain}' in {len(relevant_links)} externen Links gefunden.")
else:
# logger.debug(f" -> Domain '{full_domain}' nicht in externen Links gefunden.")
pass # domain_found bleibt False
else:
self.logger.warning(" -> Konnte HTML für Link-Prüfung nicht abrufen (page.html() leer).")
except Exception as e_link_check:
self.logger.error(f"Fehler während der Domain-Link-Prüfung für '{page.title}': {e_link_check}")
# Fehler beim Link-Check sollte die Validierung nicht blockieren, nur beeinflussen
else:
self.logger.debug(" -> Keine Website-Domain für Link-Prüfung vorhanden oder ungültig.")
# 4. Dynamische Schwellenwert-Entscheidung (Bewertung)
is_valid = False
reason = "Keine Validierungsregel traf zu" # Default Grund
# Prüfe Bedingungen in absteigender Reihenfolge ihrer Stärke / Relevanz
if similarity >= standard_threshold:
is_valid = True
reason = f"Gesamt-Ähnlichkeit ({similarity:.2f}) >= Standard-Schwelle ({standard_threshold:.2f})"
elif domain_found and first_two_words_match: # Stärkste Kombination von Indikatoren
is_valid = True
reason = f"Domain gefunden UND erste 2 normalisierte Worte stimmen überein (Sim={similarity:.2f})"
elif domain_found and first_word_match and similarity >= 0.40: # Domain + Erstes Wort + Moderate Ähnlichkeit
is_valid = True
reason = f"Domain gefunden UND erstes normalisiertes Wort stimmt überein UND Ähnlichkeit >= 0.40 (Sim={similarity:.2f})"
elif first_two_words_match and similarity >= 0.45: # Erste zwei Worte + Moderate Ähnlichkeit (auch ohne Domain)
is_valid = True
reason = f"Erste zwei normalisierte Worte stimmen überein UND Ähnlichkeit >= 0.45 (Sim={similarity:.2f})"
elif domain_found and similarity >= 0.50: # Nur Domain + Etwas höhere Ähnlichkeit
is_valid = True
reason = f"Domain gefunden UND Ähnlichkeit >= 0.50 (Sim={similarity:.2f})"
elif first_word_match and similarity >= 0.55: # Nur Erstes Wort + Etwas höhere Ähnlichkeit
is_valid = True
reason = f"Erstes normalisiertes Wort stimmt überein UND Ähnlichkeit >= 0.55 (Sim={similarity:.2f})"
# Niedrigere Schwellen für Fälle, wo die Namen stark abweichen, aber andere Indikatoren passen?
# elif domain_found and similarity >= 0.30: # Ggf. zu aggressiv
# is_valid = True
# reason = f"Domain gefunden UND Ähnlichkeit >= 0.30"
log_level = logging.INFO if is_valid else logging.DEBUG
self.logger.log(log_level, f" => Artikel '{page.title}' {'VALIDIERT' if is_valid else 'NICHT validiert'} (Grund: {reason}. Details: Sim={similarity:.2f}, Domain? {domain_found}, 1stWord? {first_word_match}, 2ndWord? {first_two_words_match})")
return is_valid
# --- Extraktionsmethoden ---
def _extract_first_paragraph_from_soup(self, soup):
"""Extrahiert den ersten aussagekräftigen Absatz aus dem Soup-Objekt."""
if not soup: return "k.A."
paragraph_text = "k.A."
try:
# Finden Sie den Hauptinhalt-Div
content_div = soup.find('div', class_='mw-parser-output')
# Suchen Sie die ersten p-Tags direkt unter diesem Div (recursive=False) oder im gesamten Soup
search_area = content_div if content_div else soup
paragraphs = search_area.find_all('p', recursive=False)
# Fallback, falls keine direkten p-Tags gefunden werden
if not paragraphs: paragraphs = search_area.find_all('p') # Recursive Fallback
# Gehe durch die gefundenen Absätze
for p in paragraphs:
# Entferne Referenzen und versteckte Spans innerhalb des p-Tags VOR dem Text-Extraktion
for sup in p.find_all('sup', class_='reference'): sup.decompose()
for span in p.find_all('span', style=lambda value: value and 'display:none' in value): span.decompose()
for span in p.find_all('span', id='coordinates'): span.decompose() # Entferne Koordinaten-Span
# Extrahiere und bereinige den Text (nutzt globale Funktion clean_text)
text = clean_text(p.get_text(separator=' ', strip=True))
# Prüfe, ob der Text lang genug ist und nicht nur z.B. Bilderklärungen sind
if text and len(text) > 50: # Mindestlänge anpassen, falls nötig
# Prüfe auf gängige unerwünschte Anfänge (z.B. nach Infoboxen)
if not re.match(r'^(Datei:|Abbildung:|Siehe auch:|Einzelnachweise)', text, re.IGNORECASE):
paragraph_text = text[:1500] # Limitiere die Länge des Absatzes
# logger.debug(f" -> Ersten gültigen Absatz gefunden: {paragraph_text[:100]}...")
break # Höre beim ersten guten Absatz auf
if paragraph_text == "k.A.":
self.logger.debug("Kein passender erster Absatz gefunden.")
except Exception as e:
self.logger.error(f"Fehler beim Extrahieren des ersten Absatzes: {e}")
return paragraph_text
def extract_categories(self, soup):
"""Extrahiert Wikipedia-Kategorien aus dem Soup-Objekt."""
if not soup: return "k.A."
cats_filtered = []
try:
# Kategorien sind normalerweise in einem div mit id="mw-normal-catlinks"
cat_div = soup.find('div', id="mw-normal-catlinks")
if cat_div:
# Die Kategorien sind innerhalb eines ul-Tags unter diesem div
ul = cat_div.find('ul')
if ul:
# Jede Kategorie ist ein li-Element innerhalb des ul
cats = [clean_text(li.get_text()) for li in ul.find_all('li')] # Nutzt globale clean_text
# Filtere leere oder unerwünschte Einträge (wie "Kategorien:")
cats_filtered = [c for c in cats if c and "kategorien:" not in c.lower()]
self.logger.debug(f"Kategorien gefunden: {cats_filtered}")
else: self.logger.debug("Kein 'ul' Tag in 'mw-normal-catlinks' gefunden.")
else: self.logger.debug("Kein 'div#mw-normal-catlinks' gefunden.")
except Exception as e:
self.logger.error(f"Fehler beim Extrahieren der Kategorien: {e}")
return ", ".join(cats_filtered) if cats_filtered else "k.A."
def _extract_infobox_value(self, soup, target):
"""
Extrahiert gezielt Branche, Umsatz oder Mitarbeiter aus der Infobox
eines Wikipedia-Artikels Soup-Objekts.
Berücksichtigt Header in <th> oder fett formatierten <td>.
"""
self.logger.debug(f"--- Entering _extract_infobox_value for target '{target}' ---")
if not soup or target not in self.keywords_map:
self.logger.debug(f"_extract_infobox_value: Ungültiger Input (Soup: {soup is not None}, Target: {target})")
return "k.A."
keywords = self.keywords_map[target]
self.logger.debug(f"_extract_infobox_value: Suche nach '{target}' mit Keywords: {keywords}")
# Finden Sie die Infobox (verschiedene Klassen sind möglich)
infobox = soup.select_one('table[class*="infobox"]') # Suche nach class, die "infobox" enthält
if not infobox:
self.logger.debug(" -> KEINE Infobox via select_one 'table[class*=\"infobox\"]' gefunden.")
return "k.A."
self.logger.debug(f" -> Infobox gefunden.")
value_found = "k.A."
try:
# Iteriere durch die Zeilen der Infobox
rows = infobox.find_all('tr')
self.logger.debug(f" -> Analysiere {len(rows)} Zeilen in der Infobox.")
for idx, row in enumerate(rows):
# logger.debug(f" --- Prüfe Roh-HTML Zeile {idx}: {str(row)[:150]}...") # Zu viel Lärm
# Suche nach TH und TD Elementen direkt unter TR
cells = row.find_all(['th', 'td'], recursive=False)
header_text = None
value_cell = None
# Gängigste Struktur: TH (Header) gefolgt von TD (Wert)
if len(cells) >= 2 and cells[0].name == 'th' and cells[1].name == 'td':
header_text = cells[0].get_text(strip=True)
value_cell = cells[1]
# logger.debug(f" -> Zeile {idx}: Struktur TH + TD erkannt.")
# Alternative Struktur: TD (Header-ähnlich, z.B. fett) gefolgt von TD (Wert)
# Hier ist Vorsicht geboten, um nicht reguläre Datenzellen zu erfassen.
elif len(cells) >= 2 and cells[0].name == 'td' and cells[1].name == 'td':
first_cell_is_header_like = False
# Prüfe auf Style-Attribut mit font-weight bold
style = cells[0].get('style', '').lower()
if 'font-weight' in style and ('bold' in style or '700' in style):
first_cell_is_header_like = True
# Prüfe auf fettgedruckten Inhalt (<b> oder <strong>)
elif cells[0].find(['b', 'strong'], recursive=False):
first_cell_is_header_like = True
if first_cell_is_header_like:
header_text = cells[0].get_text(strip=True)
value_cell = cells[1]
# logger.debug(f" -> Zeile {idx}: Struktur TD(Header-like) + TD erkannt.")
# else:
# logger.debug(f" -> Zeile {idx}: Struktur TD + TD, aber erstes TD nicht als Header erkannt.")
# Wenn eine passende Struktur gefunden wurde
if header_text is not None and value_cell is not None:
# logger.debug(f" -> Verarbeite Zeile {idx} mit Header='{header_text}'")
header_text_lower = header_text.lower()
matched_keyword = None
# Prüfe, ob ein gesuchtes Keyword im Header-Text vorkommt
for kw in keywords:
if kw in header_text_lower:
matched_keyword = kw
break
# Wenn ein Keyword gefunden wurde, extrahiere den Wert
if matched_keyword:
# logger.debug(f" --> Keyword '{matched_keyword}' gefunden in Header '{header_text}'!")
# Entferne störende Elemente wie Referenz-Tags oder versteckte Spans aus der Value-Zelle
for sup in value_cell.find_all(['sup', 'span']):
if (sup.name == 'sup' and sup.has_attr('class') and 'reference' in sup['class']) or \
(sup.name == 'span' and sup.get('style') and 'display:none' in sup['style']):
# logger.debug(f" -> Entferne störendes Element: {sup.get_text(strip=True)[:50]}...") # Zu viel Lärm
sup.decompose() # Entferne das Element
# Extrahiere den Rohtext aus der bereinigten Value-Zelle
raw_value_text = value_cell.get_text(separator=' ', strip=True)
# logger.debug(f" -> Roher TD/Value-Text nach Decompose: '{raw_value_text}'") # Zu viel Lärm
# Bereinige und konvertiere den Wert basierend auf dem Zieltyp
if target == 'branche':
# Branche: Bereinigen, Klammern entfernen, nur erste Zeile nehmen
clean_val = clean_text(raw_value_text) # Nutzt globale clean_text
clean_val = re.sub(r'\s*\([^)]*\)', '', clean_val).strip() # Klammern entfernen
clean_val = clean_val.split('\n')[0].strip() # Nur erste Zeile
value_found = clean_val if clean_val else "k.A."
self.logger.info(f" --> Branche extrahiert: '{value_found}'")
elif target == 'umsatz':
# Umsatz: Numerische Extraktion (nutzt globale Funktion extract_numeric_value)
# extract_numeric_value gibt String zurück ("k.A." oder Zahl)
numeric_val_str = extract_numeric_value(raw_value_text, is_umsatz=True)
value_found = numeric_val_str
self.logger.info(f" --> Umsatz extrahiert (aus '{raw_value_text[:50]}...'): '{value_found}'")
elif target == 'mitarbeiter':
# Mitarbeiter: Numerische Extraktion (nutzt globale Funktion extract_numeric_value)
# extract_numeric_value gibt String zurück ("k.A." oder Zahl)
numeric_val_str = extract_numeric_value(raw_value_text, is_umsatz=False)
value_found = numeric_val_str
self.logger.info(f" --> Mitarbeiter extrahiert (aus '{raw_value_text[:50]}...'): '{value_found}'")
# Da wir den Wert gefunden haben, können wir die Schleife über die Zeilen abbrechen
break
# Wenn die Schleife durchläuft und kein passendes Keyword gefunden wurde, bleibt value_found "k.A."
if value_found != "k.A.":
self.logger.debug(f" -> Finaler Wert für '{target}' gefunden: '{value_found}'")
else:
self.logger.debug(f" -> Kein passender Eintrag für '{target}' in der gesamten Infobox gefunden.")
except Exception as e:
# Logge jeden Fehler, der während der Infobox-Verarbeitung auftritt
self.logger.exception(f"Fehler beim Durchlaufen der Infobox-Zeilen für '{target}': {e}")
return "k.A." # Bei Fehler "k.A." zurückgeben
return value_found
# --- Hauptmethoden ---
# retry_on_failure Decorator sollte hier angewendet werden, da es externe Calls macht
@retry_on_failure
def search_company_article(self, company_name, website=None):
"""
Sucht einen passenden Wikipedia-Artikel für das Unternehmen und gibt das
wikipedia.WikipediaPage Objekt zurück, wenn ein relevanter und validierter
Artikel gefunden wird. Behandelt explizit Begriffsklärungsseiten.
Args:
company_name (str): Der Name des Unternehmens (CRM Name).
website (str, optional): Die Website des Unternehmens (CRM Website). Defaults to None.
Returns:
wikipedia.WikipediaPage: Das validierte Page Objekt oder None.
"""
if not company_name:
self.logger.warning("Wikipedia search skipped: No company name provided.")
return None
# Generiere Suchbegriffe
search_terms = self._generate_search_terms(company_name, website)
if not search_terms:
self.logger.warning(f"Keine Suchbegriffe für '{company_name}' generiert.")
return None
self.logger.info(f"Starte Wikipedia-Suche für '{company_name}' (Website: {website}) mit Begriffen: {search_terms}")
# Menge der bereits geprüften Titel, um Redundanzen zu vermeiden
processed_titles = set()
# --- Innere Helferfunktion zum Prüfen eines einzelnen Titels ---
def check_page(title_to_check):
"""Lädt einen potenziellen Wikipedia-Artikel und validiert ihn."""
# Prüfen, ob der Titel bereits verarbeitet wurde
if title_to_check in processed_titles:
# self.logger.debug(f" -> Titel '{title_to_check}' bereits geprüft, überspringe.") # Zu viel Lärm
return None # Titel wurde bereits geprüft
# Titel zur Liste der verarbeiteten hinzufügen, bevor er geladen wird
processed_titles.add(title_to_check)
self.logger.debug(f" -> Prüfe potenziellen Artikel: '{title_to_check}'")
try:
# Lade die Seite. auto_suggest=False deaktiviert automatische Titelkorrektur,
# preload=True lädt den Inhalt und die InfoBox gleich mit.
page = wikipedia.page(title_to_check, auto_suggest=False, preload=True)
# Prüfe, ob es sich um eine Begriffsklärungsseite handelt (wird von wikipedia.page selbst als Exception geworfen)
# oder ob unsere Validierung fehlschlägt
if self._validate_article(page, company_name, website):
# Wenn der Artikel validiert wurde, geben Sie das Page-Objekt zurück
self.logger.info(f" -> Titel '{page.title}' erfolgreich validiert.")
return page
else:
self.logger.debug(f" -> Titel '{title_to_check}' nicht validiert.")
return None
except wikipedia.exceptions.PageError:
# Titel existiert nicht auf Wikipedia
self.logger.debug(f" -> Seite '{title_to_check}' nicht gefunden (PageError).")
return None
except wikipedia.exceptions.DisambiguationError as e_inner:
# Titel führt zu einer Begriffsklärungsseite
self.logger.info(f" -> Begriffsklärung '{title_to_check}' gefunden. Prüfe Optionen: {e_inner.options[:10]}...") # Logge nur die ersten Optionen
best_option_page = None
# Gehe durch die Optionen der Begriffsklärungsseite
for option in e_inner.options:
option_lower = option.lower()
# Filtere Optionen, die wahrscheinlich keine Unternehmensartikel sind (z.B. Personen, Orte)
# Fügen Sie hier weitere Filter oder eine verbesserte Heuristik hinzu
if any(exclude_word in option_lower for exclude_word in ["(person)", "(ort)", "(geographie)"]):
self.logger.debug(f" -> Option übersprungen (wahrscheinlich keine Firma): '{option}'")
continue
# Checken Sie die Option rekursiv mit check_page
# Dies wird die Option laden und validieren
validated_option_page = check_page(option)
# Wenn eine Option validiert wurde, nehmen Sie die erste als besten Treffer (oder implementieren Sie eine Ranking-Logik)
if validated_option_page:
self.logger.info(f" -> Option '{option}' aus Begriffsklärung erfolgreich validiert!")
# Wir könnten hier aufhören oder weiter nach dem besten Treffer suchen.
# Fürs Erste nehmen wir den ersten validierten Treffer.
return validated_option_page
# Wenn keine Option validiert wurde
self.logger.debug(f" -> Keine passende/validierte Unternehmens-Option in Begriffsklärung '{title_to_check}' gefunden.")
return None # Keine passende Option gefunden
except (requests.exceptions.RequestException, wikipedia.exceptions.WikipediaException) as e_req:
# Netzwerkfehler oder Wikipedia-spezifische API-Fehler beim Laden/Validieren
# Diese Fehler werden vom @retry_on_failure Decorator (außerhalb von check_page) behandelt,
# ABER: Wenn sie innerhalb von check_page auftreten, brechen sie NUR die Prüfung dieses EINEN Titels ab.
# Wir wollen, dass der Hauptaufruf (search_company_article) retried wird, nicht check_page.
# Also loggen wir hier den Fehler und geben None zurück, ohne die Exception weiterzuwerfen.
self.logger.warning(f" -> Netzwerk/API-Fehler beim Laden/Validieren von '{title_to_check}': {e_req}. Überspringe diesen Titel.")
# Optional: Kleine Pause bei Netzwerkfehlern, um API nicht weiter zu reizen
# time.sleep(0.5)
return None # Diesen Titel überspringen und nächsten versuchen
except Exception as e_page:
# Andere unerwartete Fehler bei der Seitenverarbeitung
self.logger.error(f" -> Unerwarteter Fehler bei Verarbeitung von Titel '{title_to_check}': {type(e_page).__name__} - {e_page}")
self.logger.debug(traceback.format_exc()) # Logge Traceback für unerwartete Fehler
return None # Diesen Titel überspringen
# --- Haupt-Suchlogik (Iteriere durch Suchbegriffe und Ergebnisse) ---
self.logger.debug(f" -> Versuche direkten Match für '{company_name}'...")
# Versuche zuerst den exakten Firmennamen als Titel zu laden und zu validieren
validated_page = check_page(company_name)
if validated_page:
return validated_page # Direkter, validierter Treffer gefunden!
self.logger.debug(f" -> Kein direkter Treffer/validiert. Starte Suche mit generierten Begriffen: {search_terms}")
# Wenn kein direkter Treffer, führe eine Suche mit den generierten Begriffen durch
for term in search_terms:
try:
self.logger.debug(f" -> Suche mit Begriff: '{term}'...")
# Führe die Suche über die wikipedia-Bibliothek durch
# wikipedia.search wirft exceptions (z.B. PageError), die vom retry_on_failure im Decorator gefangen werden
search_results = wikipedia.search(term, results=getattr(Config, 'WIKIPEDIA_SEARCH_RESULTS', 5))
self.logger.debug(f" -> Suchergebnisse für '{term}': {search_results}")
if not search_results:
self.logger.debug(f" -> Keine Suchergebnisse für '{term}'.")
continue # Nächsten Suchbegriff versuchen
# Prüfe jeden Titel in den Suchergebnissen
for title in search_results:
validated_page = check_page(title)
if validated_page:
return validated_page # Ersten validierten Artikel gefunden!
# Kleine Pause zwischen dem Prüfen einzelner Suchergebnisse
# time.sleep(0.05) # Sehr kurz, optional
except Exception as e_search:
# Fehler während wikipedia.search (z.B. Netzwerkfehler, API-Fehler)
# Diese werden vom @retry_on_failure Decorator der search_company_article Methode behandelt.
# Werfen Sie die Exception erneut, damit der Decorator sie fangen kann.
self.logger.error(f"Fehler während Wikipedia-Suche für '{term}': {type(e_search).__name__} - {e_search}")
raise e_search # Exception weitergeben für Retry des gesamten search_company_article Calls
# Wenn alle Suchbegriffe und alle Ergebnisse geprüft wurden und kein validierter Artikel gefunden wurde
self.logger.warning(f"Kein passender & validierter Wikipedia-Artikel für '{company_name}' gefunden nach Prüfung aller Begriffe und Optionen.")
return None # Signalisiert, dass kein passender Artikel gefunden wurde
# retry_on_failure Decorator sollte hier angewendet werden, da es externe Calls macht
@retry_on_failure
def extract_company_data(self, page_url):
"""
Extrahiert Firmendaten (erster Absatz, Infobox-Werte, Kategorien)
von einer gegebenen Wikipedia-Artikel-URL.
Args:
page_url (str): Die URL des Wikipedia-Artikels.
Returns:
dict: Ein Dictionary mit den extrahierten Daten oder Default-Werten ('k.A.').
"""
# Default-Ergebnis im Fehlerfall oder bei ungültiger URL
default_result = {'url': page_url if page_url else 'k.A.', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}
# Grundlegende URL-Prüfung
if not page_url or not isinstance(page_url, str) or "wikipedia.org/wiki/" not in page_url.lower():
self.logger.warning(f"extract_company_data: Ungültige oder keine Wikipedia-URL '{page_url}'.")
return default_result
self.logger.info(f"Extrahiere Daten für Wiki-URL: {page_url}")
# Holen Sie das Soup-Objekt der Seite (nutzt interne Methode mit Retry)
soup = self._get_page_soup(page_url)
if not soup:
self.logger.error(f" -> Fehler: Konnte Seite {page_url} nicht laden oder parsen.")
# Das default_result enthält bereits die URL und k.A. für Daten.
return default_result
# Extrahiere die einzelnen Datenpunkte
self.logger.debug(" -> Extrahiere erster Absatz...")
first_paragraph = self._extract_first_paragraph_from_soup(soup)
self.logger.debug(" -> Extrahiere Kategorien...")
categories_val = self.extract_categories(soup)
self.logger.debug(" -> Extrahiere Branche aus Infobox...")
# Nutzt interne Methode _extract_infobox_value, die extract_numeric_value nutzt
branche_val = self._extract_infobox_value(soup, 'branche')
self.logger.debug(" -> Extrahiere Umsatz aus Infobox...")
umsatz_val = self._extract_infobox_value(soup, 'umsatz')
self.logger.debug(" -> Extrahiere Mitarbeiter aus Infobox...")
mitarbeiter_val = self._extract_infobox_value(soup, 'mitarbeiter')
# Baue das Ergebnis-Dictionary zusammen
result = {
'url': page_url,
'first_paragraph': first_paragraph,
'branche': branche_val,
'umsatz': umsatz_val,
'mitarbeiter': mitarbeiter_val,
'categories': categories_val
}
# Loggen Sie eine Zusammenfassung der extrahierten Daten
self.logger.info(f" -> Extrahierte Daten: P='{first_paragraph[:50]}...', B='{branche_val}', U='{umsatz_val}', M='{mitarbeiter_val}', C='{categories_val[:50]}...'")
return result
# ==============================================================================
# 6. DATA PROCESSOR CLASS (PART 1: Init & Status-Checker)
# (Entspricht logisch dem Beginn von 'data_processor.py')
# ==============================================================================
class DataProcessor:
"""
Zentrale Klasse zur Orchestrierung und Verarbeitung von Unternehmensdaten
aus dem Google Sheet. Enthält die Logik für die Verarbeitung einzelner
Zeilen sowie die Steuerung verschiedener Batch-Modi und Dienstprogramme.
Nutzt Instanzen von Handler-Klassen (Sheet, Wiki etc.) als Worker.
"""
def __init__(self, sheet_handler, wiki_scraper): # Akzeptiert benötigte Worker-Instanzen
"""
Initialisiert den DataProcessor mit Instanzen von Handler-Klassen.
Args:
sheet_handler (GoogleSheetHandler): Eine initialisierte Instanz.
wiki_scraper (WikipediaScraper): Eine initialisierte Instanz.
# Fügen Sie hier weitere benötigte Handler/Worker hinzu (z.B. OpenAIHandler, SerpAPIHandler),
# falls diese als eigene Klassen ausgelagert werden.
"""
# Erhalten Sie eine Logger-Instanz für diese Klasse
self.logger = logging.getLogger(__name__ + ".DataProcessor")
self.logger.info("Initialisiere DataProcessor...")
# Attribute für ML-Modellierung (werden beim ersten Bedarf geladen)
self.model = None
self.imputer = None
self._expected_features = None # Liste der erwarteten Feature-Spalten für Vorhersage
# Überprüfen Sie, ob gültige Handler-Instanzen übergeben wurden
if not isinstance(sheet_handler, GoogleSheetHandler):
self.logger.critical("DataProcessor Init FEHLER: Kein gültiger GoogleSheetHandler übergeben!")
raise ValueError("DataProcessor benötigt eine gültige GoogleSheetHandler Instanz.")
if not isinstance(wiki_scraper, WikipediaScraper):
self.logger.critical("DataProcessor Init FEHLER: Kein gültiger WikipediaScraper übergeben!")
raise ValueError("DataProcessor benötigt eine gültige WikipediaScraper Instanz.")
# Speichern Sie die Handler-Instanzen als Attribute
self.sheet_handler = sheet_handler
self.wiki_scraper = wiki_scraper
# self.openai_handler = openai_handler # Beispiel, falls ausgelagert
# self.serpapi_handler = serpapi_handler # Beispiel, falls ausgelagert
self.logger.info("DataProcessor initialisiert mit Handlern.")
# Definieren Sie hier (oder als Klassenattribut) die Zuordnung von Schritt-Typen
# zu den relevanten Spaltenschlüsseln für die Statusprüfung.
# Diese werden von _should_run_based_on_status verwendet.
self._step_status_map = {
'wiki': { # Wiki Suche & Extraktion (AN) + Wiki Verifizierung (AX) & S='X(URL Copied)'
# ACHTUNG: In _process_single_row wird 'wiki' (AN) und 'wiki_verify' (AX) separat behandelt.
# Diese Map ist primär für die Batch-Modi relevant, die auf EINEM TS prüfen.
# Für _process_single_row machen wir die Checks im Code direkt oder mit granulareren Helfern.
# Lassen wir diese Map erstmal für die Batch-Modi.
'wiki_verify': "Wiki Verif. Timestamp", # AX
'website_scrape': "Website Scrape Timestamp", # AT
'summarize_website': "Website Scrape Timestamp", # AT (Zusammenfassung triggert mit Scraping)
'branch_eval': "Timestamp letzte Prüfung", # AO
'find_wiki_serp': "SerpAPI Wiki Search Timestamp", # AY
'contact_search': "Contact Search Timestamp", # AM
'wiki_updates_from_chatgpt': "Chat Wiki Konsistenzprüfung" # S (Sonderfall: check auf Status nicht Timestamp)
# 'wiki_extract': "Wikipedia Timestamp", # AN (Wird in _process_single_row speziell geprüft)
}
}
# HINWEIS: Die Logik, ob ein Schritt ausgeführt werden soll, ist komplexer als nur ein Timestamp
# (z.B. 'find_wiki_serp' braucht auch leere M und Größe; 'summarize_website' braucht gefülltes AR).
# Die folgende Methode _should_run_based_on_status wird hauptsächlich für die sequenzielle
# Verarbeitung (_process_single_row) und den Re-Eval Modus verfeinert. Batch-Modi haben oft
# ihre eigene spezifische Logik zur Zeilenauswahl.
# --- Interne Hilfsmethode zur Statusprüfung einer Zeile für einen Schritt-Typ ---
def _should_run_based_on_status(self, row_data, step_type):
"""
Prüft, ob ein bestimmter Verarbeitungsschritt für die gegebene Zeile
basierend auf Timestamps oder Status im Sheet ausgeführt werden sollte.
Dies ignoriert das force_reeval Flag, das vom Aufrufer behandelt werden muss.
Args:
row_data (list): Die Listendaten für die Zeile.
step_type (str): Der Typ des Schritts ('wiki', 'web', 'chat' im Kontext von _process_single_row,
oder spezifischere Keys für Batch-Modi wie 'wiki_verify', 'website_scrape', etc.).
ACHTUNG: Die Interpretation von step_type hängt vom Aufrufer ab (_process_single_row vs. Batch-Methoden).
# force_reeval wird HIER nicht geprüft. Der Aufrufer muss das OR force_reeval machen.
Returns:
bool: True, wenn der Schritt basierend auf dem Status in der Zeile ausgeführt werden sollte.
"""
# Hilfsfunktion für sicheren Zellenzugriff innerhalb dieser Methode
def get_cell_value_safe(row, column_key):
idx = COLUMN_MAP.get(column_key)
if idx is not None and len(row) > idx:
# Rückgabe des Wertes, sicherstellen, dass es nicht None ist
return row[idx] if row[idx] is not None else ''
# Logge auf Debug, wenn der Index fehlt oder die Zeile zu kurz ist
# self.logger.debug(f"Kann Wert für '{column_key}' (Index {idx}) nicht sicher abrufen (Zeilenlänge {len(row)}).") # Zu viel Lärm
return '' # Gebe leeren String für fehlende Spalten zurück
status_needs_run = False # Standard: Verarbeitung nicht nötig basierend auf Status
if step_type == 'wiki':
# Für die 'wiki' Gruppe in _process_single_row (Suche, Extraktion):
# Lauf, wenn AN leer ist ODER S den speziellen Wert 'X (URL Copied)' hat.
an_value = get_cell_value_safe(row_data, "Wikipedia Timestamp").strip()
s_value = get_cell_value_safe(row_data, "Chat Wiki Konsistenzprüfung").strip().upper() # Case-insensitive prüfen
if not an_value or s_value == "X (URL COPIED)":
status_needs_run = True
# self.logger.debug(f" -> Wiki-Schritt nötig (AN leer? {not an_value}, S='X (URL COPIED)'? {s_value == 'X (URL COPIED)'})") # Zu viel Lärm
# else: self.logger.debug(f" -> Wiki-Schritt nicht nötig (AN='{an_value}', S='{s_value}')") # Zu viel Lärm
elif step_type == 'web':
# Für die 'web' Gruppe in _process_single_row (Scraping, Summarization):
# Lauf, wenn AT leer ist.
at_value = get_cell_value_safe(row_data, "Website Scrape Timestamp").strip()
if not at_value:
status_needs_run = True
# self.logger.debug(f" -> Web-Schritt nötig (AT leer)") # Zu viel Lärm
# else: self.logger.debug(f" -> Web-Schritt nicht nötig (AT='{at_value}')") # Zu viel Lärm
elif step_type == 'chat':
# Für die 'chat' Gruppe in _process_single_row (Branch, FSM, Emp, Umsatz Evals):
# Lauf, wenn AO leer ist.
# ACHTUNG: Der Trigger "Wiki Daten gerade aktualisiert" kann HIER nicht geprüft werden,
# da er Laufzeit-Status von _process_single_row ist. Das muss _process_single_row selbst handeln.
ao_value = get_cell_value_safe(row_data, "Timestamp letzte Prüfung").strip()
if not ao_value:
status_needs_run = True
# self.logger.debug(f" -> Chat-Schritt nötig (AO leer)") # Zu viel Lärm
# else: self.logger.debug(f" -> Chat-Schritt nicht nötig (AO='{ao_value}')") # Zu viel Lärm
# --- Fügen Sie hier Checks für weitere spezifische Batch/Utility-Schritte hinzu, falls _should_run_based_on_status
# universeller verwendet werden soll. Aktuell ist es hauptsächlich für _process_single_row gedacht. ---
# Für die Batch-Modi (z.B. process_verification_batch):
# Die Batch-Methoden haben oft spezifischere Kriterien ZUSÄTZLICH zum Timestamp,
# z.B. "Wiki URL (M) muss gefüllt sein und AX muss leer sein".
# Es ist oft einfacher, diese spezifische Logik in der jeweiligen Batch-Methode zu implementieren,
# anstatt sie hier zu generalisieren.
# Daher wird _should_run_based_on_status primär von _process_single_row verwendet, um die
# groben Gruppen 'wiki', 'web', 'chat' zu steuern.
else:
# Wenn ein unbekannter step_type übergeben wird
self.logger.warning(f"_should_run_based_on_status aufgerufen mit unbekanntem step_type '{step_type}'. Gibt False zurück.")
status_needs_run = False
# Optional: Logge das Ergebnis dieser spezifischen Prüfung
# self.logger.debug(f" -> _should_run_based_on_status('{step_type}') Ergebnis: {status_needs_run}") # Zu viel Lärm
return status_needs_run
# --- Die folgenden Methoden werden in separaten Teilen bereitgestellt ---
# _process_single_row method... (kommt in Teil 5 & 6)
# process_reevaluation_rows method... (kommt später)
# process_rows_sequentially method... (kommt später)
# process_verification_batch method... (kommt später)
# process_website_batch method... (kommt später)
# process_summarization_batch method... (kommt später)
# process_branch_batch method... (kommt später)
# process_serp_website_lookup method... (kommt später)
# process_website_details method... (kommt später)
# process_contact_research method... (kommt später)
# process_wiki_updates_from_chatgpt method... (kommt später)
# process_wiki_reextract_missing_an method... (kommt später)
# prepare_data_for_modeling method... (kommt später)
# train_technician_model method... (kommt später)
# run_batch_dispatcher method... (kommt später, falls benötigt)
# --- Interne Hilfsmethoden zur Prüfung, ob ein Schritt ausgeführt werden soll ---
# Diese Methoden kapseln die Logik zur Entscheidung, ob ein Schritt basierend
# auf dem Zeilenstatus (Timestamps, Flags) und dem force_reeval Flag ausgeführt werden soll.
def _get_cell_value_safe(self, row, column_key):
"""
Hilfsfunktion für sicheren Zellenzugriff anhand des COLUMN_MAP Schlüssels.
Gibt leeren String zurück, wenn Index nicht existiert oder Zeile zu kurz ist.
"""
idx = COLUMN_MAP.get(column_key)
if idx is not None and len(row) > idx:
# Rückgabe des Wertes, sicherstellen, dass es nicht None ist
return row[idx] if row[idx] is not None else ''
# Logging kann hier sehr laut sein, nur bei Bedarf aktivieren oder auf Debug lassen
# self.logger.debug(f"Kann Wert für '{column_key}' (Index {idx}) nicht sicher abrufen (Zeilenlänge {len(row)}).")
return '' # Gebe leeren String für fehlende Spalten zurück
def _needs_website_processing(self, row_data, force_reeval):
"""
Prüft, ob Website-Scraping/Summarization für diese Zeile nötig ist.
Nötig, wenn force_reeval True ist ODER wenn der Website Scrape Timestamp (AT) leer ist.
"""
if force_reeval:
# self.logger.debug(" -> Website-Schritt nötig (force_reeval=True)") # Zu viel Lärm
return True
# Prüfe, ob der Website Scrape Timestamp (AT) leer ist
at_value = self._get_cell_value_safe(row_data, "Website Scrape Timestamp").strip()
if not at_value:
# self.logger.debug(" -> Website-Schritt nötig (AT leer)") # Zu viel Lärm
return True
# self.logger.debug(f" -> Website-Schritt nicht nötig (AT='{at_value}')") # Zu viel Lärm
return False
def _needs_wiki_processing(self, row_data, force_reeval):
"""
Prüft, ob Wikipedia-Suche/Extraktion für diese Zeile nötig ist.
Nötig, wenn force_reeval True ist ODER wenn der Wikipedia Timestamp (AN)
leer ist ODER wenn Status S 'X (URL Copied)' ist.
"""
if force_reeval:
# self.logger.debug(" -> Wiki-Extraktion/-Suche nötig (force_reeval=True)") # Zu viel Lärm
return True
# Prüfe, ob der Wikipedia Timestamp (AN) leer ist
an_value = self._get_cell_value_safe(row_data, "Wikipedia Timestamp").strip()
if not an_value:
# self.logger.debug(" -> Wiki-Extraktion/-Suche nötig (AN leer)") # Zu viel Lärm
return True
# Prüfe, ob Status S 'X (URL Copied)' ist (signalisiert neue URL wurde kopiert)
s_value = self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzprüfung").strip().upper()
if s_value == "X (URL COPIED)":
# self.logger.debug(" -> Wiki-Extraktion/-Suche nötig (S='X (URL Copied)')") # Zu viel Lärm
return True
# self.logger.debug(f" -> Wiki-Extraktion/-Suche nicht nötig (AN='{an_value}', S='{s_value}')") # Zu viel Lärm
return False
def _needs_wiki_verification(self, row_data, force_reeval):
"""
Prüft, ob Wikipedia-Verifizierung (S-Y) für diese Zeile nötig ist.
Nötig, wenn force_reeval True ist ODER wenn der Wiki Verif. Timestamp (AX) leer ist
UND eine Wiki URL (M) vorhanden ist.
"""
if force_reeval:
# self.logger.debug(" -> Wiki-Verifizierung nötig (force_reeval=True)") # Zu viel Lärm
return True
# Prüfe, ob der Wiki Verif. Timestamp (AX) leer ist
ax_value = self._get_cell_value_safe(row_data, "Wiki Verif. Timestamp").strip()
if not ax_value:
# Prüfe ZUSÄTZLICH, ob eine Wiki URL (M) vorhanden ist, da Verifizierung sonst sinnlos ist
m_value = self._get_cell_value_safe(row_data, "Wiki URL").strip()
if m_value and m_value.lower() not in ["k.a.", "kein artikel gefunden"]:
# self.logger.debug(" -> Wiki-Verifizierung nötig (AX leer UND M gefüllt)") # Zu viel Lärm
return True
# else: self.logger.debug(" -> Wiki-Verifizierung nicht nötig (AX leer, aber M leer/k.A.)") # Zu viel Lärm
# else: self.logger.debug(f" -> Wiki-Verifizierung nicht nötig (AX='{ax_value}')") # Zu viel Lärm
return False
def _needs_chat_evaluations(self, row_data, force_reeval, wiki_data_just_updated):
"""
Prüft, ob ChatGPT-Evaluationen (Branch, FSM etc.) für diese Zeile nötig sind.
Nötig, wenn force_reeval True ist ODER wenn der Timestamp letzte Prüfung (AO)
leer ist ODER wenn Wiki-Daten gerade aktualisiert wurden.
"""
if force_reeval:
# self.logger.debug(" -> Chat-Evaluationen nötig (force_reeval=True)") # Zu viel Lärm
return True
# Prüfe, ob der Timestamp letzte Prüfung (AO) leer ist
ao_value = self._get_cell_value_safe(row_data, "Timestamp letzte Prüfung").strip()
if not ao_value:
# self.logger.debug(" -> Chat-Evaluationen nötig (AO leer)") # Zu viel Lärm
return True
# Prüfe, ob Wiki-Daten in diesem Lauf gerade aktualisiert wurden
if wiki_data_just_updated:
# self.logger.debug(" -> Chat-Evaluationen nötig (Wiki-Daten gerade aktualisiert)") # Zu viel Lärm
return True
# self.logger.debug(f" -> Chat-Evaluationen nicht nötig (AO='{ao_value}' und Wiki-Daten nicht aktualisiert)") # Zu viel Lärm
return False
# --- Die _process_single_row Methode folgt in den nächsten Teilen ---
# Diese Methode wird sehr lang und wird auf mehrere Nachrichten aufgeteilt.
# Sie wird die oben definierten _needs_... Methoden verwenden.
# def _process_single_row(...): # <-- Beginnt im nächsten Teil
# --- Methode: Verarbeitung einer einzelnen Zeile ---
# Diese Methode gehört in die Klasse DataProcessor.
# @retry_on_failure # Nicht sinnvoll auf dieser Orchestrierungsebene
def _process_single_row(self, row_num_in_sheet, row_data,
steps_to_run, force_reeval=False, clear_x_flag=False): # NEUES ARGUMENT hinzugefügt
"""
Verarbeitet die Daten für eine einzelne Zeile im Sheet, führt ausgewählte
Anreicherungs- und Analyseprozesse durch, basierend auf Timestamps/Status
oder dem force_reeval Flag. Sammelt und schreibt Ergebnisse zurück.
Args:
row_num_in_sheet (int): Die 1-basierte Zeilennummer im Google Sheet.
row_data (list): Die rohen Listendaten für diese Zeile.
steps_to_run (set/list): Menge oder Liste von Schlüsseln der Schritte,
die in diesem Lauf berücksichtigt werden sollen
(z.B. {'wiki', 'web', 'chat'}).
force_reeval (bool, optional): Ignoriert Timestamps/Status und erzwingt
die Ausführung der in steps_to_run
enthaltenen Schritte. Defaults to False.
"""
self.logger.info(f"--- Starte Verarbeitung für Zeile {row_num_in_sheet} {'(Re-Eval)' if force_reeval else ''} (Schritte: {', '.join(steps_to_run) if steps_to_run else 'Keine ausgewählt'}) ---")
updates = [] # Liste zur Sammlung von Sheet-Updates für diese Zeile
now_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
any_processing_done = False # Flag, ob irgendein Schritt ausgeführt wurde
wiki_data_updated_in_this_run = False # Flag, ob Wiki-Daten NEU extrahiert/gesetzt wurden (relevant für Chat-Trigger)
# Hilfsfunktion für sicheren Zellenzugriff (verwendet die interne Methode)
# get_cell_value = lambda key: self._get_cell_value_safe(row_data, key)
# Direkter Aufruf self._get_cell_value_safe ist klarer
# Initiale Werte lesen (die für mehrere Schritte benötigt werden könnten)
# Stellen Sie sicher, dass alle benötigten Spalten in COLUMN_MAP vorhanden sind,
# sonst wirft _get_cell_value_safe auf Debug-Level.
company_name = self._get_cell_value_safe(row_data, "CRM Name").strip()
website_url = self._get_cell_value_safe(row_data, "CRM Website").strip() # Arbeitskopie der URL
original_website_url_in_sheet = website_url # Originalwert aus Sheet behalten, für Lookup-Logik
crm_branche = self._get_cell_value_safe(row_data, "CRM Branche").strip()
crm_beschreibung = self._get_cell_value_safe(row_data, "CRM Beschreibung").strip()
konsistenz_s = self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzprüfung").strip() # Trimme hier schon
# Lade vorhandene Wiki-Daten (könnten alt sein, werden ggf. überschrieben)
# Verwende 'k.A.' als Standard, wenn die Zellen leer sind.
# Dies sind die Daten, die am ENDE des Wiki-Schritts in final_wiki_data stehen SOLLTEN,
# falls der Wiki-Schritt NICHT ausgeführt wird.
current_wiki_data = {
'url': self._get_cell_value_safe(row_data, "Wiki URL") or 'k.A.',
'first_paragraph': self._get_cell_value_safe(row_data, "Wiki Absatz") or 'k.A.',
'branche': self._get_cell_value_safe(row_data, "Wiki Branche") or 'k.A.',
'umsatz': self._get_cell_value_safe(row_data, "Wiki Umsatz") or 'k.A.',
'mitarbeiter': self._get_cell_value_safe(row_data, "Wiki Mitarbeiter") or 'k.A.',
'categories': self._get_cell_value_safe(row_data, "Wiki Kategorien") or 'k.A.'
}
final_wiki_data = current_wiki_data.copy() # Arbeitskopie für extrahierte/neue Daten
# Lade vorhandenen Website-Rohtext und Zusammenfassung
# Dies sind die Daten, die am ENDE des Web-Schritts in den Variablen stehen SOLLTEN,
# falls der Web-Schritt NICHT ausgeführt wird.
current_website_raw = self._get_cell_value_safe(row_data, "Website Rohtext") or 'k.A.'
current_website_summary = self._get_cell_value_safe(row_data, "Website Zusammenfassung") or 'k.A.'
website_raw = current_website_raw # Arbeitskopie
website_summary = current_website_summary # Arbeitskopie
# --- 1. Website Handling (Lookup, Scraping, Summarization) ---
# Dieser Schritt wird ausgeführt, wenn 'web' in steps_to_run enthalten ist UND
# (_needs_website_processing True ist ODER force_reeval True ist).
# _needs_website_processing prüft nur AT. Die Lookup-Logik (D leer) ist separat.
# Die Website-Verarbeitung umfasst Lookup (optional), Scraping und Summarization.
run_website_step = 'web' in steps_to_run
website_processing_needed_based_on_status = self._needs_website_processing(row_data, force_reeval)
if run_website_step and website_processing_needed_based_on_status:
any_processing_done = True # Markiere, dass in dieser Zeile etwas getan wird
self.logger.info(f"Zeile {row_num_in_sheet}: Führe WEBSITE Schritte aus (Grund: {'Re-Eval' if force_reeval else 'AT leer'})")
# Website Lookup nur, wenn die URL in Spalte D leer oder "k.A." ist
if not original_website_url_in_sheet or original_website_url_in_sheet.lower() == "k.a.":
self.logger.debug(" -> Website URL (D) leer oder k.A., suche via SERP...")
# Annahme: serp_website_lookup global definiert oder als Methode einer SerpAPIHandler Klasse (hier global/utils)
try:
# Nutzt den globalen retry_on_failure Decorator
new_website = serp_website_lookup(company_name) # Annahme: serp_website_lookup in utils.py
if new_website != "k.A.":
website_url = new_website # Update die lokale Variable für den weiteren Schritt (Scraping)
# Fügen Sie das Update für Spalte D zur Liste hinzu
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["CRM Website"] + 1)}{row_num_in_sheet}', 'values': [[website_url]]})
self.logger.info(f" -> Neue Website gefunden und für Update D:{row_num_in_sheet} vorgemerkt: {website_url}")
else:
self.logger.warning(f" -> Keine neue Website via SERP gefunden für '{company_name}'.")
# website_url bleibt leer oder k.A. in diesem Fall
except Exception as e_serp_lookup:
self.logger.error(f"FEHLER bei SERP Website Lookup für '{company_name}': {e_serp_lookup}")
# Bei Fehler bleibt website_url unverändert (leer oder k.A.)
pass # Fahren Sie fort, falls eine URL im Sheet war oder gefunden wurde
# Führe Scraping und Zusammenfassung nur durch, wenn eine gültige Website URL vorhanden ist (lokale Variable website_url)
if website_url and website_url.lower() != "k.a.":
self.logger.debug(f" -> Scrape Rohtext von {website_url}...")
# Annahme: get_website_raw global definiert oder als Methode eines WebsiteScraper (hier global/utils)
try:
# Nutzt den globalen retry_on_failure Decorator
new_website_raw = get_website_raw(website_url) # Annahme: get_website_raw in utils.py
website_raw = new_website_raw # Lokale Variable aktualisieren (AR Wert)
# Zusammenfassung nur, wenn gültiger Rohtext extrahiert wurde
if website_raw != "k.A." and website_raw != "k.A. (Nur Cookie-Banner erkannt)" and website_raw.strip():
self.logger.debug(f" -> Fasse Rohtext zusammen (Länge: {len(str(website_raw))})...")
# Annahme: summarize_website_content global definiert oder als Methode eines OpenAICreator (hier global/utils)
try:
# Nutzt den globalen retry_on_failure Decorator
new_website_summary = summarize_website_content(website_raw) # Annahme: summarize_website_content in utils.py
website_summary = new_website_summary if new_website_summary and new_website_summary.strip() else "k.A. (Keine Zusammenfassung erhalten)"
# Fügen Sie das Update für Spalte AS zur Liste hinzu
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[website_summary]]})
except Exception as e_summary:
self.logger.error(f"FEHLER bei Website Zusammenfassung für '{company_name}': {e_summary}")
website_summary = "k.A. (Fehler Zusammenfassung)" # Lokale Variable setzen
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[website_summary]]})
else:
self.logger.warning(" -> Kein gültiger Rohtext zum Zusammenfassen vorhanden.")
website_summary = "k.A." # Stelle sicher, dass die lokale Variable korrekt gesetzt ist, falls nicht zusammengefasst
# Füge 'k.A.' Update für AS hinzu (nur wenn es vorher nicht k.A. war?)
# Oder immer setzen, wenn der Schritt lief und keine Zusammenfassung erstellt wurde.
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]})
# Fügen Sie das Update für Spalte AR (Rohtext) zur Liste hinzu
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [[website_raw]]})
except Exception as e_scrape:
self.logger.error(f"FEHLER beim Website Scraping für '{company_name}' ({website_url}): {e_scrape}")
website_raw, website_summary = "k.A. (Fehler)", "k.A. (Fehler)" # Lokale Variablen setzen
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [[website_raw]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[website_summary]]})
else:
self.logger.warning(f" -> Keine gültige Website URL vorhanden/gefunden für '{company_name}'. Website Scraping übersprungen.")
# Stellen Sie sicher, dass AR und AS auf k.A. gesetzt werden, wenn der Schritt lief, aber keine URL da war
website_raw, website_summary = "k.A.", "k.A." # Lokale Variablen setzen
# Fügen Sie Updates für AR und AS hinzu, falls nötig
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]})
# Setzen Sie den Website Scrape Timestamp (AT), da der Website-Schritt lief
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Scrape Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]})
elif run_website_step: # Website Schritt war ausgewählt, aber nicht nötig basierend auf Status/Re-Eval
self.logger.debug(f"Zeile {row_num_in_sheet}: Überspringe WEBSITE Schritte (AT vorhanden und kein Re-Eval).")
# Wenn der Schritt übersprungen wird, verwenden wir die vorhandenen Werte aus dem Sheet,
# die bereits zu Beginn der Methode geladen wurden (current_website_raw, current_website_summary).
# website_raw und website_summary behalten ihre initialen Werte.
# --- Der Code für den nächsten Verarbeitungsschritt (Wikipedia) folgt im nächsten Teil ---
# Definiton der Methode _process_single_row wird in der nächsten Nachricht fortgesetzt.
# --- 2. Wikipedia Handling (Search, Extraction, Status Reset) ---
# Dieser Schritt wird ausgeführt, wenn 'wiki' in steps_to_run enthalten ist UND
# (_needs_wiki_processing True ist ODER force_reeval True ist).
# _needs_wiki_processing prüft AN und S='X (URL Copied)'.
# Die Logik für S='X (URL Copied)' dient dazu, eine URL, die durch die Wiki-Update
# Funktion in M kopiert wurde, sofort neu extrahieren zu lassen.
run_wiki_step = 'wiki' in steps_to_run
wiki_processing_needed_based_on_status = self._needs_wiki_processing(row_data, force_reeval)
if run_wiki_step and wiki_processing_needed_based_on_status:
any_processing_done = True # Markiere, dass in dieser Zeile etwas getan wird
# Bestimme den Grund für die Ausführung dieses Schritts für das Logging
grund_message_parts = []
if force_reeval: grund_message_parts.append('Re-Eval')
if not self._get_cell_value_safe(row_data, "Wikipedia Timestamp").strip(): grund_message_parts.append('AN leer')
if self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzprüfung").strip().upper() == "X (URL COPIED)": grund_message_parts.append("S='X (URL Copied)'")
grund_message = ", ".join(grund_message_parts)
self.logger.info(f"Zeile {row_num_in_sheet}: Führe WIKI Suche/Extraktion aus (Grund: {grund_message})...")
url_in_m = self._get_cell_value_safe(row_data, "Wiki URL").strip()
url_to_extract = None # Die URL, von der wir am Ende Daten extrahieren werden
search_was_needed = False # Flag, ob eine neue Suche durchgeführt wurde
# --- Logik zur Bestimmung der URL, die verwendet werden soll ---
# Priorität (bei Ausführung des Wiki-Schritts):
# 1. Wenn S == "X (URL Copied)": Ignoriere URL in M, führe neue Suche aus.
# 2. Wenn force_reeval True: Nimm URL in M, WENN gültig aussehend. Sonst neue Suche.
# 3. Wenn AN leer (und kein S="X(URL Copied)", kein Re-Eval): Nimm URL in M, WENN valide. Sonst neue Suche.
status_s_indicates_reparse = self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzprüfung").strip().upper() == "X (URL COPIED)"
an_value = self._get_cell_value_safe(row_data, "Wikipedia Timestamp").strip()
m_url_exists_and_looks_valid = url_in_m and url_in_m.lower() not in ["k.a.", "kein artikel gefunden"] and url_in_m.lower().startswith("http") and "wikipedia.org/wiki/" in url_in_m.lower()
if status_s_indicates_reparse:
self.logger.warning(f" -> Status S ist 'X (URL Copied)', ignoriere URL '{url_in_m}' in M und starte neue Suche...")
search_was_needed = True # Suche ist nötig
elif force_reeval:
self.logger.debug(" -> Re-Eval Modus aktiv für Wiki-Schritt.")
if m_url_exists_and_looks_valid:
# Im Re-Eval Modus nehmen wir die URL aus M an, ohne erneute Validierung oder Suche (Vertrauen auf M, falls valide aussieht)!
self.logger.info(f" -> Re-Eval: Nutze vorhandene URL aus Spalte M direkt: {url_in_m}")
url_to_extract = url_in_m # Verwende die URL aus M
else:
# Wenn M leer/ungültig ist, auch im Re-Eval Modus neu suchen
self.logger.warning(f" -> Re-Eval: Spalte M ist leer oder ungültig ('{url_in_m}'). Starte neue Suche...")
search_was_needed = True # Suche ist nötig
elif not an_value: # Nur wenn AN fehlt (und kein S="X(Copied)" oder Re-Eval)
if m_url_exists_and_looks_valid:
# Wenn AN fehlt und M gefüllt ist, prüfen wir die Validität der M-URL
self.logger.debug(f" -> AN fehlt, prüfe Validität der URL aus M: {url_in_m}")
try:
# Extrahieren des Titels aus der URL (z.B. 'Unternehmen_AG' aus ".../wiki/Unternehmen_AG")
# Nutzt unquote (aus utils) zur Dekodierung von URL-Sonderzeichen
title_from_url_part = url_in_m.split('/wiki/', 1)[1].split('#')[0] # Titelteil nach /wiki/, Anker entfernen
title_from_url = unquote(title_from_url_part).replace('_', ' ') # Dekodieren und Unterstriche ersetzen
# Laden des Page Objekts, um es mit _validate_article zu prüfen
# Dieser Aufruf kann PageError, DisambiguationError etc. werfen
page_from_m = wikipedia.page(title_from_url, auto_suggest=False, preload=True)
# Validierung des Artikels mit der Scraper-Methode
if self.wiki_scraper._validate_article(page_from_m, company_name, website_url):
url_to_extract = page_from_m.url # Bestätigte URL verwenden
self.logger.info(f" -> Vorhandene URL aus M '{url_to_extract}' ist valide und wird verwendet.")
else:
self.logger.warning(f" -> Vorhandene URL aus M '{page_from_m.title}' ist NICHT valide. Starte neue Suche...")
search_was_needed = True # Suche ist nötig
except (wikipedia.exceptions.PageError, wikipedia.exceptions.DisambiguationError) as e_wiki_m:
# Wenn die URL in M zu einem nicht existierenden Artikel oder einer Begriffsklärung führt
self.logger.warning(f" -> Vorhandene URL aus M '{url_in_m}' führt zu Fehler ({type(e_wiki_m).__name__}). Starte neue Suche...")
search_was_needed = True # Suche ist nötig
# Logge die Disambiguation Optionen auf Debug
if isinstance(e_wiki_m, wikipedia.exceptions.DisambiguationError):
self.logger.debug(f" -> Disambiguation Optionen: {e_wiki_m.options[:10]}...")
except Exception as e_val_m:
# Andere Fehler beim Prüfen der URL aus M (z.B. URL-Parsing-Fehler vor wikipedia.page)
self.logger.exception(f" -> Unerwarteter Fehler beim Prüfen der URL aus M '{url_in_m}': {e_val_m}. Starte neue Suche...")
search_was_needed = True # Suche ist nötig
else: # M ist leer/ungültig und AN fehlt -> Suche starten
self.logger.info(f" -> AN fehlt und M leer/ungültig. Starte Wikipedia-Suche für '{company_name}'...")
search_was_needed = True # Suche ist nötig
# --- Führe die Suche aus, wenn search_was_needed True ist ---
if search_was_needed:
self.logger.debug(f" -> Führe Wikipedia Suche über scraper durch...")
try:
# Nutzt den retry_on_failure Decorator der search_company_article Methode
validated_page = self.wiki_scraper.search_company_article(company_name, website_url) # Nutze ggf. neue Website URL
if validated_page:
url_to_extract = validated_page.url # Setze die gefundene und validierte URL
self.logger.info(f" -> Suche erfolgreich, validierte URL: {url_to_extract}")
else:
# Suche fand keinen validierten Artikel
self.logger.warning(f" -> Suche fand keinen validierten Artikel für '{company_name}'.")
url_to_extract = 'Kein Artikel gefunden' # Signalisiert kein Artikel gefunden
except Exception as e_wiki_search:
self.logger.error(f"FEHLER bei Wikipedia Suche für '{company_name}': {e_wiki_search}")
url_to_extract = 'FEHLER bei Suche' # Signalisiert Fehler bei Suche
pass # Fahren Sie fort, um zumindest den Status zu setzen
# --- Datenextraktion, wenn eine URL bestimmt wurde, von der extrahiert werden soll ---
# Extrahiere Daten, wenn url_to_extract einen Wert hat, der NICHT "Kein Artikel gefunden" oder "FEHLER bei Suche" ist
if url_to_extract and url_to_extract not in ['Kein Artikel gefunden', 'FEHLER bei Suche']:
self.logger.debug(f" -> Extrahiere Daten von URL: {url_to_extract}...")
try:
# Nutzt den retry_on_failure Decorator der extract_company_data Methode
extracted_data = self.wiki_scraper.extract_company_data(url_to_extract)
if extracted_data and extracted_data.get('url') != 'k.A.': # Prüfe auf gültige Extraktion
final_wiki_data = extracted_data # Aktualisiere die Arbeitskopie der Wiki-Daten
wiki_data_updated_in_this_run = True # Markieren, dass extrahierte Daten da sind
self.logger.info(f" -> Datenextraktion von {url_to_extract} erfolgreich.")
else:
self.logger.error(f" -> Fehler bei Datenextraktion von {url_to_extract} oder Extraktion war leer. Setze Daten auf 'k.A.'")
# Behalte die URL, aber setze alle anderen Felder auf k.A.
final_wiki_data = {'url': url_to_extract, 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}
wiki_data_updated_in_this_run = True # Markieren, dass überschrieben wird
except Exception as e_wiki_extract:
self.logger.error(f"FEHLER bei Wikipedia Datenextraktion von {url_to_extract}: {e_wiki_extract}")
# Setze Daten auf k.A., behalte aber die URL, von der extrahiert werden sollte
final_wiki_data = {'url': url_to_extract, 'first_paragraph': 'k.A. (Fehler Extraktion)', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}
wiki_data_updated_in_this_run = True # Markieren, dass überschrieben wird
pass # Fahren Sie fort
# --- Sheet Updates für M-R und AN ---
# Diese Updates werden immer hinzugefügt, wenn der WIKI-Schritt lief,
# auch wenn die Suche/Extraktion fehlschlug (dann werden k.A. oder Fehlermeldungen geschrieben).
# Aktualisiere die Spalten M-R mit den finalen Daten
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki URL"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('url', 'k.A.')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Absatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('first_paragraph', 'k.A.')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Branche"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('branche', 'k.A.')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('umsatz', 'k.A.')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('mitarbeiter', 'k.A.')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Kategorien"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('categories', 'k.A.')]]})
# Setze den Wikipedia Timestamp (AN), da der Wiki-Schritt lief
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wikipedia Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]})
# --- Setze S ('Chat Wiki Konsistenzprüfung') und AX ('Wiki Verif. Timestamp') zurück, wenn Neubewertung nötig ist ---
# Eine Neubewertung (Zurücksetzen von S und AX) ist nötig, wenn:
# - force_reeval True ist (immer bei Re-Eval des Wiki-Schritts)
# - Status S zuvor "X (URL Copied)" war (der Trigger für die Re-Extraktion)
# - Die neue URL in M (final_wiki_data['url']) anders ist als die ursprüngliche URL aus M (url_in_m)
# Dies stellt sicher, dass die Verifizierung (die in einem separaten Schritt/Batch laufen kann)
# nach der Datenextraktion erneut angestoßen wird.
# Prüfen, ob das Zurücksetzen von S und AX überhaupt notwendig ist
url_changed = (url_in_m != final_wiki_data.get('url')) # Prüft ob die NEUE URL anders ist als die ursprünglich in M
if force_reeval or status_s_indicates_reparse or url_changed:
s_idx = COLUMN_MAP.get("Chat Wiki Konsistenzprüfung")
ax_idx = COLUMN_MAP.get("Wiki Verif. Timestamp")
if s_idx is not None and ax_idx is not None:
s_let = self.sheet_handler._get_col_letter(s_idx + 1)
ax_let = self.sheet_handler._get_col_letter(ax_idx + 1)
# Füge die Updates zum Zurücksetzen von S und AX hinzu
# S wird auf '?' gesetzt, um anzuzeigen, dass eine Verifizierung aussteht
updates.append({'range': f'{s_let}{row_num_in_sheet}', 'values': [["?"]]})
# AX wird geleert, um die Batch-Verifizierung zu triggern
updates.append({'range': f'{ax_let}{row_num_in_sheet}', 'values': [[""]]})
# Bestimme den Grund-String für das Logging
grund_message_parts = []
if force_reeval: grund_message_parts.append('Re-Eval')
if status_s_indicates_reparse: grund_message_parts.append("S='X (URL Copied)'")
if url_changed: grund_message_parts.append('URL geändert')
grund_message_s_reset = ", ".join(grund_message_parts)
self.logger.info(f" -> Status S zurückgesetzt auf '?' und Timestamp AX geleert für erneute Verifikation (Grund: {grund_message_s_reset}).")
else:
self.logger.error("FEHLER: Konnte Spaltenbuchstaben für S oder AX nicht ermitteln, Zurücksetzen übersprungen.")
elif run_wiki_step: # Wiki Schritt war ausgewählt, aber nicht nötig basierend auf Status/Re-Eval
self.logger.debug(f"Zeile {row_num_in_sheet}: Überspringe WIKI Suche/Extraktion (AN vorhanden, S nicht 'X (URL Copied)' und kein Re-Eval).")
# Wenn der Schritt übersprungen wird, verwenden wir die vorhandenen Wiki-Daten aus dem Sheet,
# die bereits zu Beginn der Methode geladen wurden (current_wiki_data).
# final_wiki_data behält ihre initialen Werte.
# --- Der Code für den nächsten Verarbeitungsschritt (ChatGPT Evaluationen) folgt im nächsten Teil ---
# Definiton der Methode _process_single_row wird in der nächsten Nachricht fortgesetzt.
# --- 3. ChatGPT Evaluationen (Branch, FSM, Emp, Umsatz Schätzungen etc.) ---
# Dieser Schritt wird ausgeführt, wenn 'chatgpt' (oder 'chat' je nach gewählten Schritt-Keys)
# in steps_to_run enthalten ist UND (_needs_chat_evaluations True ist ODER force_reeval True ist).
# _needs_chat_evaluations prüft AO oder ob Wiki-Daten in diesem Lauf aktualisiert wurden.
# Annahme: Der Key für diese Gruppe in steps_to_run ist 'chatgpt' oder 'chat'
# Wir verwenden 'chat' wie im Plan vorgeschlagen
run_chat_step = 'chat' in steps_to_run
# _needs_chat_evaluations nutzt den lokalen Flag wiki_data_updated_in_this_run
chat_processing_needed_based_on_status = self._needs_chat_evaluations(row_data, force_reeval, wiki_data_updated_in_this_run)
if run_chat_step and chat_processing_needed_based_on_status:
any_processing_done = True # Markiere, dass in dieser Zeile etwas getan wird
# Bestimme den Grund für die Ausführung dieses Schritts für das Logging
grund_message_parts = []
if force_reeval: grund_message_parts.append('Re-Eval')
if not self._get_cell_value_safe(row_data, "Timestamp letzte Prüfung").strip(): grund_message_parts.append('AO leer')
if wiki_data_updated_in_this_run: grund_message_parts.append('Wiki Daten gerade aktualisiert')
grund_message = ", ".join(grund_message_parts)
self.logger.info(f"Zeile {row_num_in_sheet}: Führe CHATGPT Evaluationen aus (Grund: {grund_message})...")
# Hole die notwendigen Daten (nutze die finalen Werte aus den vorherigen Schritten)
# crm_branche, crm_beschreibung wurden initial geladen
# final_wiki_data wurde im Wiki-Schritt aktualisiert oder behält alte Werte
# website_summary wurde im Website-Schritt aktualisiert oder behält alte Werte
# --- 3a. Branchen-Einstufung (W, X, Y) ---
self.logger.debug(" -> Starte Branchen-Einstufung via ChatGPT...")
try:
# Annahme: evaluate_branche_chatgpt global definiert oder als Methode eines OpenAICreator (hier global/utils)
# evaluate_branche_chatgpt braucht Zugriff auf ALLOWED_TARGET_BRANCHES und TARGET_SCHEMA_STRING (global)
# Nutzt den globalen retry_on_failure Decorator
branch_result = evaluate_branche_chatgpt( # Annahme: evaluate_branche_chatgpt in utils.py
crm_branche,
crm_beschreibung,
final_wiki_data.get('branche', 'k.A.'), # Nutze ggf. neue Wiki-Branche
final_wiki_data.get('categories', 'k.A.'), # Nutze ggf. neue Wiki-Kategorien
website_summary # Nutze ggf. neue Website-Zusammenfassung
)
# Sammle Updates für die Branchen-Spalten
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('branch', 'FEHLER')]]}) # Fallback auf FEHLER
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenz Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('consistency', 'error')]]}) # Fallback auf error
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('justification', 'Keine Begründung')]]}) # Fallback
except Exception as e_branch_eval:
self.logger.error(f"FEHLER bei Branchen-Einstufung via ChatGPT für Zeile {row_num_in_sheet}: {e_branch_eval}")
# Füge Updates mit Fehlermeldung hinzu, um den Fehler im Sheet zu dokumentieren
error_msg = f"Fehler: {e_branch_eval}"[:200] # Kürze Fehlermeldung
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER']]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenz Branche"] + 1)}{row_num_in_sheet}', 'values': [['error']]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Branche"] + 1)}{row_num_in_sheet}', 'values': [[error_msg]]})
pass # Fahren Sie fort mit den nächsten Schritten
# --- 3b. FSM Relevanz Bewertung (Z, AA) ---
# TODO: Implementieren Sie die Logik und den Aufruf der Funktion
self.logger.debug(" -> Starte FSM Relevanz Bewertung (Platzhalter)...")
# Beispielaufruf (angenommen, Funktion evaluate_fsm_suitability existiert und liefert dict):
# fsm_result = evaluate_fsm_suitability(
# company_name,
# {'crm_desc': crm_beschreibung, 'wiki_paragraph': final_wiki_data.get('first_paragraph', 'k.A.'), 'web_summary': website_summary}
# )
# updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Prüfung FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[fsm_result.get('suitability', 'k.A.')]]})
# updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung für FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[fsm_result.get('justification', 'k.A.')]]})
# pass # Fahren Sie fort, auch wenn FSM Eval nicht implementiert ist
# --- 3c. Mitarbeiterzahl Schätzung (AB, AC, AD) ---
# TODO: Implementieren Sie die Logik und den Aufruf der Funktion
self.logger.debug(" -> Starte Mitarbeiterzahl Schätzung (Platzhalter)...")
# Beispielaufruf (angenommen, evaluate_employee_chatgpt existiert):
# emp_estimate_result = evaluate_employee_chatgpt(
# company_name,
# {'crm_emp': self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter"), 'wiki_emp': final_wiki_data.get('mitarbeiter', 'k.A.'), 'wiki_paragraph': final_wiki_data.get('first_paragraph', 'k.A.'), 'web_summary': website_summary}
# )
# updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schätzung Anzahl Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[emp_estimate_result.get('estimate', 'k.A.')]]})
# updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenzprüfung Mitarbeiterzahl"] + 1)}{row_num_in_sheet}', 'values': [[emp_estimate_result.get('consistency', 'k.A.')]]})
# updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Mitarbeiterzahl"] + 1)}{row_num_in_sheet}', 'values': [[emp_estimate_result.get('justification', 'k.A.')]]})
# pass # Fahren Sie fort
# --- 3d. Umsatz Schätzung (AG, AH) ---
# TODO: Implementieren Sie die Logik und den Aufruf der Funktion
self.logger.debug(" -> Starte Umsatz Schätzung (Platzhalter)...")
# Beispielaufruf (angenommen, evaluate_umsatz_chatgpt existiert):
# umsatz_estimate_result = evaluate_umsatz_chatgpt(
# company_name,
# {'crm_umsatz': self._get_cell_value_safe(row_data, "CRM Umsatz"), 'wiki_umsatz': final_wiki_data.get('umsatz', 'k.A.'), 'wiki_paragraph': final_wiki_data.get('first_paragraph', 'k.A.'), 'web_summary': website_summary}
# )
# updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schätzung Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[umsatz_estimate_result.get('estimate', 'k.A.')]]})
# updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[umsatz_estimate_result.get('justification', 'k.A.')]]})
# pass # Fahren Sie fort
# --- 3e. Konsolidierung Umsatz/Mitarbeiter (AV, AW) ---
# Diese Logik wurde bisher in prepare_data_for_modeling verwendet,
# kann aber auch hier nach jeder Zeilenverarbeitung durchgeführt und
# ins Sheet geschrieben werden, um die konsolidierten Werte aktuell zu halten.
self.logger.debug(" -> Konsolidiere Umsatz (AV) und Mitarbeiter (AW) (Wiki > CRM Logik)...")
try:
# Nutze get_valid_numeric (globale Hilfsfunktion)
crm_umsatz_val = get_valid_numeric(self._get_cell_value_safe(row_data, "CRM Umsatz"))
wiki_umsatz_val = get_valid_numeric(final_wiki_data.get('umsatz', 'k.A.')) # Nutze finalen Wiki-Wert
final_umsatz = str(int(wiki_umsatz_val)) if pd.notna(wiki_umsatz_val) else (str(int(crm_umsatz_val)) if pd.notna(crm_umsatz_val) else 'k.A.')
crm_ma_val = get_valid_numeric(self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter"))
wiki_ma_val = get_valid_numeric(final_wiki_data.get('mitarbeiter', 'k.A.')) # Nutze finalen Wiki-Wert
final_ma = str(int(wiki_ma_val)) if pd.notna(wiki_ma_val) else (str(int(crm_ma_val)) if pd.notna(crm_ma_val) else 'k.A.')
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Umsatz (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [[final_umsatz]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Mitarbeiter (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [[final_ma]]})
self.logger.debug(f" -> Konsolidiert: Umsatz={final_umsatz}, MA={final_ma}")
except Exception as e_consolidate:
self.logger.error(f"FEHLER bei Konsolidierung Umsatz/Mitarbeiter für Zeile {row_num_in_sheet}: {e_consolidate}")
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Umsatz (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER']]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Mitarbeiter (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER']]})
pass # Fahren Sie fort
# --- 3f. Servicetechniker Schätzung (ML Modell) (AU) ---
# Dieser Schritt erfordert das trainierte ML-Modell und Imputer.
# Die Schätzung sollte nur ausgeführt werden, wenn das Modell geladen werden kann
# UND konsolidierte MA/Umsatz Werte verfügbar sind (AV, AW).
# Dies ist komplexer und könnte in einer separaten Methode besser aufgehoben sein.
# Die Methode könnte prüfen, ob das Modell geladen ist und die Schätzung durchführen.
# result_bucket = self._predict_technician_bucket(final_umsatz, final_ma, final_branche_aus_chatgpt_eval)
# updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Geschätzter Techniker Bucket"] + 1)}{row_num_in_sheet}', 'values': [[result_bucket]]})
self.logger.debug(" -> Starte Servicetechniker Schätzung (ML) (Platzhalter)...")
pass # Fahren Sie fort
# Setze den Timestamp letzte Prüfung (AO), da die ChatGPT-Evaluationen liefen
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Timestamp letzte Prüfung"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]})
elif run_chat_step: # ChatGPT Schritt war ausgewählt, aber nicht nötig basierend auf Status/Re-Eval/Wiki-Update
self.logger.debug(f"Zeile {row_num_in_sheet}: Überspringe CHATGPT Evaluationen (AO vorhanden, Wiki nicht aktualisiert und kein Re-Eval).")
# Wenn der Schritt übersprungen wird, behalten website_summary und final_wiki_data ihre initialen Werte.
# --- Der Code für die abschließenden Updates (Version, Tokens, Batch Write) folgt im nächsten Teil ---
# Definiton der Methode _process_single_row wird in der nächsten Nachricht fortgesetzt.
# --- 4. Servicetechniker Schätzung (ML Modell) (AU) ---
# Dieser Schritt wird ausgeführt, wenn 'ml_predict' in steps_to_run enthalten ist UND
# es nötig ist (z.B. AU ist leer ODER AO wurde gerade gesetzt UND AU ist leer ODER Re-Eval).
# Die Logik, ob dieser Schritt nötig ist, ist spezifisch und könnte hier oder in einer Helper-Methode geprüft werden.
# Für die Einfachheit der _process_single_row Logik hier prüfen wir nur das Flag.
# Die Notwendigkeit (AU leer, AO frisch etc.) muss ggf. vom Aufrufer (z.B. Batch-Modus) gehandhabt werden.
# Alternativ: Fügen Sie eine Helper-Methode wie _needs_ml_prediction(row_data, force_reeval, chat_just_ran) hinzu.
# Annahme: Der Key für diesen Schritt ist 'ml_predict'
run_ml_step = 'ml_predict' in steps_to_run
# Für den ML-Schritt ist es am sinnvollsten, ihn auszuführen, wenn
# A) force_reeval gesetzt ist, ODER
# B) Der "Timestamp letzte Prüfung" (AO) gerade gesetzt wurde (chat_processing_needed_based_on_status ist True) UND der AU Bucket noch leer ist.
# C) Der AU Bucket explizit leer ist und der Modus "ml_predict" ausgewählt wurde.
# Wir prüfen hier nur das Flag run_ml_step. Die komplexere Logik zur Notwendigkeit kann
# entweder vor dem Aufruf von _process_single_row (in Batch/Sequentiell) geschehen,
# ODER eine eigene _needs_ml_prediction Methode wird hier verwendet.
# Lassen wir es hier einfach: Wenn 'ml_predict' angefordert, versuche es.
if run_ml_step: # Prüfe nur, ob der Schritt im aktuellen Lauf angefordert wurde
# Prüfe zusätzlich, ob benötigte Daten (AV, AW) vorhanden sind
final_umsatz = self._get_cell_value_safe(row_data, "Finaler Umsatz (Wiki>CRM)").strip()
final_ma = self._get_cell_value_safe(row_data, "Finaler Mitarbeiter (Wiki>CRM)").strip()
if final_umsatz != 'k.A.' and final_ma != 'k.A.':
any_processing_done = True # Markiere, dass in dieser Zeile etwas getan wird
self.logger.info(f"Zeile {row_num_in_sheet}: Führe ML-Schätzung aus...")
try:
# Annahme: _predict_technician_bucket Methode existiert in DataProcessor
# Diese Methode muss das geladene Modell/Imputer nutzen, Daten vorbereiten und vorhersagen
predicted_bucket = self._predict_technician_bucket(row_data) # Methode braucht raw_data für alle Spalten
if predicted_bucket:
# Sammle Update für den AU Bucket
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Geschätzter Techniker Bucket"] + 1)}{row_num_in_sheet}', 'values': [[predicted_bucket]]})
self.logger.info(f" -> ML-Schätzung erfolgreich: Bucket '{predicted_bucket}'.")
else:
self.logger.warning(f" -> ML-Schätzung lieferte kein Ergebnis.")
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Geschätzter Techniker Bucket"] + 1)}{row_num_in_sheet}', 'values': [['k.A. (Schätzung fehlgeschlagen)']]})
except Exception as e_ml:
self.logger.error(f"FEHLER bei ML-Schätzung für Zeile {row_num_in_sheet}: {e_ml}")
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Geschätzter Techniker Bucket"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER Schätzung']]})
pass # Fahren Sie fort
else:
self.logger.debug(f"Zeile {row_num_in_sheet}: Überspringe ML-Schätzung, da konsolidierter Umsatz/Mitarbeiter fehlt (AV/AW='k.A.').")
# --- 5. Abschließende Updates (Version, Tokens) ---
# Version (AP) wird gesetzt, wenn IRGENDEINE Verarbeitung in dieser Zeile stattgefunden hat
if any_processing_done:
# Annahme: Config.VERSION ist verfügbar
version_col_idx = COLUMN_MAP.get("Version")
if version_col_idx is not None:
updates.append({'range': f'{self.sheet_handler._get_col_letter(version_col_idx + 1)}{row_num_in_sheet}', 'values': [[Config.VERSION]]})
else:
self.logger.error("FEHLER: Spaltenschlüssel 'Version' nicht in COLUMN_MAP gefunden.")
# Tokens (AQ) - Hier ist die Zählung komplex, da mehrere OpenAI-Calls passiert sein könnten.
# Eine einfache Lösung ist, die Token-Zahl der letzten relevanten Antwort zu speichern
# oder die Token-Zahl der Prompts/Antworten während des Laufs zu aggregieren.
# Eine Aggregation in den einzelnen Schritten (Web Summary, Branch Eval etc.) wäre genauer.
# Als Platzhalter: Zählen Sie die Tokens der Website Summary (AS) und der Branch Begründung (Y)
# oder überspringen Sie es erstmal hier und implementieren es in den einzelnen Schritten.
# Überspringen wir es hier und implementieren Token-Zählung in den spezifischen OpenAI-Call-Methoden.
# Wenn der Token-Count in einer der OpenAI-Call-Methoden implementiert wird,
# muss er dort gesammelt und dann HIER in _process_single_row ins Update eingefügt werden.
# Beispiel: Sie könnten self.current_row_token_count am Anfang auf 0 setzen
# und in jeder Methode, die call_openai_chat nutzt, += zum Token-Count addieren.
# Dann hier:
# tokens_col_idx = COLUMN_MAP.get("Tokens")
# if tokens_col_idx is not None:
# updates.append({'range': f'{self.sheet_handler._get_col_letter(tokens_col_idx + 1)}{row_num_in_sheet}', 'values': [[str(self.current_row_token_count)]]})
# else: self.logger.error("FEHLER: Spaltenschlüssel 'Tokens' nicht in COLUMN_MAP gefunden.")
pass # Token-Zählung Implementierung verschoben
# --- 5b. ReEval Flag (A) löschen (nur wenn im Re-Eval Modus und gewünscht) ---
# Dieses Update wird am Ende hinzugefügt, wenn die Verarbeitung erfolgreich (oder zumindest versucht) wurde
# und der Aufrufer (process_reevaluation_rows) dies angefordert hat.
if force_reeval and clear_x_flag:
# Ermitteln Sie den Index der ReEval Flag Spalte
reeval_col_idx = COLUMN_MAP.get("ReEval Flag")
if reeval_col_idx is not None:
flag_col_letter = self.sheet_handler._get_col_letter(reeval_col_idx + 1)
if flag_col_letter:
# Fügen Sie das Update zum Löschen des 'x'-Flags zur Liste hinzu
# Es wird nur gelöscht, wenn die Zeile ansonsten erfolgreich bis hierhin kam.
# Wenn eine schwere Exception in _process_single_row auftrat, wird diese Zeile nicht erreicht.
updates.append({'range': f'{flag_col_letter}{row_num_in_sheet}', 'values': [['']]})
self.logger.debug(f" -> Update zum Löschen des ReEval-Flags (A{row_num_in_sheet}) vorgemerkt.")
else:
self.logger.error(f"FEHLER: Konnte Spaltenbuchstaben für 'ReEval Flag' ({reeval_col_idx+1}) nicht ermitteln. Flag kann nicht gelöscht werden.")
else:
self.logger.error("FEHLER: 'ReEval Flag' Spaltenindex nicht in COLUMN_MAP gefunden. Flag kann nicht gelöscht werden.")
# --- 6. Batch Update für diese Zeile ---
# Führen Sie das Batch-Update für alle gesammelten Änderungen dieser Zeile durch.
if updates:
# Info-Log über die Anzahl der Updates für diese spezifische Zeile
self.logger.info(f"Zeile {row_num_in_sheet}: Sende Batch-Update mit {len(updates)} Operationen für diese Zeile...")
# self.sheet_handler.batch_update_cells nutzt logging intern und den retry_on_failure Decorator.
success = self.sheet_handler.batch_update_cells(updates)
if not success:
self.logger.error(f"Zeile {row_num_in_sheet}: ENDGÜLTIGER FEHLER beim Batch-Update nach Retries.")
# Hier könnten Sie einen Fehlerindikator in eine spezielle Spalte schreiben
else:
# Info-Log, wenn nichts zu tun war in dieser Zeile
if not any_processing_done:
self.logger.debug(f"Zeile {row_num_in_sheet}: Keine Updates zum Schreiben (alle relevanten Schritte übersprungen oder nicht angefordert).")
# else: # Dieser Fall sollte nicht eintreten, wenn updates nicht leer ist
# self.logger.warning(f"Zeile {row_num_in_sheet}: Updates Liste war leer, aber any_processing_done=True. Prüfen Sie die Logik.")
# Kleine Pause nach der Verarbeitung jeder Zeile, um API-Limits zu respektieren
# und die Belastung für das Google Sheet zu reduzieren.
# Der Wert sollte in Config angepasst werden. Eine kurze Pause ist auch bei Batch-Modi sinnvoll.
pause_duration = max(0.05, getattr(Config, 'RETRY_DELAY', 5) / 20.0) # Mindestens 50ms
# self.logger.debug(f"Wartezeit nach Zeile {row_num_in_sheet}: {pause_duration:.2f}s") # Zu viel Lärm im Debug
time.sleep(pause_duration)
self.logger.info(f"--- Verarbeitung für Zeile {row_num_in_sheet} abgeschlossen ---")
# --- Ende der _process_single_row Methode ---
# --- Die nächsten Methoden der DataProcessor Klasse folgen in den nächsten Teilen ---
# z.B. process_reevaluation_rows method... (kommt in Teil 10)
# process_rows_sequentially method... (kommt später)
# Batch-Methoden... (kommen später)
# Utility-Methoden... (kommen später)
# --- Methode für den Re-Eval Modus (Spalte A = 'x') ---
# Diese Methode gehört in die Klasse DataProcessor.
def process_reevaluation_rows(self, row_limit=None, clear_flag=True,
process_wiki_steps=True,
process_chatgpt_steps=True,
process_website_steps=True):
"""
Verarbeitet nur Zeilen, die in Spalte A mit 'x' markiert sind.
Ruft _process_single_row für jede dieser Zeilen auf mit force_reeval=True.
Verarbeitet maximal row_limit Zeilen.
Löscht optional das 'x'-Flag nach erfolgreicher Verarbeitung.
Erlaubt die Auswahl spezifischer Verarbeitungsschritte.
Args:
row_limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None.
clear_flag (bool, optional): Flag 'x' nach erfolgreicher Verarbeitung löschen. Defaults to True.
process_wiki_steps (bool, optional): Soll der Wiki-Schritt in _process_single_row ausgeführt werden?. Defaults to True.
process_chatgpt_steps (bool, optional): Sollen ChatGPT-Schritte in _process_single_row ausgeführt werden?. Defaults to True.
process_website_steps (bool, optional): Soll der Website-Schritt in _process_single_row ausgeführt werden?. Defaults to True.
# Fügen Sie hier ggf. weitere Parameter hinzu, wenn Sie granularere Schritte in _process_single_row haben.
"""
self.logger.info(f"Starte Re-Evaluierungsmodus (Spalte A = 'x'). Max. Zeilen: {row_limit if row_limit is not None else 'Unbegrenzt'}")
# Logge, welche Schritte für Re-Eval ausgewählt wurden
selected_steps_log = []
if process_wiki_steps: selected_steps_log.append("Wiki (wiki)")
if process_chatgpt_steps: selected_steps_log.append("ChatGPT (chat)")
if process_website_steps: selected_steps_log.append("Website (web)")
self.logger.info(f"Ausgewählte Schritte für Re-Eval: {', '.join(selected_steps_log) if selected_steps_log else 'Keine ausgewählt!'}")
# Erstelle das Set der Schritte, die an _process_single_row übergeben werden
steps_to_run_set = set()
if process_wiki_steps: steps_to_run_set.add('wiki')
if process_chatgpt_steps: steps_to_run_set.add('chat') # Annahme: 'chat' triggert alle ChatGPT Schritte in _process_single_row
if process_website_steps: steps_to_run_set.add('web')
if not steps_to_run_set:
self.logger.warning("Keine Verarbeitungsschritte für Re-Eval ausgewählt. Modus wird übersprungen.")
return
# Daten neu laden vor der Verarbeitung
if not self.sheet_handler.load_data():
self.logger.error("Fehler beim Laden der Daten für Re-Evaluation.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = 5 # Annahme
if not all_data or len(all_data) <= header_rows:
self.logger.warning("Keine Daten für Re-Evaluation gefunden.")
return
# Ermitteln Sie den Index der ReEval Flag Spalte
reeval_col_idx = COLUMN_MAP.get("ReEval Flag")
if reeval_col_idx is None:
self.logger.critical("FEHLER: 'ReEval Flag' Spaltenindex nicht in COLUMN_MAP gefunden.")
return
# Sammeln Sie die Zeilen, die mit 'x' markiert sind
rows_to_process = []
for idx_in_list in range(header_rows, len(all_data)):
row_data = all_data[idx_in_list]
row_num_in_sheet = idx_in_list + 1 # 1-basierte Zeilennummer im Sheet
# Prüfen Sie sicher auf den Wert 'x' in Spalte A
cell_a_value = self._get_cell_value_safe(row_data, "ReEval Flag").strip().lower()
if cell_a_value == "x":
rows_to_process.append({'row_num': row_num_in_sheet, 'data': row_data})
found_count = len(rows_to_process)
self.logger.info(f"{found_count} Zeilen mit ReEval-Flag 'x' gefunden.")
if found_count == 0:
self.logger.info("Keine Zeilen zur Re-Evaluation markiert.")
return
# Verarbeitung der markierten Zeilen
processed_count = 0
#updates_clear_flag = [] # Updates zum Löschen des 'x'-Flags
rows_actually_processed = [] # Liste der Zeilen, die tatsächlich verarbeitet wurden
for task in rows_to_process:
# Überprüfen Sie das Limit VOR der Verarbeitung
if row_limit is not None and processed_count >= row_limit:
self.logger.info(f"Zeilenlimit ({row_limit}) für Re-Evaluation erreicht. Breche weitere Verarbeitung ab.")
break
row_num = task['row_num']
row_data = task['data'] # Die Rohdaten für diese Zeile
self.logger.info(f"Bearbeite Re-Eval Zeile {row_num}...")
try:
# RUFE _process_single_row AUF mit force_reeval=True und dem ausgewählten steps_to_run_set
self._process_single_row(
row_num_in_sheet = row_num,
row_data = row_data,
steps_to_run = steps_to_run_set, # <-- Übergibt die ausgewählten Schritte
force_reeval = True, # <-- Erzwingt Re-Evaluation unabhängig von Timestamps
clear_x_flag = clear_flag # <-- ÜBERGIBT, OB DAS FLAG GELÖSCHT WERDEN SOLL
)
processed_count += 1 # Nur zählen, wenn _process_single_row keine Exception geworfen hat
rows_actually_processed.append(row_num) # Nur Zeilen hinzufügen, die erfolgreich an _process_single_row übergeben wurden
# Vorbereiten des Updates zum Löschen des 'x'-Flags (falls gewünscht)
#if clear_flag:
# flag_col_letter = self.sheet_handler._get_col_letter(reeval_col_idx + 1)
# if flag_col_letter:
# updates_clear_flag.append({'range': f'{flag_col_letter}{row_num}', 'values': [['']]})
# else:
# self.logger.error(f"Fehler: Konnte Spaltenbuchstaben für 'ReEval Flag' ({reeval_col_idx+1}) nicht ermitteln.")
except Exception as e_proc:
# Wenn _process_single_row einen Fehler wirft, fangen wir ihn hier, loggen ihn
# und fahren mit der nächsten Zeile fort. Das 'x'-Flag wird in diesem Fall NICHT gelöscht.
self.logger.exception(f"FEHLER bei Re-Evaluation von Zeile {row_num}: {e_proc}")
# Hier könnten Sie einen Fehlerindikator in eine spezielle Spalte schreiben
# Lösche Flags am Ende in einem Batch-Update
#if clear_flag and updates_clear_flag:
# self.logger.info(f"Lösche ReEval-Flags für {len(updates_clear_flag)} erfolgreich verarbeitete Zeilen ({rows_actually_processed})...")
# # Nutzen Sie die batch_update_cells Methode des Sheet Handlers
# success = self.sheet_handler.batch_update_cells(updates_clear_flag)
# if success:
# self.logger.info("ReEval-Flags erfolgreich gelöscht.")
# else:
# self.logger.error("FEHLER beim Löschen der ReEval-Flags nach Re-Evaluation.")
self.logger.info(f"Re-Evaluierung abgeschlossen. {processed_count} Zeilen verarbeitet (Gefunden: {found_count}, Limit: {row_limit}).")
# --- Die nächste Methode der DataProcessor Klasse folgt in der nächsten Nachricht ---
# z.B. process_rows_sequentially method... (kommt in Teil 11)
# --- Methode für sequenzielle Verarbeitung (full_run) ---
# Diese Methode gehört in die Klasse DataProcessor.
def process_rows_sequentially(self, start_sheet_row, num_to_process,
process_wiki_steps=True,
process_chatgpt_steps=True,
process_website_steps=True,
# Fügen Sie hier ggf. weitere boolsche Flags für andere Schrittgruppen hinzu
force_reeval_in_single_row=False): # Optionale Steuerung für _process_single_row
"""
Verarbeitet eine feste Anzahl von Zeilen beginnend bei einer bestimmten
Sheet-Zeilennummer sequenziell, eine nach der anderen, unter Verwendung
von _process_single_row.
Args:
start_sheet_row (int): Die 1-basierte Zeilennummer im Sheet, ab der gestartet werden soll.
num_to_process (int): Die maximale Anzahl der zu verarbeitenden Zeilen.
process_wiki_steps (bool, optional): Soll der Wiki-Schritt in _process_single_row ausgeführt werden?. Defaults to True.
process_chatgpt_steps (bool, optional): Sollen ChatGPT-Schritte in _process_single_row ausgeführt werden?. Defaults to True.
process_website_steps (bool, optional): Soll der Website-Schritt in _process_single_row ausgeführt werden?. Defaults to True.
# Fügen Sie hier ggf. weitere boolsche Flags für andere Schrittgruppen hinzu.
force_reeval_in_single_row (bool, optional): Erzwingt force_reeval=True in _process_single_row
für alle verarbeiteten Zeilen in diesem Lauf. Defaults to False.
"""
header_rows = 5 # Annahme
# Prüfen Sie, ob num_to_process gültig ist
if num_to_process is None or num_to_process <= 0:
self.logger.info("Sequenzielle Verarbeitung übersprungen: num_to_process ist 0 oder None.")
return
self.logger.info(f"Starte sequenzielle Verarbeitung von {num_to_process} Zeilen ab Sheet-Zeile {start_sheet_row}...")
self.logger.info(f" Ausgewählte Schritte: Wiki={process_wiki_steps}, ChatGPT={process_chatgpt_steps}, Website={process_website_steps}")
if force_reeval_in_single_row: self.logger.warning(" !!! force_reeval=True wird für alle Zeilen in _process_single_row gesetzt !!!")
# Lade Daten einmalig vor der Verarbeitung
if not self.sheet_handler.load_data():
self.logger.error("Fehler beim Laden der Daten für sequenzielle Verarbeitung.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
total_sheet_rows = len(all_data)
# Berechnen Sie den tatsächlichen Start-Index in der all_data Liste (0-basiert)
start_index_in_all_data = start_sheet_row - 1
if start_index_in_all_data >= total_sheet_rows:
self.logger.warning(f"Start-Sheet-Zeile {start_sheet_row} (Index {start_index_in_all_data}) liegt außerhalb der verfügbaren Daten ({total_sheet_rows} Zeilen insgesamt). Keine Verarbeitung.")
return
if start_index_in_all_data < header_rows:
self.logger.warning(f"Start-Sheet-Zeile {start_sheet_row} liegt innerhalb der Header-Zeilen ({header_rows} Header). Verarbeitung startet ab Sheet-Zeile {header_rows + 1}.")
start_index_in_all_data = header_rows # Starten Sie nach den Headern
# Berechne den tatsächlichen End-Index in der all_data Liste (exklusiv)
# end_index_in_all_data = start_index_in_all_data + num_to_process
# Der End-Index sollte auch die Gesamtanzahl der Zeilen nicht überschreiten
end_index_in_all_data = min(start_index_in_all_data + num_to_process, total_sheet_rows)
self.logger.info(f"Sequenzielle Verarbeitung: Sheet-Zeilen (1-basiert) von {start_index_in_all_data + 1} bis {end_index_in_all_data}. (Verarbeite max {end_index_in_all_data - start_index_in_all_data} Zeilen)")
if start_index_in_all_data >= end_index_in_all_data:
self.logger.info("Berechneter Start liegt bei oder nach dem berechneten Ende. Keine Zeilen zu verarbeiten.")
return
# Erstelle das Set der Schritte, die an _process_single_row übergeben werden
steps_to_run_set = set()
if process_wiki_steps: steps_to_run_set.add('wiki')
if process_chatgpt_steps: steps_to_run_set.add('chat')
if process_website_steps: steps_to_run_set.add('web')
# Fügen Sie hier weitere Schritte hinzu, falls granularere Flags verwendet werden
if not steps_to_run_set:
self.logger.warning("Keine Verarbeitungsschritte für sequenziellen Lauf ausgewählt. Modus wird übersprungen.")
return
processed_count = 0
# Iteriere über die Zeilen im angegebenen Bereich (0-basiert)
for i in range(start_index_in_all_data, end_index_in_all_data):
row_num_in_sheet = i + 1 # 1-basierte Zeilennummer im Sheet
row_data = all_data[i] # Tatsächliche Zeilendaten aus der Gesamtliste
# Überspringen Sie Header-Zeilen explizit, falls der Startindex fälschlicherweise <= header_rows war
if row_num_in_sheet <= header_rows:
self.logger.debug(f"Überspringe Header-Zeile {row_num_in_sheet}.")
continue
# Stellen Sie sicher, dass die Zeile nicht leer ist oder nur aus leeren Strings besteht
if not any(cell and cell.strip() for cell in row_data):
self.logger.debug(f"Überspringe scheinbar leere Zeile {row_num_in_sheet}.")
continue
try:
# Rufe die Methode zur Verarbeitung einer einzelnen Zeile auf
# _process_single_row wird intern die Timestamps prüfen (außer force_reeval)
self._process_single_row(
row_num_in_sheet = row_num_in_sheet,
row_data = row_data,
steps_to_run = steps_to_run_set, # <-- Übergibt die ausgewählten Schritte
force_reeval = force_reeval_in_single_row # <-- Steuert force_reeval in _process_single_row
)
processed_count += 1 # Zählen, wenn _process_single_row erfolgreich aufgerufen wurde (unabhängig von internen Überspringungen)
except Exception as e_proc:
# Logge den spezifischen Fehler für diese Zeile, fahre aber mit der nächsten fort
self.logger.exception(f"FEHLER bei sequenzieller Verarbeitung von Zeile {row_num_in_sheet}: {e_proc}")
# Hier könnten Sie einen Fehlerindikator in eine spezielle Spalte schreiben
self.logger.info(f"Sequenzielle Verarbeitung abgeschlossen. {processed_count} Zeilen im Bereich [{start_sheet_row}, {end_index_in_all_data}] bearbeitet.")
# --- Die nächsten Batch-Methoden der DataProcessor Klasse folgen in den nächsten Teilen ---
# process_verification_batch method... (kommt in Teil 12)
# process_website_scraping_batch method... (kommt in Teil 12)
# process_summarization_batch method... (kommt in Teil 12)
# process_branch_batch method... (kommt in Teil 13)
# process_find_wiki_serp method... (kommt in Teil 13)
# process_contact_search method... (kommt in Teil 13)
# process_wiki_updates_from_chatgpt method... (kommt in Teil 14)
# process_wiki_reextract_missing_an method... (kommt in Teil 14)
# ==========================================================================
# === Batch Processing Methods =============================================
# ==========================================================================
# --- Interne Hilfsfunktion für Wiki-Verifizierungs-Batch (OpenAI Call) ---
# Übernommen aus Ihrem Code (_process_batch in Teil 8), angepasst als Methode.
# Diese Methode ist spezifisch für den Wiki-Verifizierungs-Batchmodus.
@retry_on_failure # Anwenden des Decorators, da hier call_openai_chat aufgerufen wird
def _process_verification_openai_batch(self, batch_data):
"""
Verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen über OpenAI.
Sammelt die Ergebnisse und gibt sie zurück. Aktualisiert NICHT das Sheet direkt.
Args:
batch_data (list): Liste von Dictionaries, jedes enthält:
{'row_num': int, 'company_name': str, 'crm_desc': str,
'wiki_url': str, 'wiki_paragraph': str, 'wiki_categories': str}
Returns:
dict: Ein Dictionary, das Zeilennummern auf die rohe ChatGPT-Antwort mappt.
z.B. {2122: "OK", 2123: "X | ..."}
Bei Fehlern oder fehlenden Antworten wird ein Fehlerstring verwendet.
"""
if not batch_data:
return {}
self.logger.debug(f"Sende OpenAI-Batch für Wiki-Verifizierung ({len(batch_data)} Zeilen, Start: {batch_data[0]['row_num']})...")
# --- Prompt Erstellung ---
aggregated_prompt = (
"Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln für Unternehmen. "
"Für jeden der folgenden Einträge prüfe, ob der vorhandene Wikipedia-Artikel (URL, Absatz, Kategorien) plausibel zum Firmennamen und zur Beschreibung passt. "
"Gib das Ergebnis für jeden Eintrag ausschließlich im folgenden Format auf einer neuen Zeile aus:\n"
"Eintrag <Zeilennummer>: <Antwort>\n\n"
"Mögliche Antworten:\n"
"- 'OK' (wenn der Artikel gut passt)\n"
"- 'X | Alternativer Artikel: <URL> | Begründung: <Kurze Begründung>' (wenn der Artikel nicht passt, aber ein besserer gefunden wurde)\n"
"- 'X | Kein passender Artikel gefunden | Begründung: <Kurze Begründung>' (wenn der Artikel nicht passt und kein besserer gefunden wurde)\n"
# Der Fall "Kein Wikipedia-Eintrag vorhanden" wird vom Skript VOR diesem Call behandelt
# und sollte hier nicht vom KI-Modell generiert werden.
"Stelle sicher, dass du nur EINE Zeile pro Eintrag im Format 'Eintrag X: Antwort' ausgibst.\n\n"
"Einträge zur Prüfung:\n"
"--------------------\n"
)
# Fügen Sie die Daten für jeden Eintrag im Batch hinzu
for item in batch_data:
row_num = item['row_num']
# Kürze die Beschreibungen und Kategorien, um das Prompt-Limit zu reduzieren
crm_desc_short = item.get('crm_desc', 'k.A.')[:200] + '...' if len(item.get('crm_desc', '')) > 200 else item.get('crm_desc', 'k.A.')
wiki_paragraph_short = item.get('wiki_paragraph', 'k.A.')[:200] + '...' if len(item.get('wiki_paragraph', '')) > 200 else item.get('wiki_paragraph', 'k.A.')
wiki_categories_short = item.get('wiki_categories', 'k.A.')[:200] + '...' if len(item.get('wiki_categories', '')) > 200 else item.get('wiki_categories', 'k.A.')
entry_text = (
f"Eintrag {row_num}:\n"
f" Firmenname: {item.get('company_name', 'k.A.')}\n"
f" CRM-Beschreibung: {crm_desc_short}\n"
f" Wikipedia-URL: {item.get('wiki_url', 'k.A.')}\n"
f" Wiki-Absatz: {wiki_paragraph_short}\n"
f" Wiki-Kategorien: {wiki_categories_short}\n"
f"----\n"
)
aggregated_prompt += entry_text
aggregated_prompt += "--------------------\nBitte nur die 'Eintrag X: Antwort'-Zeilen ausgeben."
# Token Count für den Prompt (nutzt globale Funktion)
prompt_tokens = token_count(aggregated_prompt)
self.logger.debug(f"Geschätzte Token-Zahl für Wiki-Verifizierungs-Batch: {prompt_tokens}")
# --- ChatGPT Aufruf ---
try:
# Annahme: call_openai_chat global definiert oder als Methode (hier global/utils)
# Nutzt den retry_on_failure Decorator
chat_response = call_openai_chat(aggregated_prompt, temperature=0.0) # Niedrige Temperatur für konsistente Antworten
if not chat_response:
self.logger.error(f"Keine Antwort von OpenAI für Verifizierungs-Batch {batch_data[0]['row_num']}-{batch_data[-1]['row_num']}.")
# Geben Sie ein Dictionary zurück, das signalisiert, dass für alle Zeilen ein Fehler aufgetreten ist
return {item['row_num']: "FEHLER: Keine Antwort von OpenAI" for item in batch_data}
# --- Antwort parsen ---
# Das Parsen erfolgt hier und die Ergebnisse werden zurückgegeben.
# Das Sheet-Update erfolgt in der aufrufenden Methode.
answers = {}
lines = chat_response.strip().split('\n')
parsed_count = 0
for line in lines:
# Regulärer Ausdruck, um "Eintrag <Nummer>:" zu finden und den Rest der Zeile zu erfassen
match = re.match(r"Eintrag (\d+): (.*)", line.strip())
if match:
row_num = int(match.group(1))
answer_text = match.group(2).strip()
# Prüfen, ob die Zeilennummer im ursprünglichen Batch enthalten war
if any(item['row_num'] == row_num for item in batch_data):
answers[row_num] = answer_text
parsed_count += 1
# else:
# self.logger.debug(f"Warnung: Antwort für unerwartete Zeilennummer {row_num} im Batch erhalten: {answer_text}") # Zu viel Lärm
self.logger.debug(f"OpenAI-Batch-Antwort geparst: {parsed_count} von {len(batch_data)} Zeilen erfolgreich zugeordnet.")
if parsed_count < len(batch_data):
self.logger.warning(f"Nicht alle Zeilen aus dem Batch ({len(batch_data)}) konnten in der OpenAI-Antwort ({len(lines)} Zeilen) geparst werden.")
self.logger.debug(f"Unerwartete Antwortteile (erste 500 Zeichen): {chat_response[:500]}")
# Geben Sie für fehlende Zeilen einen Fehlerwert zurück
for item in batch_data:
if item['row_num'] not in answers:
answers[item['row_num']] = "FEHLER: Antwort nicht geparst"
# Wenn die Antwort geparst werden konnte (auch wenn nicht alle Zeilen geparst wurden)
return answers
except Exception as e:
# Jeder Fehler, der nicht vom Decorator gefangen und wiederholt wurde, wird hier geloggt.
# Der Decorator wirft bei endgültigem Scheitern eine Exception, die hier gefangen wird.
self.logger.error(f"Endgültiger FEHLER beim OpenAI-Aufruf für Wiki-Verifizierungs-Batch: {e}")
# Geben Sie ein Dictionary zurück, das signalisiert, dass für alle Zeilen im Batch ein Fehler aufgetreten ist
return {item['row_num']: f"FEHLER API: {str(e)[:100]}" for item in batch_data}
# --- Methode für den Wiki-Verifizierungs-Batchmodus (AX) ---
# Übernommen aus process_verification_only in Teil 8, angepasst als Methode.
def process_verification_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None):
"""
Batch-Prozess nur für Wikipedia-Verifizierung (Spalten S-Y).
Lädt Daten neu, prüft für jede Zeile im Bereich, ob Timestamp AX (Wiki Verif.)
bereits gesetzt ist, ob eine Wiki URL (M) vorhanden ist und ob Status S
nicht bereits 'OK', 'X (URL Copied)' oder 'X (Invalid Suggestion)' ist.
Setzt AX für bearbeitete Zeilen und schreibt S-Y in Batches.
Args:
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung).
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht übersprungener) Zeilen. Defaults to None.
"""
self.logger.info(f"Starte Wikipedia-Verifizierungsmodus (Batch). Bereich: {start_sheet_row}-{end_sheet_row}, Limit: {limit if limit is not None else 'Unbegrenzt'}...")
# --- Daten laden ---
# Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt
if start_sheet_row is None:
self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AX...")
# Nutzt get_start_row_index des Sheet Handlers
start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Wiki Verif. Timestamp")
if start_data_index_no_header == -1:
self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche ab.")
return
start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1
self.logger.info(f"Automatisch ermittelte Startzeile (erste leere AX Zelle): {start_sheet_row}")
else:
# Daten trotzdem neu laden, um aktuell zu sein
if not self.sheet_handler.load_data():
self.logger.error("FEHLER beim Laden der Daten für process_verification_batch.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = self.sheet_handler._header_rows
total_sheet_rows = len(all_data)
# Berechne Endzeile, wenn nicht gesetzt oder wenn Limit aktiv ist
if end_sheet_row is None:
end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
# Das Limit wird bei der Iteration unten angewendet
self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}")
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.")
return
# --- Indizes und Buchstaben ---
# Stellen Sie sicher, dass alle benötigten Spalten in COLUMN_MAP vorhanden sind
required_keys = [
"Wiki Verif. Timestamp", "Wiki URL", "Chat Wiki Konsistenzprüfung", # Prüfkriterien / Timestamp
"CRM Name", "CRM Beschreibung", "Wiki Absatz", "Wiki Kategorien", # Daten für Prompt
"Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", # Ergebnisspalten (T, U)
]
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
if None in col_indices.values():
missing = [k for k, v in col_indices.items() if v is None]
self.logger.critical(f"FEHLER: Benötigte Spaltenschlüssel fehlen in COLUMN_MAP für process_verification_batch: {missing}. Breche ab.")
return
# Spaltenbuchstaben für Updates
ts_ax_letter = self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"] + 1)
s_letter = self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"] + 1)
t_letter = self.sheet_handler._get_col_letter(col_indices["Chat Begründung Wiki Inkonsistenz"] + 1)
u_letter = self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"] + 1)
# Spalten V-Y leeren (werden in diesem Modus nicht neu befüllt)
v_idx = COLUMN_MAP.get("Begründung bei Abweichung") # V ist Index 21
y_idx = COLUMN_MAP.get("Chat Begründung Abweichung Branche") # Y ist Index 24
if v_idx is None or y_idx is None:
self.logger.error("FEHLER: Indizes für Spalten V oder Y fehlen in COLUMN_MAP. Kann V-Y nicht leeren.")
# Gehen Sie weiter, da dies kein kritischer Fehler ist, aber loggen Sie es.
v_y_range_letter = None
else:
v_letter = self.sheet_handler._get_col_letter(v_idx + 1)
y_letter = self.sheet_handler._get_col_letter(y_idx + 1)
v_y_range_letter = f'{v_letter}:{y_letter}' # z.B. V:Y
# --- Verarbeitung ---
batch_size = getattr(Config, 'PROCESSING_BATCH_SIZE', 20) # Nutze die Batch-Größe aus Config
current_batch_data = [] # Daten für den aktuellen OpenAI Batch
rows_in_current_batch = [] # Zeilennummern für den aktuellen OpenAI Batch
all_sheet_updates = [] # Gesammelte Updates für Batch-Schreiben
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) # Nutze die Update-Batch-Größe aus Config
processed_count = 0 # Zählt Zeilen, die im Batch verarbeitet wurden
skipped_count = 0 # Zählt Zeilen, die übersprungen wurden
skipped_no_wiki_url = 0 # Zählt Zeilen, die wegen fehlender M-URL übersprungen wurden
# Iteriere über die Sheet-Zeilen im definierten Bereich
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht
row = all_data[row_index_in_list]
# Stellen Sie sicher, dass die Zeile nicht leer ist (mindestens Name vorhanden)
company_name = self._get_cell_value_safe(row, "CRM Name").strip()
if not company_name:
self.logger.debug(f"Zeile {i}: Übersprungen (Kein Firmenname).")
skipped_count += 1
continue
# --- Prüfung, ob Verarbeitung für diese Zeile nötig ist ---
# Kriterium: AX ist leer UND Wiki URL (M) ist gefüllt UND Status S ist NICHT Endzustand.
ax_value = self._get_cell_value_safe(row, "Wiki Verif. Timestamp").strip()
m_value = self._get_cell_value_safe(row, "Wiki URL").strip()
s_value_upper = self._get_cell_value_safe(row, "Chat Wiki Konsistenzprüfung").strip().upper()
is_wiki_url_valid_looking = m_value and m_value.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche"] # Prüfe, ob M eine gültige URL sein könnte
is_s_in_endstate = s_value_upper in ["OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)"] # Endzustände von S
# Verarbeitung nötig, wenn AX leer UND M gefüllt/gültig aussieht UND S NICHT im Endzustand ist
processing_needed_for_row = not ax_value and is_wiki_url_valid_looking and not is_s_in_endstate
# Loggen der Prüfergebnisse für diese Zeile auf Debug
log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
if log_check:
self.logger.debug(f"Zeile {i} (Wiki Verif. Check): AX leer? {not ax_value}, M gültig? {is_wiki_url_valid_looking}, S ('{s_value_upper}') Endzustand? {is_s_in_endstate}. Benötigt Verarbeitung? {processing_needed_for_row}")
if not processing_needed_for_row:
skipped_count += 1
if not is_wiki_url_valid_looking: skipped_no_wiki_url += 1 # Zähle separat, wenn M leer/ungültig war
continue
# --- Wenn Verarbeitung nötig: Füge zur Batch-Liste hinzu ---
processed_count += 1 # Zähle die Zeile, die verarbeitet wird (zum Limit zählen)
# Prüfe das Limit für verarbeitete Zeilen
if limit is not None and processed_count > limit:
self.logger.info(f"Verarbeitungslimit ({limit}) für process_verification_batch erreicht. Breche weitere Zeilenprüfung ab.")
break # Schleife abbrechen
# Sammle die benötigten Daten für den OpenAI Prompt
crm_desc = self._get_cell_value_safe(row, "CRM Beschreibung")
wiki_paragraph = self._get_cell_value_safe(row, "Wiki Absatz")
wiki_categories = self._get_cell_value_safe(row, "Wiki Kategorien")
current_batch_data.append({
'row_num': i,
'company_name': company_name,
'crm_desc': crm_desc,
'wiki_url': m_value,
'wiki_paragraph': wiki_paragraph,
'wiki_categories': wiki_categories
})
rows_in_current_batch.append(i) # Sammle Zeilennummer
# --- Verarbeite den Batch, wenn voll ---
if len(current_batch_data) >= batch_size:
self.logger.debug(f"\n--- Starte Wiki-Verifizierungs-Batch ({len(current_batch_data)} Tasks, Zeilen {rows_in_current_batch[0]}-{rows_in_current_batch[-1]}) ---")
# Rufe die interne Methode auf, die den OpenAI Call macht
batch_results = self._process_verification_openai_batch(current_batch_data)
# Sammle Sheet Updates basierend auf den Batch-Ergebnissen
# Setze immer den Timestamp AX und die Werte in S, T, U
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
batch_sheet_updates = []
for row_num in rows_in_current_batch:
answer = batch_results.get(row_num, "FEHLER: Batch-Ergebnis fehlt") # Fallback
# self.logger.debug(f"Zeile {row_num} Verifizierungsantwort: '{answer}'") # Zu viel Lärm
# Logik zur Bestimmung der Werte für S, T, U basierend auf 'answer' (wie in _process_batch)
wiki_confirm, alt_article, wiki_explanation = "", "", ""
# Spalten V-Y (Begründung bei Abweichung etc.) werden in diesem Modus geleert
v_y_values = [''] * (y_idx - v_idx + 1) if v_y_range_letter else []
if answer.upper() == "OK":
wiki_confirm = "OK"
wiki_explanation = "Passt laut KI zur Firma." # Standard Begründung
elif answer.startswith("X |"):
parts = answer.split("|", 2)
wiki_confirm = "X"
if len(parts) > 1:
detail = parts[1].strip()
if detail.startswith("Alternativer Artikel:"): alt_article = detail.split(":", 1)[1].strip()
elif detail == "Kein passender Artikel gefunden": alt_article = detail
else: alt_article = detail # Unbekanntes Detail
if len(parts) > 2:
reason_part = parts[2].strip()
if reason_part.startswith("Begründung:"): wiki_explanation = reason_part.split(":", 1)[1].strip()
else: wiki_explanation = reason_part # Unbekannte Begründung
# Füge ggf. den rohen Antworttext zur Begründung hinzu, wenn Parsing unvollständig war
if not alt_article or not wiki_explanation:
wiki_explanation += f" (Rohantwort: {answer[:100]}...)"
# else if answer == "FEHLER: Keine Antwort von OpenAI" etc.:
elif answer.startswith("FEHLER"):
wiki_confirm = "FEHLER"
wiki_explanation = answer # Fehlermeldung in Begründung schreiben
alt_article = "Siehe Begründung"
else: # Unerwartetes Format
wiki_confirm = "?"
wiki_explanation = f"Unerwartetes Format: {answer}"
alt_article = "Siehe Begründung"
# Füge Updates für S, T, U und AX hinzu
batch_sheet_updates.append({'range': f'{s_letter}{row_num}', 'values': [[wiki_confirm]]})
batch_sheet_updates.append({'range': f'{t_letter}{row_num}', 'values': [[alt_article]]})
batch_sheet_updates.append({'range': f'{u_letter}{row_num}', 'values': [[wiki_explanation]]})
batch_sheet_updates.append({'range': f'{ts_ax_letter}{row_num}', 'values': [[current_timestamp]]}) # Setze AX Timestamp
# Füge Update zum Leeren von V-Y hinzu, falls Index gefunden wurde
if v_y_range_letter:
batch_sheet_updates.append({'range': f'{v_letter}{row_num}:{y_letter}{row_num}', 'values': [v_y_values]})
# --- Sende gesammelte Updates für diesen Batch ---
if batch_sheet_updates:
self.logger.debug(f" Sende Sheet-Update für {len(rows_in_current_batch)} Zeilen ({len(batch_sheet_updates)} Zellen)...")
# Nutzt die batch_update_cells Methode des Sheet Handlers mit Retry
success = self.sheet_handler.batch_update_cells(batch_sheet_updates)
if success:
self.logger.info(f" Sheet-Update für Wiki-Verifizierungs-Batch {rows_in_current_batch[0]}-{rows_in_current_batch[-1]} erfolgreich.")
# Der Fehlerfall wird von batch_update_cells geloggt
# Setze Batch-Listen zurück
current_batch_data = []
rows_in_current_batch = []
# Pause nach jedem Batch-API-Call
# Nutze Config.RETRY_DELAY, ggf. kürzer, da es ein Batch war
pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.5 # 50% der Retry-Wartezeit
self.logger.debug(f"Warte {pause_duration:.2f}s vor nächstem Batch...")
time.sleep(pause_duration)
# --- Verarbeitung des letzten unvollständigen Batches nach der Schleife ---
if current_batch_data:
self.logger.debug(f"\n--- Starte FINALEN Wiki-Verifizierungs-Batch ({len(current_batch_data)} Tasks, Zeilen {rows_in_current_batch[0]}-{rows_in_current_batch[-1]}) ---")
batch_results = self._process_verification_openai_batch(current_batch_data)
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
batch_sheet_updates = []
for row_num in rows_in_current_batch:
answer = batch_results.get(row_num, "FEHLER: Batch-Ergebnis fehlt")
wiki_confirm, alt_article, wiki_explanation = "", "", ""
v_y_values = [''] * (y_idx - v_idx + 1) if v_y_range_letter else []
if answer.upper() == "OK": wiki_confirm = "OK"; wiki_explanation = "Passt laut KI zur Firma."
elif answer.startswith("X |"):
parts = answer.split("|", 2); wiki_confirm = "X"
if len(parts) > 1: detail = parts[1].strip(); alt_article = detail.split(":", 1)[1].strip() if detail.startswith("Alternativer Artikel:") else detail
if len(parts) > 2: reason_part = parts[2].strip(); wiki_explanation = reason_part.split(":", 1)[1].strip() if reason_part.startswith("Begründung:") else reason_part
if not alt_article or not wiki_explanation: wiki_explanation += f" (Rohantwort: {answer[:100]}...)"
elif answer.startswith("FEHLER"): wiki_confirm = "FEHLER"; wiki_explanation = answer; alt_article = "Siehe Begründung"
else: wiki_confirm = "?"; wiki_explanation = f"Unerwartetes Format: {answer}"; alt_article = "Siehe Begründung"
batch_sheet_updates.append({'range': f'{s_letter}{row_num}', 'values': [[wiki_confirm]]})
batch_sheet_updates.append({'range': f'{t_letter}{row_num}', 'values': [[alt_article]]})
batch_sheet_updates.append({'range': f'{u_letter}{row_num}', 'values': [[wiki_explanation]]})
batch_sheet_updates.append({'range': f'{ts_ax_letter}{row_num}', 'values': [[current_timestamp]]})
if v_y_range_letter: batch_sheet_updates.append({'range': f'{v_letter}{row_num}:{y_letter}{row_num}', 'values': [v_y_values]})
if batch_sheet_updates:
self.logger.debug(f" Sende FINALES Sheet-Update für {len(rows_in_current_batch)} Zeilen ({len(batch_sheet_updates)} Zellen)...")
success = self.sheet_handler.batch_update_cells(batch_sheet_updates)
if success: self.logger.info(f" FINALES Sheet-Update für Wiki-Verifizierungs-Batch erfolgreich.")
self.logger.info(f"Wikipedia-Verifizierungs-Batch abgeschlossen. {processed_count} Zeilen verarbeitet, {skipped_count} Zeilen übersprungen ({skipped_no_wiki_url} wegen fehlender M-URL).")
# --- Die nächsten Batch-Methoden der DataProcessor Klasse folgen in den nächsten Teilen ---
# process_website_batch method... (kommt in Teil 13)
# process_summarization_batch method... (kommt in Teil 13)
# process_branch_batch method... (kommt in Teil 14)
# process_find_wiki_serp method... (kommt in Teil 14)
# process_contact_search method... (kommt in Teil 14)
# process_wiki_updates_from_chatgpt method... (kommt in Teil 15)
# process_wiki_reextract_missing_an method... (kommt in Teil 15)
# --- Methode für den Website-Scraping-Batchmodus (AR) ---
# Übernommen aus process_website_batch in Teil 9, angepasst als Methode.
def process_website_scraping_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None):
"""
Batch-Prozess NUR für Website-Scraping (Rohtext AR).
Lädt Daten neu, prüft Spalte AR auf Inhalt ('', 'k.A.', etc.) und überspringt Zeilen mit Inhalt.
Setzt AR + AT + AP für bearbeitete Zeilen. Sendet Updates gebündelt.
Args:
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung).
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht übersprungener) Zeilen. Defaults to None.
"""
self.logger.info(f"Starte Website-Scraping (Batch) für Rohtext (AR). Bereich: {start_sheet_row}-{end_sheet_row}, Limit: {limit if limit is not None else 'Unbegrenzt'}...")
# --- Daten laden ---
# Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt
if start_sheet_row is None:
self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AT...")
# Nutzt get_start_row_index des Sheet Handlers. Prüft auf leeren AT.
start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Website Scrape Timestamp")
if start_data_index_no_header == -1:
self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche ab.")
return
start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1
self.logger.info(f"Automatisch ermittelte Startzeile (erste leere AT Zelle): {start_sheet_row}")
else:
# Daten trotzdem neu laden, um aktuell zu sein
if not self.sheet_handler.load_data():
self.logger.error("FEHLER beim Laden der Daten für process_website_scraping_batch.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = self.sheet_handler._header_rows
total_sheet_rows = len(all_data)
# Berechne Endzeile, wenn nicht gesetzt
if end_sheet_row is None:
end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}")
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.")
return
# --- Indizes und Buchstaben ---
required_keys = [
"Website Rohtext", "CRM Website", "Version", "Website Scrape Timestamp", "CRM Name"
]
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
if None in col_indices.values():
missing = [k for k, v in col_indices.items() if v is None]
self.logger.critical(f"FEHLER: Benötigte Spaltenschlüssel fehlen in COLUMN_MAP für process_website_scraping_batch: {missing}. Breche ab.")
return
rohtext_col_idx = col_indices["Website Rohtext"]
website_col_idx = col_indices["CRM Website"]
version_col_idx = col_indices["Version"]
timestamp_col_idx = col_indices["Website Scrape Timestamp"]
name_col_idx = col_indices["CRM Name"]
rohtext_col_letter = self.sheet_handler._get_col_letter(rohtext_col_idx + 1)
version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1)
timestamp_col_letter = self.sheet_handler._get_col_letter(timestamp_col_idx + 1)
# --- Worker-Funktion für Scraping ---
# Diese Funktion läuft in einem separaten Thread
def scrape_raw_text_task(task_info):
row_num = task_info['row_num']
url = task_info['url']
raw_text = "k.A."
error = None
try:
# Nutzt die globale Funktion get_website_raw mit Retry Decorator
raw_text = get_website_raw(url) # Annahme: get_website_raw in utils.py
except Exception as e:
# Fängt Fehler beim Scraping, damit der Thread nicht abstürzt
error = f"Scraping Fehler Zeile {row_num} ({url}): {e}"
self.logger.error(error)
raw_text = "k.A. (Fehler)" # Setze einen Fehlerwert in den Rohtext
#logger.debug(f"Scraping Task Zeile {row_num} abgeschlossen. Textlänge: {len(str(raw_text))}.") # Zu viel Lärm
return {"row_num": row_num, "raw_text": raw_text, "error": error}
# --- Hauptlogik: Iteriere und sammle Batches ---
tasks_for_processing_batch = [] # Tasks für den aktuellen Scraping-Batch
rows_in_current_scraping_batch = [] # Zeilennummern im aktuellen Batch
all_sheet_updates = [] # Gesammelte Updates für Batch-Schreiben
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) # Nutze die Update-Batch-Größe aus Config
# --- Worker-Funktion für Scraping (intern) ---
# Diese Funktion wird vom ThreadPoolExecutor aufgerufen und hat Zugriff auf den umgebenden Scope.
def scrape_raw_text_task(task_info):
"""
Scrapt den Rohtext einer Website in einem separaten Thread.
Wird vom ThreadPoolExecutor in process_website_scraping_batch aufgerufen.
Nutzt die globale Funktion get_website_raw.
Args:
task_info (dict): Enthält {'row_num': int, 'url': str}.
Returns:
dict: Enthält {'row_num': int, 'raw_text': str, 'error': str}.
"""
row_num = task_info['row_num']
url = task_info['url']
raw_text = "k.A."
error = None
try:
# RUFT die globale Funktion get_website_raw auf.
# Der retry_on_failure Decorator auf get_website_raw behandelt Retries und Fehler.
raw_text = get_website_raw(url) # <<< Hier wird die globale Funktion aufgerufen
# Wenn get_website_raw einen Fehler loggt und einen Fehlerstring zurückgibt,
# wird dies im Ergebnisdict als raw_text gespeichert.
# Wir können hier prüfen, ob der raw_text einen Fehlerwert signalisiert.
if str(raw_text).startswith("k.A. (Fehler") or str(raw_text).startswith("FEHLER:"):
error = f"Scraping Fehler (Details im Rohtext): {raw_text[:100]}..."
# self.logger.error(error) # Wird bereits in get_website_raw geloggt
pass # Fehler wurde bereits im Rückgabewert signalisiert
except Exception as e:
# Dieser Block sollte jetzt seltener erreicht werden, da get_website_raw
# die meisten Fehler intern fängt und mit retry behandelt.
# Wenn eine Exception hier durchkommt, ist es ein unerwarteter Fehler im Task-Handling selbst.
error = f"Unerwarteter Fehler im Scraping Task Zeile {row_num} ({url}): {type(e).__name__} - {e}"
# self.logger.error(error) # Loggen Sie diesen unerwarteten Fehler
raw_text = "k.A. (Unerwarteter Fehler Task)" # Setze einen spezifischen Fehlerwert
# logger.debug(f"Scraping Task Zeile {row_num} abgeschlossen. Textlänge: {len(str(raw_text))}.") # Zu viel Lärm
return {"row_num": row_num, "raw_text": raw_text, "error": error}
# --- Hauptlogik: Iteriere und sammle Batches ---
processed_count = 0 # Zählt Zeilen, die im Batch verarbeitet (versucht) wurden
skipped_count = 0 # Zählt Zeilen, die übersprungen wurden (wegen Inhalt oder fehlender URL)
# Iteriere über die Sheet-Zeilen im definierten Bereich
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht
row = all_data[row_index_in_list]
# Stellen Sie sicher, dass die Zeile nicht leer ist
if not any(cell and cell.strip() for cell in row):
#self.logger.debug(f"Zeile {i}: Übersprungen (Leere Zeile).") # Zu viel Lärm
skipped_count += 1
continue
# --- Prüfung, ob Verarbeitung für diese Zeile nötig ist ---
# Kriterium: Website Rohtext (AR) ist leer oder "k.A." etc.
# UND Website URL (D) ist vorhanden und nicht "k.A.".
# Prüfe Website Rohtext (AR) auf Inhalt
cell_value_ar = self._get_cell_value_safe(row, "Website Rohtext")
ar_is_empty_or_default = not cell_value_ar or str(cell_value_ar).strip().lower() in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]
# Prüfe Website URL (D) auf Inhalt
website_url = self._get_cell_value_safe(row, "CRM Website").strip()
website_url_is_valid_looking = website_url and website_url.lower() not in ["k.a.", "kein artikel gefunden"]
# Verarbeitung nötig, wenn AR leer UND D gefüllt
processing_needed_for_row = ar_is_empty_or_default and website_url_is_valid_looking
# Loggen der Prüfergebnisse auf Debug
log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
if log_check:
self.logger.debug(f"Zeile {i} (Website Scraping Check): AR leer/default? {ar_is_empty_or_default}, D gültig? {website_url_is_valid_looking}. Benötigt Verarbeitung? {processing_needed_for_row}")
if not processing_needed_for_row:
skipped_count += 1
continue
# --- Wenn Verarbeitung nötig: Füge zur Batch-Liste hinzu ---
processed_count += 1 # Zähle die Zeile, die verarbeitet wird (zum Limit zählen)
# Prüfe das Limit für verarbeitete Zeilen
if limit is not None and processed_count > limit:
self.logger.info(f"Verarbeitungslimit ({limit}) für process_website_scraping_batch erreicht. Breche weitere Zeilenprüfung ab.")
break # Schleife abbrechen
tasks_for_processing_batch.append({"row_num": i, "url": website_url})
rows_in_current_scraping_batch.append(i) # Sammle Zeilennummer
# --- Verarbeite den Batch, wenn voll ---
scraping_batch_size = getattr(Config, 'PROCESSING_BATCH_SIZE', 20) # Batch-Größe aus Config
max_scraping_workers = getattr(Config, 'MAX_SCRAPING_WORKERS', 10) # Max Worker aus Config
if len(tasks_for_processing_batch) >= scraping_batch_size:
batch_start_row = tasks_for_processing_batch[0]['row_num']
batch_end_row = tasks_for_processing_batch[-1]['row_num']
self.logger.debug(f"\n--- Starte Website-Scraping Batch ({len(tasks_for_processing_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---")
scraping_results = {}
batch_error_count = 0 # Fehlerzähler für diesen spezifischen Batch
self.logger.debug(f" Scrape {len(tasks_for_processing_batch)} Websites parallel (max {max_scraping_workers} worker)...")
# Nutzt concurrent.futures für paralleles Scraping
with concurrent.futures.ThreadPoolExecutor(max_workers=max_scraping_workers) as executor:
# Map tasks to futures
future_to_task = {executor.submit(scrape_raw_text_task, task): task for task in tasks_for_processing_batch}
#future_to_task = {executor.submit(_scrape_raw_text_task_global, task): task for task in tasks_for_processing_batch} # Auf globalen Namen geändert
# Process results as they complete
for future in concurrent.futures.as_completed(future_to_task):
task = future_to_task[future] # Get the original task data
try:
result = future.result() # Get the result from the future
scraping_results[result['row_num']] = result['raw_text']
if result['error']: # Check if the worker reported an error
batch_error_count += 1
except Exception as exc:
# This block catches unexpected errors during future result retrieval
row_num = task['row_num']
err_msg = f"Generischer Fehler Scraping Task Zeile {row_num} ({task['url']}): {exc}"
self.logger.error(err_msg)
scraping_results[row_num] = "k.A. (Fehler Task)" # Set a default error value
batch_error_count += 1
self.logger.debug(f" Scraping für Batch beendet. {len(scraping_results)} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).")
# Sheet Updates vorbereiten (AR, AT, AP) für diesen Batch
if scraping_results:
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
current_version = getattr(Config, 'VERSION', 'unknown')
batch_sheet_updates = []
for row_num, raw_text_res in scraping_results.items():
# Updates für AR, AT und AP
batch_sheet_updates.append({'range': f'{rohtext_col_letter}{row_num}', 'values': [[raw_text_res]]})
batch_sheet_updates.append({'range': f'{timestamp_col_letter}{row_num}', 'values': [[current_timestamp]]}) # Setze AT Timestamp
batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Setze AP Version
# Sammle diese Batch-Updates für das größere Batch-Update am Ende oder bei Limit
all_sheet_updates.extend(batch_sheet_updates)
# Leere den Scraping-Batch
tasks_for_processing_batch = []
rows_in_current_scraping_batch = []
# Sende gesammelte Sheet Updates, wenn das Update-Batch-Limit erreicht ist
# Wir prüfen die Anzahl der *Zeilen*, für die Updates gesammelt wurden
# Updates pro Zeile sind 3 (AR, AT, AP). all_sheet_updates.extend fügt 3 Einträge pro Zeile hinzu.
# Anzahl der Zeilen = len(all_sheet_updates) / 3
rows_in_update_batch = len(all_sheet_updates) // 3 # Ganzzahl-Division
if rows_in_update_batch >= update_batch_row_limit:
self.logger.debug(f" Sende gesammelte Sheet-Updates ({rows_in_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...")
# Nutzt die batch_update_cells Methode des Sheet Handlers mit Retry
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
if success:
self.logger.info(f" Sheet-Update für {rows_in_update_batch} Zeilen erfolgreich.")
# Der Fehlerfall wird von batch_update_cells geloggt
# Leere die gesammelten Updates nach dem Senden
all_sheet_updates = []
# rows_in_update_batch muss nicht explizit zurückgesetzt werden, da es aus len(all_sheet_updates) berechnet wird.
# Keine Pause hier nach jedem kleinen Scraping-Batch, da wir auf batch_update warten.
# Die Pause kommt erst nach dem Batch-Update.
# --- Verarbeitung des letzten unvollständigen Scraping-Batches nach der Schleife ---
# Führe den letzten Batch aus, wenn noch Tasks vorhanden sind
if tasks_for_processing_batch:
batch_start_row = tasks_for_processing_batch[0]['row_num']
batch_end_row = tasks_for_processing_batch[-1]['row_num']
self.logger.debug(f"\n--- Starte FINALEN Website-Scraping Batch ({len(tasks_for_processing_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---")
scraping_results = {}
batch_error_count = 0
self.logger.debug(f" Scrape {len(tasks_for_processing_batch)} Websites parallel (max {max_scraping_workers} worker)...")
with concurrent.futures.ThreadPoolExecutor(max_workers=max_scraping_workers) as executor:
future_to_task = {executor.submit(scrape_raw_text_task, task): task for task in tasks_for_processing_batch}
for future in concurrent.futures.as_completed(future_to_task):
task = future_to_task[future]
try:
result = future.result()
scraping_results[result['row_num']] = result['raw_text']
if result['error']: batch_error_count += 1
except Exception as exc:
row_num = task['row_num']
err_msg = f"Generischer Fehler Scraping Task Zeile {row_num} ({task['url']}): {exc}"
self.logger.error(err_msg)
scraping_results[row_num] = "k.A. (Fehler Task)"
batch_error_count += 1
self.logger.debug(f" FINALER Scraping Batch beendet. {len(scraping_results)} Ergebnisse erhalten ({batch_error_count} Fehler).")
if scraping_results:
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
current_version = getattr(Config, 'VERSION', 'unknown')
batch_sheet_updates = []
for row_num, raw_text_res in scraping_results.items():
batch_sheet_updates.append({'range': f'{rohtext_col_letter}{row_num}', 'values': [[raw_text_res]]})
batch_sheet_updates.append({'range': f'{timestamp_col_letter}{row_num}', 'values': [[current_timestamp]]})
batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]})
all_sheet_updates.extend(batch_sheet_updates)
# --- Finale Sheet Updates senden ---
# Sende alle verbleibenden gesammelten Updates
if all_sheet_updates:
rows_in_final_update_batch = len(all_sheet_updates) // 3 # Ganzzahl-Division
self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({rows_in_final_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...")
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
if success:
self.logger.info(f"FINALES Sheet-Update erfolgreich.")
# Der Fehlerfall wird von batch_update_cells geloggt
self.logger.info(f"Website-Scraping (Batch) abgeschlossen. {processed_count} Zeilen verarbeitet (versucht), {skipped_count} Zeilen übersprungen.")
# Es ist keine Pause nach diesem Modus nötig, da die nächste Aktion im Dispatcher folgt.
# --- Methode für den Website-Summarization-Batchmodus (AS) ---
# Übernommen aus process_website_summarization_batch in Teil 9, angepasst als Methode.
def process_summarization_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None):
"""
Batch-Prozess NUR für Website-Zusammenfassung (AS).
Lädt Daten neu, prüft, ob Rohtext (AR) vorhanden und Zusammenfassung (AS) fehlt.
Fasst Rohtexte im Batch via OpenAI zusammen und setzt AS + AP.
Args:
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung).
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht übersprungener) Zeilen. Defaults to None.
"""
self.logger.info(f"Starte Website-Zusammenfassung (Batch). Bereich: {start_sheet_row}-{end_sheet_row}, Limit: {limit if limit is not None else 'Unbegrenzt'}...")
# --- Konfiguration ---
openai_batch_size = getattr(Config, 'OPENAI_BATCH_SIZE_LIMIT', 4) # Holt Wert aus Config
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) # Holt Wert aus Config
# --- Daten laden ---
# Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt
if start_sheet_row is None:
self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AS...")
# Nutzt get_start_row_index des Sheet Handlers. Prüft auf leeren AS.
start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Website Zusammenfassung")
if start_data_index_no_header == -1:
self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche ab.")
return
start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1
self.logger.info(f"Automatisch ermittelte Startzeile (erste leere AS Zelle): {start_sheet_row}")
else:
# Daten trotzdem neu laden, um aktuell zu sein
if not self.sheet_handler.load_data():
self.logger.error("FEHLER beim Laden der Daten für process_summarization_batch.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = self.sheet_handler._header_rows
total_sheet_rows = len(all_data)
# Berechne Endzeile, wenn nicht gesetzt
if end_sheet_row is None:
end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}")
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.")
return
# --- Indizes und Buchstaben ---
required_keys = [
"Website Rohtext", "Website Zusammenfassung", "Version", "CRM Name"
]
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
if None in col_indices.values():
missing = [k for k, v in col_indices.items() if v is None]
self.logger.critical(f"FEHLER: Benötigte Spaltenschlüssel fehlen in COLUMN_MAP für process_summarization_batch: {missing}. Breche ab.")
return
rohtext_col_idx = col_indices["Website Rohtext"]
summary_col_idx = col_indices["Website Zusammenfassung"]
version_col_idx = col_indices["Version"]
name_col_idx = col_indices["CRM Name"]
summary_col_letter = self.sheet_handler._get_col_letter(summary_col_idx + 1)
version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1)
# --- Verarbeitung ---
tasks_for_openai_batch = [] # Tasks für den aktuellen OpenAI Batch
rows_in_current_openai_batch = [] # Zeilennummern im aktuellen OpenAI Batch
all_sheet_updates = [] # Gesammelte Updates für Batch-Schreiben
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) # Update Batch Größe aus Config
processed_count = 0 # Zählt Zeilen, die im Batch verarbeitet (versucht) wurden
skipped_count = 0 # Zählt Zeilen, die übersprungen wurden (wegen fehlendem Rohtext oder vorhandener Zusammenfassung)
# Iteriere über die Sheet-Zeilen im definierten Bereich
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht
row = all_data[row_index_in_list]
# Stellen Sie sicher, dass die Zeile nicht leer ist
if not any(cell and cell.strip() for cell in row):
#self.logger.debug(f"Zeile {i}: Übersprungen (Leere Zeile).") # Zu viel Lärm
skipped_count += 1
continue
# --- Prüfung, ob Verarbeitung für diese Zeile nötig ist ---
# Kriterium: Website Rohtext (AR) ist vorhanden und nicht "k.A." etc.
# UND Website Zusammenfassung (AS) ist leer oder "k.A.".
# Prüfe Website Rohtext (AR) auf Inhalt
raw_text = self._get_cell_value_safe(row, "Website Rohtext")
raw_text_is_valid = raw_text and str(raw_text).strip().lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]
# Prüfe Website Zusammenfassung (AS) auf Inhalt
summary_value = self._get_cell_value_safe(row, "Website Zusammenfassung")
summary_is_empty_or_default = not summary_value or str(summary_value).strip().lower() in ["k.a.", "k.a. (keine zusammenfassung erhalten)"]
# Verarbeitung nötig, wenn AR gefüllt UND AS leer
processing_needed_for_row = raw_text_is_valid and summary_is_empty_or_default
# Loggen der Prüfergebnisse auf Debug
log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
if log_check:
company_name = self._get_cell_value_safe(row, "CRM Name").strip()
self.logger.debug(f"Zeile {i} ({company_name[:30]}... Website Summarization Check): AR gültig? {raw_text_is_valid} (len={len(str(raw_text))}), AS leer/default? {summary_is_empty_or_default}. Benötigt Verarbeitung? {processing_needed_for_row}")
if not processing_needed_for_row:
skipped_count += 1
continue
# --- Wenn Verarbeitung nötig: Füge zur Batch-Liste hinzu ---
processed_count += 1 # Zähle die Zeile, die verarbeitet wird (zum Limit zählen)
# Prüfe das Limit für verarbeitete Zeilen
if limit is not None and processed_count > limit:
self.logger.info(f"Verarbeitungslimit ({limit}) für process_summarization_batch erreicht. Breche weitere Zeilenprüfung ab.")
break # Schleife abbrechen
# Sammle die benötigten Daten für den OpenAI Batch
tasks_for_openai_batch.append({'row_num': i, 'raw_text': raw_text})
rows_in_current_openai_batch.append(i) # Sammle Zeilennummer
# --- Verarbeite den Batch, wenn voll ---
if len(tasks_for_openai_batch) >= openai_batch_size:
batch_start_row = tasks_for_openai_batch[0]['row_num']
batch_end_row = tasks_for_openai_batch[-1]['row_num']
self.logger.debug(f"\n--- Starte Website-Summarization Batch ({len(tasks_for_openai_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---")
# Rufe die globale Funktion auf, die den OpenAI Call macht (nutzt intern call_openai_chat)
# summarize_batch_openai nutzt den retry_on_failure Decorator
try:
summaries_result = summarize_batch_openai(tasks_for_openai_batch) # Annahme: summarize_batch_openai in utils.py
# Ergebnisse sollten ein Dict {row_num: summary_text} sein
# Sammle Sheet Updates (AS, AP) für diesen Batch
current_version = getattr(Config, 'VERSION', 'unknown')
batch_sheet_updates = []
for row_num in rows_in_current_openai_batch: # Iteriere über die Zeilen im *gesendeten* Batch
# Hole das Ergebnis für diese Zeile aus dem Ergebnis-Dict
summary = summaries_result.get(row_num, "k.A. (Batch Ergebnis fehlte)")
# Stelle sicher, dass 'k.A.' bei leeren/kurzen Summaries gesetzt wird
if not summary or summary.strip().lower() in ["k.a.", "k.a. (keine zusammenfassung erhalten)"]:
summary = "k.A. (Keine Zusammenfassung erhalten)"
batch_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[summary]]}) # Setze AS
batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Setze AP Version
# Sammle diese Batch-Updates für das größere Batch-Update
all_sheet_updates.extend(batch_sheet_updates)
except Exception as e_openai_batch:
# Wenn der gesamte summarize_batch_openai Call fehlschlägt (nach Retries)
self.logger.error(f"Endgültiger FEHLER beim OpenAI-Batch-Aufruf für Zusammenfassung: {e_openai_batch}")
# Fügen Sie Fehlerwerte für alle Zeilen im Batch hinzu
current_version = getattr(Config, 'VERSION', 'unknown')
for row_num in rows_in_current_openai_batch:
error_summary = f"FEHLER OpenAI Batch: {str(e_openai_batch)[:100]}"
all_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[error_summary]]})
all_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]})
# Leere den OpenAI-Batch
tasks_for_openai_batch = []
rows_in_current_openai_batch = []
# Sende gesammelte Sheet Updates, wenn das Update-Batch-Limit erreicht ist
# Updates pro Zeile sind 2 (AS, AP). len(all_sheet_updates) / 2
rows_in_update_batch = len(all_sheet_updates) // 2
if rows_in_update_batch >= update_batch_row_limit:
self.logger.debug(f" Sende gesammelte Sheet-Updates ({rows_in_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...")
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
if success:
self.logger.info(f" Sheet-Update für {rows_in_update_batch} Zeilen erfolgreich.")
# Der Fehlerfall wird von batch_update_cells geloggt
# Leere die gesammelten Updates nach dem Senden
all_sheet_updates = []
# Kurze Pause nach jedem OpenAI Batch
pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.5 # 50% der Retry-Wartezeit
self.logger.debug(f"Warte {pause_duration:.2f}s vor nächstem Batch...")
time.sleep(pause_duration)
# --- Verarbeitung des letzten unvollständigen OpenAI Batches nach der Schleife ---
# Führe den letzten Batch aus, wenn noch Tasks vorhanden sind
if tasks_for_openai_batch:
batch_start_row = tasks_for_openai_batch[0]['row_num']
batch_end_row = tasks_for_openai_batch[-1]['row_num']
self.logger.debug(f"\n--- Starte FINALEN Website-Summarization Batch ({len(tasks_for_openai_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---")
try:
summaries_result = summarize_batch_openai(tasks_for_openai_batch)
current_version = getattr(Config, 'VERSION', 'unknown')
batch_sheet_updates = []
for row_num in rows_in_current_openai_batch:
summary = summaries_result.get(row_num, "k.A. (Batch Ergebnis fehlte)")
if not summary or summary.strip().lower() in ["k.a.", "k.a. (keine zusammenfassung erhalten)"]:
summary = "k.A. (Keine Zusammenfassung erhalten)"
batch_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[summary]]})
batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]})
all_sheet_updates.extend(batch_sheet_updates)
except Exception as e_openai_batch:
self.logger.error(f"Endgültiger FEHLER beim FINALEN OpenAI-Batch-Aufruf für Zusammenfassung: {e_openai_batch}")
current_version = getattr(Config, 'VERSION', 'unknown')
for row_num in rows_in_current_openai_batch:
error_summary = f"FEHLER OpenAI Batch: {str(e_openai_batch)[:100]}"
all_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[error_summary]]})
all_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]})
# --- Finale Sheet Updates senden ---
if all_sheet_updates:
rows_in_final_update_batch = len(all_sheet_updates) // 2
self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({rows_in_final_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...")
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
if success:
self.logger.info(f"FINALES Sheet-Update erfolgreich.")
# Der Fehlerfall wird von batch_update_cells geloggt
self.logger.info(f"Website-Zusammenfassung (Batch) abgeschlossen. {processed_count} Zeilen verarbeitet (versucht), {skipped_count} Zeilen übersprungen.")
# Es ist keine Pause nach diesem Modus nötig.
# --- Die nächsten Batch-Methoden der DataProcessor Klasse folgen in den nächsten Teilen ---
# process_branch_batch method... (kommt in Teil 14)
# process_find_wiki_serp method... (kommt in Teil 14)
# process_contact_search method... (kommt in Teil 14)
# process_wiki_updates_from_chatgpt method... (kommt in Teil 15)
# process_wiki_reextract_missing_an method... (kommt in Teil 15)
# --- Interne Hilfsfunktion für Branchen-Batch (OpenAI Call) ---
# Diese Funktion läuft in einem separaten Thread für parallele Verarbeitung.
# Sie nutzt den globalen evaluate_branche_chatgpt, der wiederum call_openai_chat nutzt.
# Der OpenAI Semaphore sollte hier genutzt werden, da dies der Punkt ist,
# der tatsächliche OpenAI API Calls initiiert.
def evaluate_branch_task(self, task_data, openai_semaphore):
"""
Führt die Branchenevaluation für eine einzelne Zeile aus.
Läuft in einem separaten Thread für den Branchen-Batch.
Args:
task_data (dict): Enthält die Daten für die Zeile.
openai_semaphore (threading.Semaphore): Semaphore zur Begrenzung gleichzeitiger OpenAI-Calls.
Returns:
dict: Ergebnis von evaluate_branche_chatgpt plus row_num und error.
"""
row_num = task_data['row_num']
result = {"branch": "k.A. (Fehler Task)", "consistency": "error", "justification": "Fehler in Worker-Task"}
error = None
try:
# Acquire the semaphore before making the OpenAI call
with openai_semaphore:
# Kleine künstliche Pause reduziert manchmal Race Conditions bei hoher Last oder schnellen APIs
# time.sleep(0.05) # Optional
# Annahme: evaluate_branche_chatgpt ist global definiert (utils.py)
# evaluate_branche_chatgpt ruft call_openai_chat auf, der den retry_on_failure Decorator nutzt.
result = evaluate_branche_chatgpt(
task_data['crm_branche'],
task_data['beschreibung'],
task_data['wiki_branche'],
task_data['wiki_kategorien'],
task_data['website_summary']
)
except Exception as e:
error = f"Fehler bei Branchenevaluation Zeile {row_num}: {e}"
self.logger.error(error)
# Stellen Sie sicher, dass das Ergebnis-Dict im Fehlerfall korrekt ist
result = {"branch": "FEHLER", "consistency": "error_task", "justification": error[:500]} # Kürze Begründung
#logger.debug(f"Branch Task Zeile {row_num} abgeschlossen.") # Zu viel Lärm
return {"row_num": row_num, "result": result, "error": error}
# --- Methode für den Branchen-Batchmodus (AO) ---
# Übernommen aus process_branch_batch in Teil 9, angepasst als Methode.
def process_branch_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None):
"""
Batch-Prozess für Brancheneinschätzung mit paralleler Verarbeitung via Threads.
Prüft Timestamp AO, führt evaluate_branche_chatgpt parallel aus (limitiert),
setzt W, X, Y, AO + AP und sendet Sheet-Updates GEBÜNDELT PRO VERARBEITUNGS-BATCH.
Args:
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung).
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht übersprungener) Zeilen. Defaults to None.
"""
self.logger.info(f"Starte Brancheneinschätzung (Parallel Batch). Bereich: {start_sheet_row}-{end_sheet_row}, Limit: {limit if limit is not None else 'Unbegrenzt'}...")
# --- Daten laden ---
# Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt
if start_sheet_row is None:
self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AO...")
# Nutzt get_start_row_index des Sheet Handlers. Prüft auf leeren AO.
start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Timestamp letzte Prüfung")
if start_data_index_no_header == -1:
self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche ab.")
return
start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1
self.logger.info(f"Automatisch ermittelte Startzeile (erste leere AO Zelle): {start_sheet_row}")
else:
# Daten trotzdem neu laden, um aktuell zu sein
if not self.sheet_handler.load_data():
self.logger.error("FEHLER beim Laden der Daten für process_branch_batch.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = self.sheet_handler._header_rows
total_sheet_rows = len(all_data)
# Berechne Endzeile, wenn nicht gesetzt
if end_sheet_row is None:
end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}")
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.")
return
# --- Indizes und Buchstaben ---
required_keys = [
"Timestamp letzte Prüfung", # AO - Prüfkriterium
"CRM Branche", "CRM Beschreibung", "Wiki Branche", "Wiki Kategorien", # Daten für Prompt
"Website Zusammenfassung", "Version", # Weitere Daten für Prompt / Update
"Chat Vorschlag Branche", "Chat Konsistenz Branche", "Chat Begründung Abweichung Branche" # Ergebnisspalten W, X, Y
]
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
if None in col_indices.values():
missing = [k for k, v in col_indices.items() if v is None]
self.logger.critical(f"FEHLER: Benötigte Spaltenschlüssel fehlen in COLUMN_MAP für process_branch_batch: {missing}. Breche ab.")
return
# Spaltenbuchstaben für Updates
ts_ao_letter = self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Prüfung"] + 1)
version_col_letter = self.sheet_handler._get_col_letter(col_indices["Version"] + 1)
branch_w_letter = self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Branche"] + 1)
branch_x_letter = self.sheet_handler._get_col_letter(col_indices["Chat Konsistenz Branche"] + 1)
branch_y_letter = self.sheet_handler._get_col_letter(col_indices["Chat Begründung Abweichung Branche"] + 1)
# --- Konfiguration für Parallelisierung ---
MAX_BRANCH_WORKERS = getattr(Config, 'MAX_BRANCH_WORKERS', 10) # Threads für parallele Verarbeitung
OPENAI_CONCURRENCY_LIMIT = getattr(Config, 'OPENAI_CONCURRENCY_LIMIT', 3) # Max. gleichzeitige OpenAI Calls
openai_semaphore_branch = threading.Semaphore(OPENAI_CONCURRENCY_LIMIT) # Semaphore Instanz
# --- Verarbeitung ---
processing_batch_size = getattr(Config, 'PROCESSING_BRANCH_BATCH_SIZE', 20) # Größe des Verarbeitungsbatches
tasks_for_processing_batch = [] # Tasks für den aktuellen Batch
rows_in_current_batch = [] # Zeilennummern im aktuellen Batch
# Sheet Updates werden direkt nach Verarbeitung eines Batch geschrieben,
# keine große gesammelte Liste wie bei Scraping/Summarization
processed_count = 0 # Zählt Zeilen, die im Batch verarbeitet (versucht) wurden
skipped_count = 0 # Zählt Zeilen, die übersprungen wurden (wegen AO oder fehlender Daten)
# Laden Sie das Zielschema, falls noch nicht geschehen (evaluate_branche_chatgpt benötigt es)
# evaluate_branche_chatgpt prüft intern, ob das Schema geladen ist und loggt Fehler,
# aber wir können hier auch prüfen und ggf. abbrechen.
global ALLOWED_TARGET_BRANCHES
if not ALLOWED_TARGET_BRANCHES:
# Annahme: load_target_schema ist global (utils.py)
load_target_schema() # Versuche, das Schema zu laden
if not ALLOWED_TARGET_BRANCHES:
self.logger.critical("FEHLER: Ziel-Branchenschema konnte nicht geladen werden. Branchenbewertung nicht möglich. Breche ab.")
return
# Iteriere über die Sheet-Zeilen im definierten Bereich
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht
row = all_data[row_index_in_list]
# Stellen Sie sicher, dass die Zeile nicht leer ist
if not any(cell and cell.strip() for cell in row):
#self.logger.debug(f"Zeile {i}: Übersprungen (Leere Zeile).") # Zu viel Lärm
skipped_count += 1
continue
# --- Prüfung, ob Verarbeitung für diese Zeile nötig ist ---
# Kriterium: Timestamp letzte Prüfung (AO) ist leer.
# ZUSÄTZLICH: Prüfen, ob genügend Quelldaten für die Evaluation vorhanden sind.
# Mindestens CRM Branche ODER Beschreibung ODER Wiki Branche/Kategorien ODER Website Summary.
# evaluate_branche_chatgpt prüft auf mind. 2 Info-Punkte. Wir können hier eine ähnliche Prüfung machen.
ao_value = self._get_cell_value_safe(row, "Timestamp letzte Prüfung").strip()
processing_needed_based_on_status = not ao_value
if not processing_needed_based_on_status:
skipped_count += 1
continue
# Prüfe, ob ausreichend Daten vorhanden sind (mindestens 2 Quellen)
crm_branche = self._get_cell_value_safe(row, "CRM Branche").strip()
crm_beschreibung = self._get_cell_value_safe(row, "CRM Beschreibung").strip()
wiki_branche = self._get_cell_value_safe(row, "Wiki Branche").strip()
wiki_kategorien = self._get_cell_value_safe(row, "Wiki Kategorien").strip()
website_summary = self._get_cell_value_safe(row, "Website Zusammenfassung").strip()
info_sources_count = sum(1 for val in [crm_branche, crm_beschreibung, wiki_branche, wiki_kategorien, website_summary] if val and val != "k.A.")
if info_sources_count < 2:
self.logger.debug(f"Zeile {i} (Branch Check): Übersprungen (AO leer, aber nur {info_sources_count} Informationsquellen verfügbar).")
skipped_count += 1
continue
# --- Wenn Verarbeitung nötig: Füge zur Batch-Liste hinzu ---
processed_count += 1 # Zähle die Zeile, die verarbeitet wird (zum Limit zählen)
# Prüfe das Limit für verarbeitete Zeilen
if limit is not None and processed_count > limit:
self.logger.info(f"Verarbeitungslimit ({limit}) für process_branch_batch erreicht. Breche weitere Zeilenprüfung ab.")
break # Schleife abbrechen
# Sammle die benötigten Daten für den Branchen-Task
tasks_for_processing_batch.append({
"row_num": i,
"crm_branche": crm_branche,
"beschreibung": crm_beschreibung,
"wiki_branche": wiki_branche,
"wiki_kategorien": wiki_kategorien,
"website_summary": website_summary
})
rows_in_current_batch.append(i) # Sammle Zeilennummer
# --- Verarbeite den Batch, wenn voll ---
if len(tasks_for_processing_batch) >= processing_batch_size:
batch_start_row = tasks_for_processing_batch[0]['row_num']
batch_end_row = tasks_for_processing_batch[-1]['row_num']
self.logger.debug(f"\n--- Starte Branch-Evaluation Batch ({len(tasks_for_processing_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---")
results_list = [] # Ergebnisse dieses Batch
batch_error_count = 0 # Fehlerzähler für diesen spezifischen Batch
self.logger.debug(f" Evaluiere {len(tasks_for_processing_batch)} Zeilen parallel (max {MAX_BRANCH_WORKERS} worker, {OPENAI_CONCURRENCY_LIMIT} OpenAI gleichzeitig)...")
# *** BEGINN PARALLELE VERARBEITUNG MIT THREADS ***
# Verwende ThreadPoolExecutor für parallele Ausführung der evaluate_branch_task
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_BRANCH_WORKERS) as executor:
# Map tasks to futures, passing the semaphore
future_to_task = {executor.submit(self.evaluate_branch_task, task, openai_semaphore): task for task in tasks_for_processing_batch}
# Process results as they complete
for future in concurrent.futures.as_completed(future_to_task):
task = future_to_task[future] # Get the original task data
try:
result_data = future.result() # Get the result from the future
results_list.append(result_data) # Add the result (including error flag)
if result_data.get('error'): # Check if the worker reported an error
batch_error_count += 1
except Exception as exc:
# This block catches unexpected errors during future result retrieval
row_num = task['row_num']
err_msg = f"Generischer Fehler Branch Task Zeile {row_num}: {exc}"
self.logger.error(err_msg)
# Append a specific error result for this row
results_list.append({"row_num": row_num, "result": {"branch": "FEHLER", "consistency": "error_task", "justification": err_msg[:500]}, "error": err_msg})
batch_error_count += 1
# *** ENDE PARALLELE VERARBEITUNG ***
self.logger.debug(f" Branch-Evaluation für Batch beendet. {len(results_list)} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).")
# Sheet Updates vorbereiten FÜR DIESEN BATCH
if results_list:
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
current_version = getattr(Config, 'VERSION', 'unknown')
batch_sheet_updates = []
# Sortiere Ergebnisse nach Zeilennummer für geordnetes Schreiben (optional, aber gut)
results_list.sort(key=lambda x: x['row_num'])
for res_data in results_list:
row_num = res_data['row_num']
result = res_data['result'] # Das Ergebnis-Dict von evaluate_branche_chatgpt
# Logge das individuelle Ergebnis VOR dem Update
# self.logger.debug(f" Zeile {row_num}: Ergebnis -> Branch='{result.get('branch')}', Consistency='{result.get('consistency')}', Justification='{result.get('justification', '')[:50]}...'") # Zu viel Lärm
# Sammle Updates für W, X, Y, AO, AP
batch_sheet_updates.append({'range': f'{branch_w_letter}{row_num}', 'values': [[result.get("branch", "FEHLER")]]})
batch_sheet_updates.append({'range': f'{branch_x_letter}{row_num}', 'values': [[result.get("consistency", "error")]]})
batch_sheet_updates.append({'range': f'{branch_y_letter}{row_num}', 'values': [[result.get("justification", "Keine Begründung")]]})
batch_sheet_updates.append({'range': f'{ts_ao_letter}{row_num}', 'values': [[current_timestamp]]}) # Setze AO Timestamp
batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Setze AP Version
# --- Sende Updates für DIESEN BATCH SOFORT ---
if batch_sheet_updates:
self.logger.debug(f" Sende Sheet-Update für {len(results_list)} Zeilen ({len(batch_sheet_updates)} Zellen)...")
# Nutzt die batch_update_cells Methode des Sheet Handlers mit Retry
success = self.sheet_handler.batch_update_cells(batch_sheet_updates)
if success:
self.logger.info(f" Sheet-Update für Batch {batch_start_row}-{batch_end_row} erfolgreich.")
# Der Fehlerfall wird von batch_update_cells geloggt
# else: self.logger.debug(f" Keine Sheet-Updates für Batch {batch_start_row}-{batch_end_row} vorbereitet.") # Zu viel Lärm
# Leere den Batch für die nächste Iteration
tasks_for_processing_batch = []
rows_in_current_batch = []
# Pause NACHDEM ein Batch komplett verarbeitet und geschrieben wurde
# Nutze Config.RETRY_DELAY, ggf. kürzer, da es ein Batch war
pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.5 # 50% der Retry-Wartezeit
self.logger.debug(f"--- Batch {batch_start_row}-{batch_end_row} abgeschlossen. Warte {pause_duration:.2f}s vor nächstem Batch ---")
time.sleep(pause_duration)
# --- Verarbeitung des letzten unvollständigen Batches nach der Schleife ---
if tasks_for_processing_batch:
batch_start_row = tasks_for_processing_batch[0]['row_num']
batch_end_row = tasks_for_processing_batch[-1]['row_num']
self.logger.debug(f"\n--- Starte FINALEN Branch-Evaluation Batch ({len(tasks_for_processing_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---")
results_list = []
batch_error_count = 0
self.logger.debug(f" Evaluiere {len(tasks_for_processing_batch)} Zeilen parallel (max {MAX_BRANCH_WORKERS} worker, {OPENAI_CONCURRENCY_LIMIT} OpenAI gleichzeitig)...")
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_BRANCH_WORKERS) as executor:
future_to_task = {executor.submit(self.evaluate_branch_task, task, openai_semaphore): task for task in tasks_for_processing_batch}
for future in concurrent.futures.as_completed(future_to_task):
task = future_to_task[future]
try:
result_data = future.result()
results_list.append(result_data)
if result_data.get('error'): batch_error_count += 1
except Exception as exc:
row_num = task['row_num']
err_msg = f"Generischer Fehler Branch Task Zeile {row_num}: {exc}"
self.logger.error(err_msg)
results_list.append({"row_num": row_num, "result": {"branch": "FEHLER", "consistency": "error_task", "justification": err_msg[:500]}, "error": err_msg})
batch_error_count += 1
self.logger.debug(f" FINALER Branch Batch beendet. {len(results_list)} Ergebnisse erhalten ({batch_error_count} Fehler).")
if results_list:
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
current_version = getattr(Config, 'VERSION', 'unknown')
batch_sheet_updates = []
results_list.sort(key=lambda x: x['row_num'])
for res_data in results_list:
row_num = res_data['row_num']
result = res_data['result']
batch_sheet_updates.append({'range': f'{branch_w_letter}{row_num}', 'values': [[result.get("branch", "FEHLER")]]})
batch_sheet_updates.append({'range': f'{branch_x_letter}{row_num}', 'values': [[result.get("consistency", "error")]]})
batch_sheet_updates.append({'range': f'{branch_y_letter}{row_num}', 'values': [[result.get("justification", "Keine Begründung")]]})
batch_sheet_updates.append({'range': f'{ts_ao_letter}{row_num}', 'values': [[current_timestamp]]})
batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]})
# All updates are in batch_sheet_updates for the final batch
# Send them
if batch_sheet_updates:
self.logger.debug(f" Sende FINALES Sheet-Update für {len(results_list)} Zeilen ({len(batch_sheet_updates)} Zellen)...")
success = self.sheet_handler.batch_update_cells(batch_sheet_updates)
if success:
self.logger.info(f" FINALES Sheet-Update für Branch Batch erfolgreich.")
self.logger.info(f"Brancheneinschätzung (Parallel Batch) abgeschlossen. {processed_count} Zeilen verarbeitet (versucht), {skipped_count} Zeilen übersprungen.")
# Keine Pause nach diesem Modus nötig.
# --- Methode für den SerpAPI Wiki Search Batchmodus (AY) ---
# Übernommen aus process_find_wiki_with_serp in Teil 2, angepasst als Methode.
def process_find_wiki_serp(self, start_sheet_row=None, end_sheet_row=None, limit=None, min_employees=500, min_umsatz=200):
"""
Sucht fehlende Wikipedia-URLs (Spalte M = k.A.) über SerpAPI für Unternehmen mit
(Umsatz CRM > min_umsatz MIO € ODER Mitarbeiter CRM > min_employees)
UND wenn der SerpAPI Wiki Search Timestamp (AY) leer ist.
Trägt gefundene URLs in Spalte M ein. Setzt ReEval-Flag (A)
und löscht abhängige Wiki-Spalten (N-V, AN, AO, AP, AX).
Setzt Timestamp in Spalte AY, wann die Suche durchgeführt wurde (unabhängig vom Ergebnis).
Args:
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung).
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht übersprungener) Zeilen. Defaults to None.
min_employees (int, optional): Mindestanzahl Mitarbeiter (Spalte K) als Teilfilter. Defaults to 500.
min_umsatz (int, optional): Mindestumsatz in MIO € (Spalte J) als Teilfilter. Defaults to 200.
"""
self.logger.info(f"Starte Modus 'find_wiki_serp': Suche fehlende Wiki-URLs für Firmen mit (Umsatz CRM > {min_umsatz} MIO € ODER Mitarbeiter CRM > {min_employees}). Bereich: {start_sheet_row}-{end_sheet_row}, Limit: {limit if limit is not None else 'Unbegrenzt'}...")
# --- Daten laden ---
# Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt
if start_sheet_row is None:
self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AY...")
# Nutzt get_start_row_index des Sheet Handlers. Prüft auf leeren AY.
start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="SerpAPI Wiki Search Timestamp")
if start_data_index_no_header == -1:
self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche ab.")
return
start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1
self.logger.info(f"Automatisch ermittelte Startzeile (erste leere AY Zelle): {start_sheet_row}")
else:
# Daten trotzdem neu laden, um aktuell zu sein
if not self.sheet_handler.load_data():
self.logger.error("FEHLER beim Laden der Daten für process_find_wiki_serp.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = self.sheet_handler._header_rows
total_sheet_rows = len(all_data)
# Berechne Endzeile, wenn nicht gesetzt
if end_sheet_row is None:
end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}")
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.")
return
# --- Indizes und Buchstaben ---
required_keys = [
"SerpAPI Wiki Search Timestamp", "Wiki URL", "CRM Umsatz", "CRM Anzahl Mitarbeiter", # Prüfkriterien / Timestamp
"ReEval Flag", "CRM Name", "CRM Website", # Daten für Suche / Updates
"Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", # Spalten zum Leeren
"Chat Wiki Konsistenzprüfung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", # Spalten zum Leeren
"Begründung bei Abweichung", "Wikipedia Timestamp", "Timestamp letzte Prüfung", # Spalten zum Leeren
"Version", "Wiki Verif. Timestamp" # Spalten zum Leeren
]
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
if None in col_indices.values():
missing = [k for k, v in col_indices.items() if v is None]
self.logger.critical(f"FEHLER: Benötigte Spaltenschlüssel fehlen in COLUMN_MAP für process_find_wiki_serp: {missing}. Breche ab.")
return
# Spaltenbuchstaben für Updates/Leerung
ay_letter = self.sheet_handler._get_col_letter(col_indices["SerpAPI Wiki Search Timestamp"] + 1) # Timestamp zu setzen
m_letter = self.sheet_handler._get_col_letter(col_indices["Wiki URL"] + 1) # Wiki URL Spalte
a_letter = self.sheet_handler._get_col_letter(col_indices["ReEval Flag"] + 1) # ReEval Flag
# Spalten N-V leeren
n_idx = col_indices["Wiki Absatz"]
v_idx = col_indices["Begründung bei Abweichung"]
n_letter = self.sheet_handler._get_col_letter(n_idx + 1)
v_letter = self.sheet_handler._get_col_letter(v_idx + 1)
n_v_range_letter = f'{n_letter}:{v_letter}'
# Timestamps AN, AO, AX, Version AP leeren
an_letter = self.sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"] + 1)
ao_letter = self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Prüfung"] + 1)
ap_letter = self.sheet_handler._get_col_letter(col_indices["Version"] + 1)
ax_letter = self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"] + 1)
# --- Verarbeitung ---
all_sheet_updates = [] # Gesammelte Updates für Batch-Schreiben
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) # Nutze die Update-Batch-Größe aus Config
processed_count = 0 # Zählt Zeilen, für die SerpAPI versucht wurde (zum Limit zählen)
skipped_count = 0 # Zählt Zeilen, die übersprungen wurden (verschiedene Gründe)
found_urls_count = 0 # Zählt Zeilen, wo eine URL gefunden und eingetragen wurde
now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Iteriere durch die Datenzeilen im definierten Bereich
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht
row = all_data[row_index_in_list]
# Stellen Sie sicher, dass die Zeile nicht leer ist
if not any(cell and cell.strip() for cell in row):
#self.logger.debug(f"Zeile {i}: Übersprungen (Leere Zeile).") # Zu viel Lärm
skipped_count += 1
continue
# --- Prüfung, ob Verarbeitung für diese Zeile nötig ist ---
# Kriterium: AY ist leer
# UND Wiki URL (M) ist leer oder "k.A."
# UND (Umsatz CRM (J) > min_umsatz MIO € ODER Mitarbeiter CRM (K) > min_employees)
ay_value = self._get_cell_value_safe(row, "SerpAPI Wiki Search Timestamp").strip()
m_value = self._get_cell_value_safe(row, "Wiki URL").strip()
umsatz_val_str = self._get_cell_value_safe(row, "CRM Umsatz")
ma_val_str = self._get_cell_value_safe(row, "CRM Anzahl Mitarbeiter")
# Nutze die globale Hilfsfunktion, um die Werte für den Vergleich zu bekommen
umsatz_val_mio = get_numeric_filter_value(umsatz_val_str, is_umsatz=True) # Annahme: get_numeric_filter_value in utils.py
ma_val_num = get_numeric_filter_value(ma_val_str, is_umsatz=False) # Annahme: get_numeric_filter_value in utils.py
is_m_empty_or_ka = not m_value or m_value.lower() == "k.a."
size_criteria_met = (umsatz_val_mio > min_umsatz) or (ma_val_num > min_employees)
processing_needed_for_row = not ay_value and is_m_empty_or_ka and size_criteria_met
# Loggen der Prüfergebnisse auf Debug
log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
if log_check:
company_name = self._get_cell_value_safe(row, "CRM Name").strip()
self.logger.debug(f"Zeile {i} ({company_name[:30]}... SerpAPI Wiki Search Check): AY leer? {not ay_value}, M leer/k.A.? {is_m_empty_or_ka}, Größe ({umsatz_val_mio:.1f} Mio, {ma_val_num} MA) Kriterium? {size_criteria_met}. Benötigt Verarbeitung? {processing_needed_for_row}")
if not processing_needed_for_row:
skipped_count += 1
continue
# --- Wenn Verarbeitung nötig: Führe SerpAPI Suche aus ---
processed_count += 1 # Zähle die Zeile, für die SerpAPI versucht wird (zum Limit zählen)
# Prüfe das Limit für verarbeitete Zeilen
if limit is not None and processed_count > limit:
self.logger.info(f"Verarbeitungslimit ({limit}) für process_find_wiki_serp erreicht. Breche weitere Zeilenprüfung ab.")
break # Schleife abbrechen
# Hole Firmenname und Website für die Suche
company_name = self._get_cell_value_safe(row, "CRM Name").strip()
website_url = self._get_cell_value_safe(row, "CRM Website").strip() # Website kann für SerpAPI Kontext hilfreich sein
if not company_name:
self.logger.warning(f"Zeile {i}: Übersprungen (kein Firmenname für Suche vorhanden).")
skipped_count += 1 # Zählen als übersprungen, da Suche nicht möglich
# Setze AY Timestamp auch hier, um nicht immer wieder zu versuchen
all_sheet_updates.append({'range': f'{ay_letter}{i}', 'values': [[now_timestamp_str]]})
continue # Nächste Zeile
self.logger.info(f"Zeile {i}: Suche Wiki-URL für '{company_name}' (Umsatz (Mio): {umsatz_val_mio:.1f}, MA: {ma_val_num}) via SerpAPI...")
# Führe die SerpAPI Suche durch (nutzt globale Funktion mit Retry)
try:
wiki_url_found = serp_wikipedia_lookup(company_name, website=website_url) # Annahme: serp_wikipedia_lookup in utils.py
except Exception as e_serp_wiki:
self.logger.error(f"FEHLER bei serp_wikipedia_lookup für Zeile {i} ('{company_name}'): {e_serp_wiki}")
wiki_url_found = None # Bei Fehler als nicht gefunden behandeln
pass # Fahren Sie fort, um Timestamp zu setzen und Updates vorzubereiten
# --- Updates vorbereiten ---
# Timestamp AY IMMER setzen, nachdem der Versuch gemacht wurde, unabhängig vom Ergebnis
all_sheet_updates.append({'range': f'{ay_letter}{i}', 'values': [[now_timestamp_str]]})
# Wenn eine URL gefunden wurde, bereite weitere Updates vor
if wiki_url_found and wiki_url_found.strip() and wiki_url_found.lower() != "k.a.":
self.logger.info(f" -> URL gefunden: {wiki_url_found}. Bereite Update vor (Setze M, A; Lösche N-V, AN, AO, AP, AX).")
found_urls_count += 1
# Setze M mit der gefundenen URL
all_sheet_updates.append({'range': f'{m_letter}{i}', 'values': [[wiki_url_found]]})
# Setze ReEval Flag (A) auf 'x'
all_sheet_updates.append({'range': f'{a_letter}{i}', 'values': [['x']]})
# Leere Spalten N-V
empty_nv_values = [''] * (v_idx - n_idx + 1) # Anzahl der Spalten von N bis V
all_sheet_updates.append({'range': f'{n_letter}{i}:{v_letter}{i}', 'values': [empty_nv_values]})
# Leere Timestamps AN, AO, AX und Version AP
all_sheet_updates.append({'range': f'{an_letter}{i}', 'values': [['']]})
all_sheet_updates.append({'range': f'{ao_letter}{i}', 'values': [['']]})
all_sheet_updates.append({'range': f'{ap_letter}{i}', 'values': [['']]})
all_sheet_updates.append({'range': f'{ax_letter}{i}', 'values': [['']]})
else:
self.logger.info(f" -> Keine Wiki-URL für '{company_name}' via SerpAPI gefunden.")
# Nur AY Timestamp wird gesetzt, was bereits oben passiert ist.
# Sende gesammelte Sheet Updates, wenn das Update-Batch-Limit erreicht ist
# Die Anzahl der Updates pro Zeile, für die eine URL gefunden wurde, ist hoch (1+1+1+1+1+1+1 + (V-N+1)), ca. 10-15.
# Wenn keine URL gefunden, sind es nur 1 (AY).
# Wir prüfen einfach die Länge der gesammelten Liste.
if len(all_sheet_updates) >= update_batch_row_limit * (10): # Grobe Schätzung pro Zeile
self.logger.debug(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...")
# Nutzt die batch_update_cells Methode des Sheet Handlers mit Retry
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
if success:
self.logger.info(f" Sheet-Update für {len(all_sheet_updates)} Zellen erfolgreich.")
# Der Fehlerfall wird von batch_update_cells geloggt
# Leere die gesammelten Updates nach dem Senden
all_sheet_updates = []
# Kleiner Sleep nach jeder SerpAPI-Suche (nutzt Config)
# Der Decorator kümmert sich um Retries mit Backoff, dies ist eine globale Rate-Limit-Vorsorge.
serp_delay = getattr(Config, 'SERPAPI_DELAY', 1.5)
#self.logger.debug(f"Warte {serp_delay:.2f}s nach SerpAPI Suche...") # Zu viel Lärm
time.sleep(serp_delay)
# --- Finale Sheet Updates senden ---
# Sende alle verbleibenden gesammelten Updates
if all_sheet_updates:
self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...")
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
if success:
self.logger.info(f"FINALES Sheet-Update erfolgreich.")
# Der Fehlerfall wird von batch_update_cells geloggt
self.logger.info(f"Modus 'find_wiki_serp' abgeschlossen. {processed_count} Zeilen verarbeitet (versucht), {found_urls_count} URLs gefunden & eingetragen, {skipped_count} Zeilen übersprungen.")
# Keine Pause nach diesem Modus nötig.
# --- Methode für den Contact Search Batchmodus (AM, AI-AL) ---
# Übernommen aus process_contact_research in Teil 10, angepasst als Methode.
def process_contact_search(self, start_sheet_row=None, end_sheet_row=None, limit=None):
"""
Sucht LinkedIn Kontakte via SERP API für Zeilen, bei denen der
Contact Search Timestamp (AM) leer ist. Trägt Trefferzahlen in
AI-AL und den Timestamp in AM ein. Schreibt Details optional in ein 'Contacts' Blatt.
Args:
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung).
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht übersprungener) Zeilen. Defaults to None.
"""
self.logger.info(f"Starte Contact Research (LinkedIn via SerpAPI). Bereich: {start_sheet_row}-{end_sheet_row}, Limit: {limit if limit is not None else 'Unbegrenzt'}...")
# --- Daten laden ---
# Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt
if start_sheet_row is None:
self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AM...")
# Nutzt get_start_row_index des Sheet Handlers. Prüft auf leeren AM.
start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Contact Search Timestamp")
if start_data_index_no_header == -1:
self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche ab.")
return
start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1
self.logger.info(f"Automatisch ermittelte Startzeile (erste leere AM Zelle): {start_sheet_row}")
else:
# Daten trotzdem neu laden, um aktuell zu sein
if not self.sheet_handler.load_data():
self.logger.error("FEHLER beim Laden der Daten für process_contact_search.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = self.sheet_handler._header_rows
total_sheet_rows = len(all_data)
# Berechne Endzeile, wenn nicht gesetzt
if end_sheet_row is None:
end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}")
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.")
return
# --- Indizes und Buchstaben ---
required_keys = [
"Contact Search Timestamp", # AM - Prüfkriterium / Timestamp
"CRM Name", "CRM Kurzform", "CRM Website", # Daten für Suche
"Linked Serviceleiter gefunden", "Linked It-Leiter gefunden", # Zielspalten für Trefferzahlen
"Linked Management gefunden", "Linked Disponent gefunden" # Zielspalten für Trefferzahlen
]
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
if None in col_indices.values():
missing = [k for k, v in col_indices.items() if v is None]
self.logger.critical(f"FEHLER: Benötigte Spaltenschlüssel fehlen in COLUMN_MAP für process_contact_search: {missing}. Breche ab.")
return
# Spaltenbuchstaben für Updates (Trefferzahlen AI-AL und Timestamp AM)
ts_am_letter = self.sheet_handler._get_col_letter(col_indices["Contact Search Timestamp"] + 1)
ai_letter = self.sheet_handler._get_col_letter(col_indices["Linked Serviceleiter gefunden"] + 1)
aj_letter = self.sheet_handler._get_col_letter(col_indices["Linked It-Leiter gefunden"] + 1)
ak_letter = self.sheet_handler._get_col_letter(col_indices["Linked Management gefunden"] + 1)
al_letter = self.sheet_handler._get_col_letter(col_indices["Linked Disponent gefunden"] + 1)
# Positionen, nach denen gesucht wird (kann in Config verschoben werden)
# Die Zuordnung zur Zählspalte (AI-AL) muss hier im Code erfolgen.
positions_to_search = {
"Serviceleiter": ["Serviceleiter", "Leiter Kundendienst", "Einsatzleiter"],
"IT-Leiter": ["IT-Leiter", "Leiter IT"],
"Management": ["Geschäftsführer", "Vorstand", "Inhaber", "CEO", "CTO", "COO"], # Management erweitert
"Disponent": ["Disponent"]
}
# Stellen Sie sicher, dass die Schlüssel im Dict den COLUMN_MAP Keys entsprechen
# Kontakte-Blatt öffnen oder erstellen (wird einmalig gemacht)
contacts_sheet = None
try:
# Versuche, das Sheet "Contacts" zu öffnen
contacts_sheet = self.sheet_handler.sheet.spreadsheet.worksheet("Contacts")
self.logger.info("Blatt 'Contacts' gefunden.")
except gspread.exceptions.WorksheetNotFound:
# Wenn nicht gefunden, erstelle es
self.logger.info("Blatt 'Contacts' nicht gefunden, erstelle neu...")
try:
# TODO: Richtige Reihenfolge und Namen für Contacts Sheet Header definieren
contacts_header = ["Firmenname", "CRM Kurzform", "Website", "Geschlecht", "Vorname", "Nachname", "Position", "Suchbegriffskategorie", "E-Mail-Adresse", "LinkedIn-Link", "Timestamp"]
# Geschätzte Anzahl Zeilen/Spalten für neues Blatt (kann angepasst werden)
contacts_sheet = self.sheet_handler.sheet.spreadsheet.add_worksheet(title="Contacts", rows="5000", cols=len(contacts_header))
# Schreibe Header in die erste Zeile
contacts_sheet.update(values=[contacts_header], range_name=f"A1:{self.sheet_handler._get_col_letter(len(contacts_header))}1")
self.logger.info("Neues Blatt 'Contacts' erstellt und Header eingetragen.")
except Exception as e_create_sheet:
self.logger.critical(f"FEHLER: Konnte Blatt 'Contacts' nicht erstellen: {e_create_sheet}. Kontakt-Details können nicht gespeichert werden.")
contacts_sheet = None # Setze contacts_sheet auf None, um Schreibversuche zu verhindern
# --- Verarbeitung ---
all_sheet_updates = [] # Gesammelte Updates für Batch-Schreiben ins Hauptblatt
all_contact_rows_to_append = [] # Gesammelte Zeilen für append_rows ins Contacts-Blatt
# append_rows kann große Batches handhaben, wir können hier mehr sammeln als beim Batch-Update.
# Oder wir schreiben pro Firma in das Contacts-Blatt (weniger sammelbar).
# Fürs Erste sammeln wir pro Firma und schreiben dann.
processed_count = 0 # Zählt Zeilen im Hauptblatt, die verarbeitet (versucht) wurden
skipped_count = 0 # Zählt Zeilen, die übersprungen wurden (wegen AM oder fehlender Daten)
now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Iteriere durch die Datenzeilen im definierten Bereich
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1 # 0-based index in the all_data list
if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht
row = all_data[row_index_in_list]
# Stellen Sie sicher, dass die Zeile nicht leer ist
if not any(cell and cell.strip() for cell in row):
#self.logger.debug(f"Zeile {i}: Übersprungen (Leere Zeile).") # Zu viel Lärm
skipped_count += 1
continue
# --- Prüfung, ob Verarbeitung für diese Zeile nötig ist ---
# Kriterium: Contact Search Timestamp (AM) ist leer.
# ZUSÄTZLICH: Prüfen, ob CRM Name, Kurzform und Website vorhanden sind.
am_value = self._get_cell_value_safe(row, "Contact Search Timestamp").strip()
processing_needed_based_on_status = not am_value
# Hole Daten für Suche
company_name = self._get_cell_value_safe(row, "CRM Name").strip()
crm_kurzform = self._get_cell_value_safe(row, "CRM Kurzform").strip()
website = self._get_cell_value_safe(row, "CRM Website").strip()
# Prüfen Sie, ob die Mindestdaten für die Suche vorhanden sind
has_min_data_for_search = company_name and crm_kurzform and website and website.lower() != "k.a."
processing_needed_for_row = processing_needed_based_on_status and has_min_data_for_search
# Loggen der Prüfergebnisse auf Debug
log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
if log_check:
self.logger.debug(f"Zeile {i} ({company_name[:30]}... Contact Check): AM leer? {not am_value}, Mindestdaten? {has_min_data_for_search}. Benötigt Verarbeitung? {processing_needed_for_row}")
if not processing_needed_for_row:
skipped_count += 1
continue
# --- Wenn Verarbeitung nötig: Führe LinkedIn Suche(n) aus ---
processed_count += 1 # Zähle die Zeile, für die Suche versucht wird (zum Limit zählen)
# Prüfe das Limit für verarbeitete Zeilen
if limit is not None and processed_count > limit:
self.logger.info(f"Verarbeitungslimit ({limit}) für process_contact_search erreicht. Breche weitere Zeilenprüfung ab.")
break # Schleife abbrechen
self.logger.info(f"Zeile {i}: Suche LinkedIn Kontakte für '{crm_kurzform}' ({website})...")
all_found_contacts_for_row = [] # Alle Kontakte, die für diese EINE Zeile gefunden werden
contact_counts_for_row = {key: 0 for key in positions_to_search.keys()} # Zähler für diese Zeile
# Führe die Suche für jede Positionskategorie durch
for category, queries in positions_to_search.items():
# Führe die Suche für jede spezifische Abfrage innerhalb der Kategorie durch
# Suchergebnisse deduplizieren (kann ein Kontakt unter mehreren Positionen auftauchen)
found_contacts_in_category = {} # {linkedin_url: contact_data}
for position_query in queries:
self.logger.debug(f" -> Suche nach: '{position_query}' bei '{crm_kurzform}'...")
try:
# search_linkedin_contacts nutzt den retry_on_failure Decorator und SerpAPI.
# Es gibt eine Liste von Kontakt-Dicts zurück.
# Wir limitieren die Anzahl der SerpAPI Ergebnisse pro Suche.
contacts_from_query = search_linkedin_contacts(
company_name=company_name,
website=website, # Kann ggf. als Kontext im Prompt helfen (nicht in search_linkedin_contacts genutzt, aber könnte)
position_query=position_query,
crm_kurzform=crm_kurzform,
num_results=getattr(Config, 'SERPAPI_LINKEDIN_RESULTS_PER_QUERY', 5) # Konfigurierbar
)
# Zähle Treffer für diese Kategorie (einfache Zählung hier)
# contact_counts_for_row[category] += len(contacts_from_query) # Nicht hier zählen, sondern nach Deduplizierung!
# Füge gefundene Kontakte (mit Suchkategorie) zur Liste für diese Zeile hinzu, dedupliziert
for contact in contacts_from_query:
linkedin_url = contact.get("LinkedInURL")
if linkedin_url:
if linkedin_url not in found_contacts_in_category:
# Ersten Fund mit dieser URL hinzufügen
contact["Suchbegriffskategorie"] = category # Speichere die Kategorie, die den Treffer brachte
found_contacts_in_category[linkedin_url] = contact
else:
# Wenn der Kontakt schon gefunden wurde (andere Kategorie), füge die neue Kategorie hinzu (optional)
# Oder behalte einfach die erste Kategorie. Behalten wir die erste.
pass
# self.logger.debug(f" -> Gefunden: {contact.get('Vorname')} {contact.get('Nachname')} ({contact.get('Position')})")
except Exception as e_linkedin_search:
self.logger.error(f"FEHLER bei search_linkedin_contacts für Zeile {i} (Query: '{position_query}'): {e_linkedin_search}")
pass # Fahren Sie fort mit der nächsten Query
# Pause nach jeder SerpAPI Suche (pro Position_query)
serp_delay = getattr(Config, 'SERPAPI_DELAY', 1.5)
#self.logger.debug(f"Warte {serp_delay:.2f}s nach LinkedIn Suche für '{position_query}'...") # Zu viel Lärm
time.sleep(serp_delay)
# Zähle die eindeutigen Treffer in dieser Kategorie nach allen Queries
contact_counts_for_row[category] = len(found_contacts_in_category)
# Füge die eindeutigen Kontakte dieser Kategorie zur Gesamtliste für die Zeile hinzu
all_found_contacts_for_row.extend(found_contacts_in_category.values())
# --- Verarbeite gefundene Kontakte und bereite Updates vor ---
rows_to_append_to_contacts_sheet = [] # Zeilen für das 'Contacts' Blatt
main_sheet_updates_for_row = [] # Updates für das Hauptblatt (AI-AL, AM)
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Timestamp für diese Zeile/Kontakte
# Fügen Sie die Updates für die Trefferzahlen im Hauptblatt hinzu
# Stellen Sie sicher, dass die Spaltenbuchstaben korrekt sind (AI, AJ, AK, AL)
main_sheet_updates_for_row.append({'range': f'{ai_letter}{i}', 'values': [[str(contact_counts_for_row.get("Serviceleiter", 0))]]})
main_sheet_updates_for_row.append({'range': f'{aj_letter}{i}', 'values': [[str(contact_counts_for_row.get("IT-Leiter", 0))]]})
main_sheet_updates_for_row.append({'range': f'{ak_letter}{i}', 'values': [[str(contact_counts_for_row.get("Management", 0))]]})
main_sheet_updates_for_row.append({'range': f'{al_letter}{i}', 'values': [[str(contact_counts_for_row.get("Disponent", 0))]]})
# Setze den Contact Search Timestamp (AM)
main_sheet_updates_for_row.append({'range': f'{ts_am_letter}{i}', 'values': [[timestamp]]})
# Sammeln Sie diese Updates für das Hauptblatt
all_sheet_updates.extend(main_sheet_updates_for_row)
self.logger.info(f"Zeile {i}: Kontaktzahlen gesammelt: {contact_counts_for_row} Timestamp AM vorgemerkt.")
# Bereiten Sie die Zeilen für das 'Contacts' Blatt vor (falls es existiert)
if contacts_sheet:
unique_contacts_for_row = {c['LinkedInURL']: c for c in all_found_contacts_for_row}.values() # Endgültige Deduplizierung über alle Kategorien
for contact in unique_contacts_for_row:
firstname = contact.get("Vorname", "")
lastname = contact.get("Nachname", "")
# Nutzt globale Funktionen get_gender und get_email_address (utils.py)
gender_value = get_gender(firstname)
email = get_email_address(firstname, lastname, website) # Nutzt die Website der Firma
contact_row = [
contact.get("Firmenname", ""),
contact.get("CRM Kurzform", ""),
contact.get("Website", ""),
gender_value,
firstname,
lastname,
contact.get("Position", ""),
contact.get("Suchbegriffskategorie", ""), # Welche Kategorie brachte den Treffer
email,
contact.get("LinkedInURL", ""),
timestamp # Wann der Kontakt gefunden wurde
]
rows_to_append_to_contacts_sheet.append(contact_row)
if rows_to_append_to_contacts_sheet:
# Fügen Sie diese Zeilen zur globalen Liste der Kontakte hinzu, die später angefügt werden
all_contact_rows_to_append.extend(rows_to_append_to_contacts_sheet)
self.logger.debug(f" -> {len(rows_to_append_to_contacts_sheet)} eindeutige Kontakte für Zeile {i} zum Anfügen vorgemerkt.")
else:
self.logger.debug(f" -> Keine neuen Kontakte für Zeile {i} gefunden.")
# Sende gesammelte Sheet Updates (Hauptblatt) wenn das Update-Batch-Limit erreicht ist
# Updates pro Zeile sind 5 (AI-AL + AM). len(all_sheet_updates) / 5
rows_in_main_sheet_update_batch = len(all_sheet_updates) // 5
if rows_in_main_sheet_update_batch >= update_batch_row_limit:
self.logger.debug(f" Sende gesammelte Hauptblatt-Updates ({rows_in_main_sheet_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...")
# Nutzt die batch_update_cells Methode des Sheet Handlers mit Retry
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
if success:
self.logger.info(f" Hauptblatt-Update für {rows_in_main_sheet_update_batch} Zeilen erfolgreich.")
# Der Fehlerfall wird von batch_update_cells geloggt
# Leere die gesammelten Updates nach dem Senden
all_sheet_updates = []
# Eine längere Pause nach der Verarbeitung jeder Firma im Contact Search Modus
# Dieser Modus ist API-intensiv und sollte langsamer laufen.
pause_duration = getattr(Config, 'RETRY_DELAY', 10) * 0.8 # Längere Pause, z.B. 80% der Retry-Wartezeit
self.logger.debug(f"Warte {pause_duration:.2f}s nach Verarbeitung von Zeile {i}...")
time.sleep(pause_duration)
# --- Finale Sheet Updates (Hauptblatt) senden ---
if all_sheet_updates:
rows_in_final_main_sheet_update_batch = len(all_sheet_updates) // 5
self.logger.info(f"Sende FINALE gesammelte Hauptblatt-Updates ({rows_in_final_main_sheet_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...")
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
if success:
self.logger.info(f"FINALES Hauptblatt-Update erfolgreich.")
# Der Fehlerfall wird von batch_update_cells geloggt
# --- Finale Kontakte-Zeilen (Contacts Sheet) anfügen ---
if contacts_sheet and all_contact_rows_to_append:
self.logger.info(f"Füge {len(all_contact_rows_to_append)} gesammelte Kontaktzeilen an Blatt 'Contacts' an...")
try:
# append_rows ist effizienter als batch_update für viele neue Zeilen am Ende
# Nutzt den retry_on_failure Decorator indirekt, wenn sheet.append_rows ihn hat,
# oder wir könnten hier manuell retry hinzufügen.
# Gspread's append_rows wirft bei Fehlern Exceptions, die vom globalen Decorator
# (falls er die Methode umhüllt) oder hier manuell behandelt werden müssten.
# Lassen wir es erstmal so, dass es Exceptions wirft, die die main-Funktion fängt.
contacts_sheet.append_rows(all_contact_rows_to_append, value_input_option='USER_ENTERED')
self.logger.info(f"Anfügen von {len(all_contact_rows_to_append)} Kontaktzeilen erfolgreich.")
except Exception as e_append:
self.logger.error(f"FEHLER beim Anfügen von Kontaktzeilen an Blatt 'Contacts': {e_append}")
pass # Fahren Sie fort, der Rest des Skripts sollte nicht blockiert werden
self.logger.info(f"Modus 'contact_search' abgeschlossen. {processed_count} Zeilen verarbeitet (versucht), {skipped_count} Zeilen übersprungen.")
# Keine Pause nach diesem Modus nötig.
# --- Die nächsten Utility Methoden der DataProcessor Klasse folgen in den nächsten Teilen ---
# prepare_data_for_modeling method... (kommt in Teil 15)
# train_technician_model method... (kommt in Teil 15)
# process_website_details method... (kommt in Teil 16) # Optional/Experimentell
# process_wiki_updates_from_chatgpt method... (kommt in Teil 16)
# process_wiki_reextract_missing_an method... (kommt in Teil 16)
# ==========================================================================
# === Utility Methods (ML Data Prep & Training) ============================
# ==========================================================================
# Diese Methode wird in _process_single_row aufgerufen, wenn der ML-Schritt angefordert ist.
def _predict_technician_bucket(self, row_data):
"""
Führt eine Vorhersage des Servicetechniker-Buckets für eine einzelne Zeile
mit dem trainierten ML-Modell durch. Lädt das Modell und den Imputer bei
Bedarf.
Args:
row_data (list): Die Rohdaten für die Zeile.
Returns:
str: Der vorhergesagte Bucket-Label oder None bei Fehler/kein Ergebnis.
"""
self.logger.debug(f"Versuche ML-Schätzung für Zeile {self._get_cell_value_safe(row_data, 'CRM Name')[:30]}...")
# Laden Sie das Modell und den Imputer, falls noch nicht geschehen
# Dies stellt sicher, dass sie nur einmal geladen werden
if self.model is None or self.imputer is None:
self.logger.info("Lade ML-Modell und Imputer...")
try:
self._load_ml_model(MODEL_FILE, IMPUTER_FILE) # Nutzt interne Lademethode
if self.model is None or self.imputer is None:
self.logger.error("Laden von Modell oder Imputer fehlgeschlagen.")
return None # Abbruch, wenn Laden fehlschlägt
self.logger.info("ML-Modell und Imputer erfolgreich geladen.")
except Exception as e:
self.logger.error(f"FEHLER beim Laden von ML-Modell/Imputer: {e}")
return None # Abbruch bei Ladefehler
# Bereiten Sie die Daten für DIESE EINE ZEILE für die Vorhersage vor
try:
# Diese Logik ist ähnlich wie in prepare_data_for_modeling, aber nur für eine Zeile
# und muss mit den exakt gleichen Spalten und Encodings arbeiten wie das Training.
# Hole die benötigten Spaltenwerte für diese Zeile (basierend auf COLUMN_MAP keys)
row_values = {
"CRM Name": self._get_cell_value_safe(row_data, "CRM Name"),
"CRM Branche": self._get_cell_value_safe(row_data, "CRM Branche"),
"CRM Umsatz": self._get_cell_value_safe(row_data, "CRM Umsatz"),
"Wiki Umsatz": self._get_cell_value_safe(row_data, "Wiki Umsatz"),
"CRM Anzahl Mitarbeiter": self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter"),
"Wiki Mitarbeiter": self._get_cell_value_safe(row_data, "Wiki Mitarbeiter"),
# Technikerzahl wird für die Vorhersage NICHT benötigt, nur für Training
# "CRM Anzahl Techniker": self._get_cell_value_safe(row_data, "CRM Anzahl Techniker"),
}
# Erstelle einen temporären DataFrame für diese eine Zeile
df_single_row = pd.DataFrame([row_values])
# --- Konsolidieren Umsatz/Mitarbeiter (Wiki > CRM) ---
def get_valid_numeric_for_pred(value_str):
# Vereinfachte numerische Extraktion für Vorhersage
# Muss konsistent mit prepare_data_for_modeling sein!
if value_str is None or pd.isna(value_str) or str(value_str).strip() == '': return np.nan
try:
# Nutzt die Logik aus extract_numeric_value / get_valid_numeric
raw_value_str = str(value_str).strip()
processed_value = clean_text(raw_value_str)
if processed_value == "k.A.": return np.nan
processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|über|unter|mehr als|weniger als|bis zu)\s+', '', processed_value)
processed_value = re.sub(r'[€$£¥]', '', processed_value).strip()
processed_value = re.split(r'\s*(-||bis)\s*', processed_value, 1)[0].strip()
processed_value_no_thousands = processed_value.replace('.', '').replace("'", "")
processed_value_final = processed_value_no_thousands.replace(',', '.')
match = re.search(r'([\d.]+)', processed_value_final)
if not match: return np.nan
num_str = match.group(1)
if not num_str or num_str == '.': return np.nan
num = float(num_str)
original_lower = raw_value_str.lower()
multiplier = 1.0
if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower): multiplier = 1000000000.0
elif re.search(r'\bmio\s*\b|\bmillionen\s*\b|\bmill.\s*\b', original_lower): multiplier = 1000000.0
elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): multiplier = 1000.0
num = num * multiplier
return num if num > 0 else np.nan # Nur positive Werte
except Exception as e:
# logger.debug(f"Fehler in get_valid_numeric_for_pred für Wert '{str(value_str)[:50]}...': {e}") # Zu viel Lärm
return np.nan
df_single_row['Finaler_Umsatz'] = np.where(
df_single_row['Wiki Umsatz'].apply(get_valid_numeric_for_pred).notna(),
df_single_row['Wiki Umsatz'].apply(get_valid_numeric_for_pred),
df_single_row['CRM Umsatz'].apply(get_valid_numeric_for_pred)
)
df_single_row['Finaler_Mitarbeiter'] = np.where(
df_single_row['Wiki Mitarbeiter'].apply(get_valid_numeric_for_pred).notna(),
df_single_row['Wiki Mitarbeiter'].apply(get_valid_numeric_for_pred),
df_single_row['CRM Anzahl Mitarbeiter'].apply(get_valid_numeric_for_pred)
)
# --- Kategoriale Features (Branche) ---
branche_col_name = "CRM Branche" # Original Header Name
# Stelle sicher, dass die Spalte String ist und fülle evtl. NaNs
df_single_row[branche_col_name] = df_single_row[branche_col_name].astype(str).fillna('Unbekannt').str.strip()
# One-Hot Encoding
# WICHTIG: Muss alle BRANCHEN aus dem TRAININGSDATENSATZ enthalten,
# auch wenn diese in der einzelnen Zeile nicht vorkommen.
# Dummy-Spalten für fehlende Branchen müssen hinzugefügt werden!
# load_ml_model muss auch die Liste der Feature-Spalten speichern (inkl. aller Branche Dummies).
# Laden Sie die Liste der erwarteten Features (z.B. aus einer separaten Datei oder dem Imputer/Modell-Artefakt)
if not hasattr(self, '_expected_features') or self._expected_features is None:
self.logger.error("FEHLER: Erwartete Feature-Spalten für ML-Vorhersage nicht geladen.")
return None
# Führen Sie One-Hot Encoding durch
df_encoded = pd.get_dummies(df_single_row, columns=[branche_col_name], prefix='Branche', dummy_na=False)
# Fügen Sie fehlende Feature-Spalten hinzu und stellen Sie die Reihenfolge sicher
# Fehlende Spalten werden mit 0 gefüllt
missing_cols = set(self._expected_features) - set(df_encoded.columns)
for c in missing_cols:
df_encoded[c] = 0
# Stellen Sie sicher, dass die Spalten in der richtigen Reihenfolge sind (wie im Training)
df_processed = df_encoded[self._expected_features]
# --- Imputation der fehlenden Werte ---
# Muss konsistent mit dem Imputer aus dem Training sein
df_imputed = self.imputer.transform(df_processed)
df_imputed = pd.DataFrame(df_imputed, columns=self._expected_features) # Ergebnisse sind ein Numpy Array, konvertiere zurück zu DataFrame
# --- Vorhersage ---
# Das Decision Tree Modell erwartet die vorbereiteten und imputierten Features
if not self.model:
self.logger.error("FEHLER: ML-Modell ist nicht geladen.")
return None
prediction_proba = self.model.predict_proba(df_imputed)
# prediction_proba ist ein Array von Wahrscheinlichkeiten für jede Klasse
# Wir nehmen die Klasse mit der höchsten Wahrscheinlichkeit
predicted_class_index = np.argmax(prediction_proba[0])
predicted_bucket_label = self.model.classes_[predicted_class_index] # Holt das Label aus dem Modell
self.logger.debug(f" -> ML Vorhersage: {predicted_bucket_label} (Wahrscheinlichkeiten: {prediction_proba[0]})")
return predicted_bucket_label # Gibt das Label zurück
except Exception as e:
self.logger.exception(f"FEHLER bei der Datenvorbereitung/Vorhersage für Zeile (ML): {e}")
return "FEHLER Schätzung" # Signalisiert Fehler
def _load_ml_model(self, model_path, imputer_path):
"""Lädt das trainierte ML-Modell und den Imputer von der Festplatte."""
self.model = None
self.imputer = None
self._expected_features = None # Speicherliste der Feature-Spalten
try:
if not os.path.exists(model_path):
self.logger.error(f"ML-Modell Datei nicht gefunden: {model_path}")
return
if not os.path.exists(imputer_path):
self.logger.error(f"Imputer Datei nicht gefunden: {imputer_path}")
return
# Liste der Feature-Spalten sollte idealerweise auch gespeichert werden!
expected_features_path = PATTERNS_FILE_JSON # Annahme: JSON enthält die Spaltenliste
with open(model_path, 'rb') as f:
self.model = pickle.load(f)
self.logger.info(f"ML-Modell '{model_path}' erfolgreich geladen.")
# Modell-Klassen loggen zur Info
self.logger.debug(f"Geladene Modell-Klassen: {self.model.classes_}")
with open(imputer_path, 'rb') as f:
self.imputer = pickle.load(f)
self.logger.info(f"Imputer '{imputer_path}' erfolgreich geladen.")
if os.path.exists(expected_features_path):
with open(expected_features_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# Annahme: Die JSON-Datei enthält eine Liste der Feature-Spalten unter dem Key "feature_columns"
self._expected_features = data.get("feature_columns")
if self._expected_features and isinstance(self._expected_features, list):
self.logger.info(f"Erwartete Feature-Spalten ({len(self._expected_features)}) aus '{expected_features_path}' geladen.")
# self.logger.debug(f"Erwartete Features (erste 5): {self._expected_features[:5]}...") # Zu viel Lärm
else:
self.logger.error(f"Formatfehler in '{expected_features_path}' oder Key 'feature_columns' fehlt.")
self._expected_features = None # Setze auf None bei Fehler
else:
self.logger.warning(f"Datei mit erwarteten Feature-Spalten '{expected_features_path}' nicht gefunden. ML-Vorhersage könnte fehlschlagen.")
self._expected_features = None # Nicht gefunden
# Wenn expected_features nicht geladen werden konnte, versuchen Sie es aus Imputer/Modell zu extrahieren (wenn möglich)
if self._expected_features is None:
try:
# Scikit-learn Imputer/Model haben oft feature_names_in_
if hasattr(self.imputer, 'feature_names_in_') and self.imputer.feature_names_in_ is not None:
self._expected_features = list(self.imputer.feature_names_in_)
self.logger.info(f"Erwartete Feature-Spalten ({len(self._expected_features)}) aus Imputer geladen.")
elif hasattr(self.model, 'feature_names_in_') and self.model.feature_names_in_ is not None:
self._expected_features = list(self.model.feature_names_in_)
self.logger.info(f"Erwartete Feature-Spalten ({len(self._expected_features)}) aus Modell geladen.")
else:
self.logger.error("Konnte erwartete Feature-Spalten weder aus Datei noch aus Modell/Imputer extrahieren. ML-Vorhersage wird fehlschlagen.")
self._expected_features = None
except Exception as e_extract:
self.logger.error(f"FEHLER beim Extrahieren der Feature-Namen aus Modell/Imputer: {e_extract}")
self._expected_features = None
except Exception as e:
self.logger.exception(f"FEHLER beim Laden von ML-Artefakten: {e}")
self.model = None # Sicherstellen, dass Attribute None sind
self.imputer = None
self._expected_features = None
# Methode zur Datenvorbereitung für ML (WIRD VON train_technician_model aufgerufen)
# Übernommen aus prepare_data_for_modeling in Teil 12/13, angepasst als Methode.
def prepare_data_for_modeling(self):
"""
Lädt Daten aus dem Google Sheet über den sheet_handler,
bereitet sie für das Decision Tree Modell vor:
- Wählt relevante Spalten aus und benennt sie um.
- Konsolidiert Umsatz/Mitarbeiter (Wiki > CRM Priorität).
- Filtert nach gültiger Technikerzahl (> 0).
- Erstellt die Zielvariable (Techniker-Bucket).
- Bereitet Features auf (One-Hot Encoding für Branche).
- Behält NaNs in numerischen Features für spätere Imputation.
Returns:
pandas.DataFrame: Vorbereiteter DataFrame für Training/Test-Split,
oder None bei Fehlern.
"""
self.logger.info("Starte Datenvorbereitung für Modellierung (Training)...")
# Nutze den self.sheet_handler der Klasse
if not self.sheet_handler or not self.sheet_handler.sheet_values:
self.logger.error("Fehler: Sheet Handler nicht initialisiert oder keine Daten geladen für prepare_data_for_modeling.")
# Versuche die Daten einmalig innerhalb dieser Methode zu laden, falls sie fehlen
if not self.sheet_handler.load_data():
self.logger.critical("Konnte Daten auch nach erneutem Versuch nicht laden. Abbruch der Datenvorbereitung.")
return None
all_data = self.sheet_handler.get_all_data_with_headers() # Nutze die im Handler geladenen Daten
header_rows = self.sheet_handler._header_rows
# Prüfe auf ausreichende Zeilenzahl (Header + mindestens eine Datenzeile)
min_required_rows = header_rows + 1
if not all_data or len(all_data) < min_required_rows:
self.logger.error(f"Fehler: Nicht genügend Datenzeilen ({len(all_data)}) im Sheet gefunden für Modellierung (mindestens {min_required_rows} benötigt).")
return None
try:
# Die erste Zeile sollte die Spaltennamen enthalten
headers = all_data[0]
# Stelle sicher, dass die Header-Zeile auch die erwartete Mindestlänge hat
try:
max_col_idx_in_map = max(COLUMN_MAP.values())
if len(headers) <= max_col_idx_in_map:
self.logger.critical(f"FEHLER: Header-Zeile ({len(headers)} Spalten) ist kürzer als der höchste Index in COLUMN_MAP ({max_col_idx_in_map}). COLUMN_MAP passt nicht zum Sheet.")
return None
except ValueError: # Tritt auf, wenn COLUMN_MAP leer ist
self.logger.critical("FEHLER: COLUMN_MAP scheint leer zu sein. Kann Max Index nicht ermitteln.")
return None
except Exception as e:
self.logger.critical(f"FEHLER beim Prüfen der Spaltenlänge der Header-Zeile: {e}")
return None
except IndexError:
self.logger.critical("FEHLER: Sheet scheint leer zu sein oder hat keine erste Zeile, keine Header gefunden.")
return None
except Exception as e:
self.logger.critical(f"FEHLER beim Zugriff auf Header: {e}")
return None
data_rows = all_data[header_rows:] # Annahme: Die ersten X Zeilen sind Header
# Erstelle DataFrame
df = pd.DataFrame(data_rows, columns=headers)
self.logger.info(f"Initialen DataFrame für Modellierung erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.")
# --- Spaltenauswahl und Umbenennung ---
# Definiere die notwendigen Spalten anhand ihrer COLUMN_MAP Schlüssel
# und weisen ihnen interne, einfachere Namen zu.
col_keys_mapping = {
"name": "CRM Name", # Zur Identifikation, wird später entfernt
"branche_crm": "CRM Branche", # Für One-Hot Encoding
"umsatz_crm": "CRM Umsatz", # Für Konsolidierung
"umsatz_wiki": "Wiki Umsatz", # Für Konsolidierung
"ma_crm": "CRM Anzahl Mitarbeiter", # Für Konsolidierung
"ma_wiki": "Wiki Mitarbeiter", # Für Konsolidierung
"techniker": "CRM Anzahl Techniker" # DIE ZIELVARIABLE (Bekannte Technikerzahl)
}
# Überprüfe, ob alle benötigten Spalten in der COLUMN_MAP vorhanden sind
missing_keys_in_map = [key for key in col_keys_mapping.values() if key not in COLUMN_MAP]
if missing_keys_in_map:
self.logger.critical(f"FEHLER: Folgende benötigte Spalten-Schlüssel fehlen in COLUMN_MAP für prepare_data_for_modeling: {missing_keys_in_map}.")
return None
# Erstelle das Mapping von tatsächlichen Header-Namen zu internen Schlüsseln
# Verwende die Header-Namen aus dem geladenen Sheet und die COLUMN_MAP, um die richtigen Header zu finden
header_to_internal_key = {}
cols_to_select_by_header = [] # Liste der Header-Namen, die aus dem DF ausgewählt werden
try:
for internal_key, column_map_key in col_keys_mapping.items():
header_name_from_sheet = headers[COLUMN_MAP[column_map_key]]
header_to_internal_key[header_name_from_sheet] = internal_key
cols_to_select_by_header.append(header_name_from_sheet)
# Wähle nur die benötigten Spalten im DataFrame aus
df_subset = df[cols_to_select_by_header].copy() # Kopie erstellen
# Benenne die Spalten um
df_subset.rename(columns=header_to_internal_key, inplace=True)
except KeyError as e:
# Dieser Fehler sollte eigentlich durch die obige Prüfung abgefangen werden,
# tritt aber auf, wenn ein erwarteter Header-Name nicht im geladenen DF ist (selten, wenn COLUMN_MAP korrekt ist)
self.logger.critical(f"FEHLER beim Auswählen/Umbenennen der Spalten (KeyError: '{e}'). Der Header wurde nicht im DataFrame gefunden.")
self.logger.debug(f"Erwartete Header: {cols_to_select_by_header}. Verfügbare Header im DF: {list(df.columns)}")
return None
except IndexError as e:
# Tritt auf, wenn COLUMN_MAP einen Index > Anzahl Spalten im DF hat
self.logger.critical(f"FEHLER beim Auswählen/Umbenennen der Spalten (IndexError: '{e}'). COLUMN_MAP zeigt auf Spalten, die nicht im geladenen Sheet existieren.")
self.logger.debug(f"COLUMN_MAP: {COLUMN_MAP}. Sheet hat {len(headers)} Spalten.")
return None
except Exception as e:
self.logger.critical(f"Unerwarteter FEHLER beim Auswählen/Umbenennen der Spalten: {e}")
self.logger.debug(traceback.format_exc())
return None
self.logger.info(f"Benötigte Spalten für Modellierung ausgewählt und umbenannt: {list(df_subset.columns)}")
# --- Features konsolidieren (Umsatz, Mitarbeiter) ---
# Nutzt die globale Hilfsfunktion get_valid_numeric, die numerische Werte als Float/Int oder NaN zurückgibt
cols_to_process = {
'Umsatz': ('umsatz_wiki', 'umsatz_crm', 'Finaler_Umsatz'),
'Mitarbeiter': ('ma_wiki', 'ma_crm', 'Finaler_Mitarbeiter')
}
for base_name, (wiki_col, crm_col, final_col) in cols_to_process.items():
self.logger.debug(f"Verarbeite und konsolidiere '{base_name}' (Priorität: Wiki > CRM)...")
# Sicherstellen, dass die Spalten im df_subset existieren, bevor apply aufgerufen wird
# Dies sollte durch die Spaltenauswahl oben garantiert sein, aber zur Sicherheit
wiki_series = df_subset[wiki_col].apply(get_valid_numeric) if wiki_col in df_subset.columns else pd.Series(np.nan, index=df_subset.index)
crm_series = df_subset[crm_col].apply(get_valid_numeric) if crm_col in df_subset.columns else pd.Series(np.nan, index=df_subset.index)
df_subset[final_col] = np.where(
wiki_series.notna(), # Wenn Wiki-Wert vorhanden ist (nicht NaN)
wiki_series, # Nimm den Wiki-Wert
crm_series # Sonst nimm den CRM-Wert (der auch NaN sein kann)
)
# Info-Log über Ergebnis
self.logger.info(f" -> {df_subset[final_col].notna().sum()} gültige '{final_col}' Werte erstellt (von {len(df_subset)} Zeilen).")
# --- Zielvariable vorbereiten (Technikerzahl) ---
techniker_col_internal = "techniker" # Interne Spaltenname nach Umbenennung
self.logger.info(f"Verarbeite Zielvariable '{techniker_col_internal}'...")
# Sicherstellen, dass die Spalte existiert
if techniker_col_internal not in df_subset.columns:
self.logger.critical(f"FEHLER: Zielvariable '{techniker_col_internal}' (CRM Anzahl Techniker) nicht im DataFrame gefunden nach Umbenennung.")
return None
# Konvertiere zu Numerisch (Fehler -> NaN)
# Verwende get_valid_numeric, um positive Float-Werte oder NaN zu erhalten
df_subset['Anzahl_Servicetechniker_Numeric'] = df_subset[techniker_col_internal].apply(get_valid_numeric)
# Filtere Zeilen: Behalte nur die mit gültiger, positiver Technikerzahl (float > 0)
initial_rows = len(df_subset)
# Hier filtern wir basierend auf der numerischen Spalte, die durch get_valid_numeric erstellt wurde
df_filtered = df_subset[
df_subset['Anzahl_Servicetechniker_Numeric'].notna() &
(df_subset['Anzahl_Servicetechniker_Numeric'] > 0)
].copy() # WICHTIG: .copy() um SettingWithCopyWarning zu vermeiden
filtered_rows = len(df_filtered)
removed_rows = initial_rows - filtered_rows
# Info, wenn Zeilen entfernt wurden
if removed_rows > 0:
self.logger.info(f"{removed_rows} Zeilen entfernt aufgrund fehlender/ungültiger Technikerzahl (Wert <= 0 oder nicht numerisch/parsebar).")
self.logger.info(f"Verbleibende Zeilen für Modellierungstraining (mit gültiger Technikerzahl > 0): {filtered_rows}")
if filtered_rows == 0:
self.logger.error("FEHLER: Keine Zeilen mit gültiger Technikerzahl (>0) übrig für Modellierungstraining!")
return None
# --- Techniker-Buckets erstellen ---
# Die Bins und Labels müssen die gefilterten Daten widerspiegeln (die jetzt alle > 0 sind).
# Die Bin-Definition muss so sein, dass alle Werte > 0 einem Bucket zugeordnet werden.
# Beispiel: (-1, 0] -> Bucket 1 (0), (0, 19] -> Bucket 2 (<20), (19, 49] -> Bucket 3 (<50) etc.
# Da wir auf >0 filtern, landet 0 nie im Trainingsset, aber die Bin-Definition muss trotzdem Sinn ergeben.
bins = [-1, 0, 19, 49, 99, 249, 499, float('inf')]
labels = ['Bucket_1_(0)', 'Bucket_2_(<20)', 'Bucket_3_(<50)', 'Bucket_4_(<100)', 'Bucket_5_(<250)', 'Bucket_6_(<500)', 'Bucket_7_(>499)']
try:
df_filtered['Techniker_Bucket'] = pd.cut(
df_filtered['Anzahl_Servicetechniker_Numeric'],
bins=bins,
labels=labels,
right=True, # Intervalle sind (links, rechts]. (0, 19] inkludiert 19.
include_lowest=True # Inkludiert den niedrigsten Wert der ersten Bin (-1)
)
self.logger.info("Techniker-Buckets erstellt.")
# Prüfe, ob NaNs in Buckets erstellt wurden (sollte bei >0 Filterung und korrekten Bins nicht passieren)
if df_filtered['Techniker_Bucket'].isna().any():
nan_bucket_rows = df_filtered['Techniker_Bucket'].isna().sum()
self.logger.warning(f"WARNUNG: {nan_bucket_rows} Zeilen mit NaNs in Techniker-Buckets nach pd.cut erstellt. Überprüfen Sie die bins/labels oder die Filterung.")
# Entfernen Sie diese Zeilen, da sie nicht zum Trainieren verwendet werden können
df_filtered.dropna(subset=['Techniker_Bucket'], inplace=True)
self.logger.info(f"Nach Entfernung von {nan_bucket_rows} Zeilen mit NaN Buckets: {len(df_filtered)} Zeilen verbleiben für Training.")
if len(df_filtered) == 0:
self.logger.error("FEHLER: Keine Zeilen übrig nach Entfernung von NaN Buckets. Modell kann nicht trainiert werden.")
return None
# Verteilung als Info-Log
self.logger.info(f"Verteilung der Techniker-Buckets im Trainingsdatensatz ({len(df_filtered)} Zeilen):\n{df_filtered['Techniker_Bucket'].value_counts(normalize=False).sort_index()}") # Zählung
self.logger.info(f"Verteilung (Prozent):\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True).sort_index().round(3)}") # Prozent
except Exception as e:
self.logger.critical(f"FEHLER beim Erstellen der Techniker-Buckets: {e}")
self.logger.debug(traceback.format_exc())
return None
# --- Kategoriale Features vorbereiten (Branche) ---
branche_col_internal = "branche_crm" # Interne Spaltenname nach Umbenennung
self.logger.info(f"Verarbeite kategoriales Feature '{branche_col_internal}' für One-Hot Encoding...")
# Sicherstellen, dass die Spalte existiert
if branche_col_internal not in df_filtered.columns:
self.logger.critical(f"FEHLER: Spalte '{branche_col_internal}' nicht im DataFrame für One-Hot Encoding gefunden.")
return None
# Stelle sicher, dass die Spalte String ist und fülle evtl. NaNs mit 'Unbekannt'
df_filtered[branche_col_internal] = df_filtered[branche_col_internal].astype(str).fillna('Unbekannt').str.strip()
# One-Hot Encoding
# dummy_na=False, da wir NaNs gefüllt haben.
# prefix='Branche' ist gut.
df_encoded = pd.get_dummies(df_filtered, columns=[branche_col_internal], prefix='Branche', dummy_na=False)
self.logger.info(f"One-Hot Encoding für '{branche_col_internal}' durchgeführt. Neue Spaltenanzahl: {len(df_encoded.columns)}")
# --- Finale Auswahl der Features für das Modell ---
# Identifizieren Sie die Feature-Spalten nach dem Encoding
feature_columns = [col for col in df_encoded.columns if col.startswith('Branche_')] # Alle One-Hot Branch-Spalten
# Fügen Sie die konsolidierten numerischen Spalten hinzu
feature_columns.extend(['Finaler_Umsatz', 'Finaler_Mitarbeiter'])
# Prüfen Sie, ob die konsolidierten numerischen Spalten existieren (sollten sie, wurden oben erstellt)
if not all(col in df_encoded.columns for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter']):
self.logger.critical("FEHLER: Konsolidierte numerische Spalten 'Finaler_Umsatz' oder 'Finaler_Mitarbeiter' fehlen im DataFrame nach Konsolidierung.")
return None
target_column = 'Techniker_Bucket' # Zielvariable
# Erstellen Sie den finalen DataFrame nur mit Features und Target
# Behalten Sie 'name' und 'Anzahl_Servicetechniker_Numeric' für Reporting/Debugging
identification_cols = ['name', 'Anzahl_Servicetechniker_Numeric']
# Sicherstellen, dass diese Spalten existieren
if not all(col in df_encoded.columns for col in identification_cols):
self.logger.critical(f"FEHLER: Identifikationsspalten {identification_cols} fehlen im DataFrame.")
return None
# Erstellen Sie den finalen DF
# Stellen Sie sicher, dass alle Feature-Spalten auch wirklich im DataFrame sind
# (Könnte fehlen, wenn z.B. Finaler_Umsatz/Mitarbeiter oben fehlschlug)
final_cols = identification_cols + feature_columns + [target_column]
missing_final_cols = [col for col in final_cols if col not in df_encoded.columns]
if missing_final_cols:
self.logger.critical(f"FEHLER: Finale Spalten für Modellierung fehlen im DataFrame: {missing_final_cols}")
return None
df_model_ready = df_encoded[final_cols].copy()
# Optional: Konvertieren Sie numerische Spalten explizit zu Float64
for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter', 'Anzahl_Servicetechniker_Numeric']:
if col in df_model_ready.columns: # Sicherheitscheck
# errors='coerce' wandelt Fehler in NaN. Wichtig, da Imputer NaNs erwartet.
df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce')
# Reset Index für saubere Verarbeitung
df_model_ready = df_model_ready.reset_index(drop=True)
self.logger.info("Datenvorbereitung für Modellierung abgeschlossen.")
self.logger.info(f"Finaler DataFrame für Modellierung hat {len(df_model_ready)} Zeilen und {len(df_model_ready.columns)} Spalten.")
self.logger.info(f"Anzahl Feature-Spalten: {len(feature_columns)}")
self.logger.info(f"Ziel-Spalte: {target_column}")
# WICHTIG: Info über fehlende Werte in den finalen numerischen Features vor Imputation
# Imputer wird im Trainingsschritt angewendet.
numeric_features_for_imputation = ['Finaler_Umsatz', 'Finaler_Mitarbeiter']
nan_counts = df_model_ready[numeric_features_for_imputation].isna().sum()
self.logger.info(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}")
rows_with_nan = df_model_ready[numeric_features_for_imputation].isna().any(axis=1).sum()
self.logger.info(f"Anzahl Zeilen mit mindestens einem fehlenden numerischen Feature (vor Imputation): {rows_with_nan}")
return df_model_ready
# Methode zum Trainieren des ML Modells
# Übernommen aus Ihrem Code (Teil 2 Beispiel?), angepasst als Methode.
def train_technician_model(self, model_out=MODEL_FILE, imputer_out=IMPUTER_FILE, patterns_out=PATTERNS_FILE_TXT):
"""
Trainiert ein Decision Tree Modell zur Schätzung der Servicetechniker-Buckets.
Speichert das Modell, den Imputer und die Feature-Spalten.
Args:
model_out (str): Dateipfad zum Speichern des trainierten Modells (.pkl).
imputer_out (str): Dateipfad zum Speichern des trainierten Imputers (.pkl).
patterns_out (str): Dateipfad zum Speichern der Feature-Spaltenliste (.txt oder .json).
"""
self.logger.info("Starte Training des Servicetechniker Decision Tree Modells...")
# 1. Daten vorbereiten (nutzt die interne Methode)
df_model_ready = self.prepare_data_for_modeling()
if df_model_ready is None:
self.logger.error("Datenvorbereitung für Modelltraining fehlgeschlagen. Training abgebrochen.")
return
# Separate Features (X) und Target (y)
# Identifikationsspalten entfernen
identification_cols = ['name', 'Anzahl_Servicetechniker_Numeric'] # Muss konsistent mit prepare_data_for_modeling sein
target_column = 'Techniker_Bucket' # Muss konsistent sein
# Feature Spalten sind alle außer Identifikation und Target
feature_columns = [col for col in df_model_ready.columns if col not in identification_cols and col != target_column]
# Stellen Sie sicher, dass es Feature-Spalten gibt
if not feature_columns:
self.logger.critical("FEHLER: Keine Feature-Spalten nach Datenvorbereitung gefunden. Training nicht möglich.")
return
X = df_model_ready[feature_columns]
y = df_model_ready[target_column]
self.logger.info(f"Daten für Training vorbereitet. X Shape: {X.shape}, y Shape: {y.shape}")
self.logger.debug(f"Feature Spalten für Training ({len(feature_columns)}): {feature_columns[:10]}...") # Logge erste 10 Features
# 2. Split in Training und Test Set
# test_size anpassen, random_state für Reproduzierbarkeit
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y) # Stratify für gleiche Bucket-Verteilung
self.logger.info(f"Daten gesplittet. Train Set: {len(X_train)} Zeilen, Test Set: {len(X_test)} Zeilen.")
# 3. Imputation (Fehlende Werte ersetzen)
# Verwenden Sie SimpleImputer (z.B. Median), um NaN-Werte zu ersetzen
# Fitten Sie den Imputer nur auf den Trainingsdaten, aber transformieren Sie beide
imputer = SimpleImputer(strategy='median') # Median ist robust gegenüber Ausreißern
self.logger.info(f"Fitte Imputer mit Strategie '{imputer.strategy}' auf Trainingsdaten...")
imputer.fit(X_train) # Fitten Sie den Imputer auf X_train
# Speichern Sie den Imputer (wird für Vorhersagen benötigt)
self.imputer = imputer # Speichern Sie ihn in der Instanz
try:
with open(imputer_out, 'wb') as f:
pickle.dump(imputer, f)
self.logger.info(f"Imputer erfolgreich gespeichert in '{imputer_out}'.")
except Exception as e:
self.logger.error(f"FEHLER beim Speichern des Imputers in '{imputer_out}': {e}")
# Fahren Sie fort, aber loggen Sie den Fehler
# Transformieren Sie Trainings- und Testdaten
X_train_imputed = imputer.transform(X_train)
X_test_imputed = imputer.transform(X_test)
# Konvertieren Sie die Ergebnisse (Numpy Arrays) zurück zu DataFrames, behalten Sie die Spaltennamen
X_train_imputed = pd.DataFrame(X_train_imputed, columns=feature_columns)
X_test_imputed = pd.DataFrame(X_test_imputed, columns=feature_columns)
self.logger.info("Numerische Features imputiert.")
# 4. Decision Tree Training
# Definieren Sie das Modell
# max_depth, min_samples_split, min_samples_leaf können getunt werden
# class_weight='balanced' ist hilfreich bei ungleicher Klassenverteilung (wahrscheinlich bei Buckets)
dt_classifier = DecisionTreeClassifier(random_state=42, class_weight='balanced')
self.logger.info("Starte Training des Decision Tree Modells...")
# Fitten Sie das Modell auf den imputierten Trainingsdaten
dt_classifier.fit(X_train_imputed, y_train)
self.logger.info("Modelltraining abgeschlossen.")
# Speichern Sie das trainierte Modell
self.model = dt_classifier # Speichern Sie es in der Instanz
try:
with open(model_out, 'wb') as f:
pickle.dump(dt_classifier, f)
self.logger.info(f"Decision Tree Modell erfolgreich gespeichert in '{model_out}'.")
except Exception as e:
self.logger.error(f"FEHLER beim Speichern des Modells in '{model_out}': {e}")
# Fahren Sie fort
# Speichern Sie die Liste der Feature-Spalten (für die Vorhersage)
self._expected_features = feature_columns # Speichern Sie diese Liste in der Instanz
try:
# Speichern als JSON für bessere Lesbarkeit
patterns_data = {"feature_columns": feature_columns, "target_classes": list(dt_classifier.classes_)}
patterns_out_json = patterns_out.replace('.txt', '.json') # Speichern Sie es als JSON
with open(patterns_out_json, 'w', encoding='utf-8') as f:
json.dump(patterns_data, f, indent=4, ensure_ascii=False)
self.logger.info(f"Erwartete Feature-Spalten und Klassen erfolgreich gespeichert in '{patterns_out_json}'.")
# Optional: Speichern als einfache Textdatei (wie im Originalcode)
# with open(patterns_out, 'w', encoding='utf-8') as f:
# for col in feature_columns: f.write(f"{col}\n")
# self.logger.info(f"Erwartete Feature-Spalten (txt) erfolgreich gespeichert in '{patterns_out}'.")
except Exception as e:
self.logger.error(f"FEHLER beim Speichern der Feature-Spalten in '{patterns_out_json}': {e}")
# Fahren Sie fort
# 5. Evaluation (Optional, aber empfohlen)
self.logger.info("Starte Modellevaluation...")
# Vorhersagen auf dem Testset
y_pred = dt_classifier.predict(X_test_imputed)
# Metriken berechnen und loggen
accuracy = accuracy_score(y_test, y_pred)
self.logger.info(f"Modell Genauigkeit auf dem Testset: {accuracy:.4f}")
# Klassifikationsbericht
# Zero_division='warn' ist Standard, '0' gibt 0 für nicht vorhandene Klassen, 'none' wirft Fehler
class_report = classification_report(y_test, y_pred, zero_division=0)
self.logger.info(f"Klassifikationsbericht auf dem Testset:\n{class_report}")
# Konfusionsmatrix
# display_labels=dt_classifier.classes_ sorgt für korrekte Beschriftung
cm = confusion_matrix(y_test, y_pred, labels=dt_classifier.classes_)
self.logger.info(f"Konfusionsmatrix auf dem Testset:\n{cm}")
# Entscheidungsregeln extrahieren (Optional)
try:
# Beschränken Sie die Tiefe für die Ausgabe, falls der Baum sehr tief ist
tree_rules = export_text(dt_classifier, feature_names=feature_columns, max_depth=5) # max_depth anpassen
self.logger.info(f"Erste Regeln des Decision Tree (max Tiefe 5):\n{tree_rules}")
except Exception as e:
self.logger.warning(f"FEHLER beim Exportieren der Baumregeln: {e}")
self.logger.info("Modelltraining und -evaluation abgeschlossen.")
# --- Die nächsten Utility Methoden der DataProcessor Klasse folgen in den nächsten Teilen ---
# process_website_details method... (kommt in Teil 16) # Optional/Experimentell
# process_wiki_updates_from_chatgpt method... (kommt in Teil 16)
# process_wiki_reextract_missing_an method... (kommt in Teil 16)
# ==========================================================================
# === Utility Methods (Other Specific Tasks) ===============================
# ==========================================================================
# --- Methode für experimentelle Website Details ---
# Übernommen aus process_website_details_for_marked_rows in Teil 12, angepasst als Methode.
# Diese Funktion ist als experimentelles Dienstprogramm gedacht.
# Annahme: Eine globale Funktion scrape_website_details existiert (oder muss erstellt werden).
def process_website_details(self, start_sheet_row=None, end_sheet_row=None, limit=None):
"""
EXPERIMENTELL: Extrahiert Website-Details für Zeilen, die mit 'x' in Spalte A markiert sind.
Schreibt die Details in eine definierte Spalte (Website Details oder AR als Fallback).
Löscht NICHT das 'x'-Flag.
Args:
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (ab Zeile 7).
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht übersprungener) Zeilen. Defaults to None.
"""
self.logger.warning(f"Starte Modus (EXPERIMENTELL): Website Detail Extraction für Zeilen mit 'x' in Spalte A. Bereich: {start_sheet_row}-{end_sheet_row}, Limit: {limit if limit is not None else 'Unbegrenzt'}...")
self.logger.warning("Hinweis: Dieser Modus erfordert die Implementierung der globalen Funktion 'scrape_website_details(url)'.")
# --- Daten laden ---
# Laden Sie Daten, aber es ist kein automatischer Startindex-Check nötig,
# da wir explizit nach 'x' suchen.
if not self.sheet_handler.load_data():
self.logger.error("Fehler beim Laden der Daten für Website Details Extraction.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = self.sheet_handler._header_rows
total_sheet_rows = len(all_data)
# Standard Startzeile, wenn nicht angegeben
if start_sheet_row is None: start_sheet_row = header_rows + 1 # Standardmäßig ab erster Datenzeile
# Berechne Endzeile, wenn nicht gesetzt
if end_sheet_row is None: end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
self.logger.info(f"Suchbereich für 'x'-Flag: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}")
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.")
return
# --- Indizes und Buchstaben ---
required_keys = ["ReEval Flag", "CRM Website"]
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
if None in col_indices.values():
missing = [k for k, v in col_indices.items() if v is None]
self.logger.critical(f"FEHLER: Benötigte Spaltenschlüssel fehlen in COLUMN_MAP für process_website_details: {missing}. Breche ab.")
return
reeval_col_idx = col_indices["ReEval Flag"]
website_col_idx = col_indices["CRM Website"]
# Bestimme die Zielspalte für die Details
details_col_idx = COLUMN_MAP.get("Website Details") # Versuche zuerst die dedizierte Spalte
details_col_key = "Website Details" # Für Logging
if details_col_idx is None:
# Fallback auf 'Website Rohtext' (AR)
details_col_idx = COLUMN_MAP.get("Website Rohtext")
details_col_key = "Website Rohtext"
if details_col_idx is None:
self.logger.critical("FEHLER: Weder 'Website Details' noch 'Website Rohtext' Spaltenindex in COLUMN_MAP gefunden.")
return
self.logger.warning(f"Keine Spalte 'Website Details' in COLUMN_MAP, nutze '{details_col_key}' ({self.sheet_handler._get_col_letter(details_col_idx+1)}) als Fallback.")
else:
self.logger.info(f"Nutze Spalte '{details_col_key}' ({self.sheet_handler._get_col_letter(details_col_idx+1)}) für Website Details.")
details_col_letter = self.sheet_handler._get_col_letter(details_col_idx + 1)
# --- Verarbeitung ---
all_sheet_updates = [] # Gesammelte Updates für Batch-Schreiben
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) # Update Batch Größe aus Config
processed_count = 0 # Zählt Zeilen, die im Batch verarbeitet (versucht) wurden
skipped_count = 0 # Zählt Zeilen, die übersprungen wurden (nicht markiert oder fehlende URL)
# Iteriere durch die Datenzeilen im definierten Bereich
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht
row = all_data[row_index_in_list]
# Stellen Sie sicher, dass die Zeile nicht leer ist
if not any(cell and cell.strip() for cell in row):
#self.logger.debug(f"Zeile {i}: Übersprungen (Leere Zeile).") # Zu viel Lärm
skipped_count += 1
continue
# --- Prüfung, ob Verarbeitung für diese Zeile nötig ist ---
# Kriterium: Zeile ist mit 'x' in Spalte A markiert
# UND Website URL (D) ist vorhanden und nicht "k.A.".
# Prüfen, ob die Zeile mit 'x' in Spalte A markiert ist
cell_a_value = self._get_cell_value_safe(row, "ReEval Flag").strip().lower()
is_marked_for_reeval = cell_a_value == "x"
if not is_marked_for_reeval:
skipped_count += 1
continue
# Prüfen, ob eine gültige Website-URL vorhanden ist
website_url = self._get_cell_value_safe(row, "CRM Website").strip()
website_url_is_valid_looking = website_url and website_url.lower() not in ["k.a.", "kein artikel gefunden"]
processing_needed_for_row = is_marked_for_reeval and website_url_is_valid_looking
# Loggen der Prüfergebnisse auf Debug
log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
if log_check:
self.logger.debug(f"Zeile {i} (Website Details Check): A='x'? {is_marked_for_reeval}, D gültig? {website_url_is_valid_looking}. Benötigt Verarbeitung? {processing_needed_for_row}")
if not processing_needed_for_row:
skipped_count += 1
continue
# --- Wenn Verarbeitung nötig: Führe Details-Extraktion aus ---
processed_count += 1 # Zähle die Zeile, die verarbeitet wird (zum Limit zählen)
# Prüfe das Limit für verarbeitete Zeilen
if limit is not None and processed_count > limit:
self.logger.info(f"Verarbeitungslimit ({limit}) für process_website_details erreicht. Breche weitere Zeilenprüfung ab.")
break # Schleife abbrechen
self.logger.info(f"Zeile {i}: Extrahiere Website Details von {website_url}...")
details = "FEHLER: Funktion 'scrape_website_details' nicht definiert" # Default Fehler
try:
# Annahme: Globale Funktion scrape_website_details existiert (oder muss erstellt werden)
# Sie muss eine URL nehmen und einen String oder ein serialisierbares Objekt zurückgeben
# Sie sollte interne Fehler behandeln und im Fehlerfall einen Fehlerstring zurückgeben
details = scrape_website_details(website_url) # <<< DIESE FUNKTION MUSS IMPLEMENTIERT WERDEN!
except NameError:
self.logger.critical("FEHLER: Funktion 'scrape_website_details' ist nicht definiert! Kann Details nicht extrahieren.")
# Fehlertext bleibt der Initialwert
except Exception as e_detail:
self.logger.exception(f"FEHLER bei scrape_website_details für {website_url}: {e_detail}")
details = f"FEHLER Extraktion: {str(e_detail)[:100]}" # Kürze Fehlermeldung
# Füge Update für die Details-Spalte hinzu
# Stelle sicher, dass der Wert in einen String konvertiert wird, falls scrape_website_details z.B. ein Dict zurückgibt
updates_for_row = []
updates_for_row.append({'range': f'{details_col_letter}{i}', 'values': [[str(details)]]})
self.logger.info(f"Zeile {i}: Details extrahiert und zum Update für Spalte {details_col_key} ({details_col_letter}{i}) hinzugefügt.")
# Sammle diese Updates
all_sheet_updates.extend(updates_for_row)
# Sende gesammelte Sheet Updates wenn das Update-Batch-Limit erreicht ist
# Updates pro Zeile ist 1 in diesem Modus.
if len(all_sheet_updates) >= update_batch_row_limit:
self.logger.debug(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...")
# Nutzt die batch_update_cells Methode des Sheet Handlers mit Retry
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
if success:
self.logger.info(f" Sheet-Update für {len(all_sheet_updates)} Zellen erfolgreich.")
# Der Fehlerfall wird von batch_update_cells geloggt
# Leere die gesammelten Updates nach dem Senden
all_sheet_updates = []
# Kleine Pause nach jeder Extraktion (nutzt Config)
pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.2
#self.logger.debug(f"Warte {pause_duration:.2f}s nach Extraktion...") # Zu viel Lärm
time.sleep(pause_duration)
# --- Finale Sheet Updates senden ---
# Sende alle verbleibenden gesammelten Updates
if all_sheet_updates:
self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...")
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
if success:
self.logger.info(f"FINALES Sheet-Update erfolgreich.")
# Der Fehlerfall wird von batch_update_cells geloggt
self.logger.info(f"Modus 'website_details' abgeschlossen. {processed_count} Zeilen verarbeitet (versucht), {skipped_count} Zeilen übersprungen.")
# Keine Pause nach diesem Modus nötig.
# --- Methode zum Verarbeiten von Wiki-Updates basierend auf ChatGPT Vorschlägen ---
# Übernommen aus process_wiki_updates_from_chatgpt in Teil 4, angepasst als Methode.
def process_wiki_updates_from_chatgpt(self, start_sheet_row=None, end_sheet_row=None, limit=None):
"""
Identifiziert Zeilen, in denen Status S gesetzt ist, aber NICHT auf einem Endzustand
(OK, X (UPDATED/COPIED/INVALID)), prüft ob U eine *valide* und *andere* Wiki-URL ist.
- Wenn ja: Kopiert U->M, markiert S='X (URL Copied)', U='URL übernommen', löscht
abhängige Wiki-Spalten (N-V, AN, AO, AP, AX), setzt ReEval-Flag A='x'.
- Wenn nein (U keine URL, U==M, oder U ungültig): LÖSCHT den Inhalt von U und
markiert S als 'X (Invalid Suggestion)'.
Verarbeitet maximal limit Zeilen.
Args:
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (ab Zeile 7).
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
limit (int, optional): Maximale Anzahl ZU PRÜFENDER Zeilen. Defaults to None.
"""
self.logger.info(f"Starte Modus: Wiki-Updates (URL-Validierung & Löschen ungültiger Vorschläge). Bereich: {start_sheet_row}-{end_sheet_row}, Limit: {limit if limit is not None else 'Unbegrenzt'}...")
# --- Daten laden ---
# Laden Sie Daten. Kein automatischer Startindex-Check nötig,
# da wir nach Status S suchen.
if not self.sheet_handler.load_data():
self.logger.error("Fehler beim Laden der Daten für Wiki Updates.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = self.sheet_handler._header_rows
total_sheet_rows = len(all_data)
# Standard Startzeile, wenn nicht angegeben
if start_sheet_row is None: start_sheet_row = header_rows + 1 # Standardmäßig ab erster Datenzeile
# Berechne Endzeile, wenn nicht gesetzt
if end_sheet_row is None: end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
self.logger.info(f"Suchbereich für Status S: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}")
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.")
return
# --- Indizes und Buchstaben ---
required_keys = [
"Chat Wiki Konsistenzprüfung", "Chat Vorschlag Wiki Artikel", "Wiki URL", # Prüfkriterien / Daten
"Wikipedia Timestamp", "Wiki Verif. Timestamp", "Timestamp letzte Prüfung", "Version", # Spalten zum Leeren
"ReEval Flag", # ReEval Flag setzen
"Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", # N-R zum Leeren
"Chat Begründung Wiki Inkonsistenz", "Begründung bei Abweichung" # T-V zum Leeren
]
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
if None in col_indices.values():
missing = [k for k, v in col_indices.items() if v is None]
self.logger.critical(f"FEHLER: Benötigte Spaltenschlüssel fehlen in COLUMN_MAP für process_wiki_updates_from_chatgpt: {missing}. Breche ab.")
return
# Spaltenbuchstaben für Updates/Leerung
s_letter = self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"] + 1) # Status S
u_letter = self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"] + 1) # Vorschlag U
m_letter = self.sheet_handler._get_col_letter(col_indices["Wiki URL"] + 1) # Wiki URL M
a_letter = self.sheet_handler._get_col_letter(col_indices["ReEval Flag"] + 1) # ReEval Flag A
# Spalten N-V leeren
n_idx = col_indices["Wiki Absatz"]
v_idx = col_indices["Begründung bei Abweichung"]
n_letter = self.sheet_handler._get_col_letter(n_idx + 1)
v_letter = self.sheet_handler._get_col_letter(v_idx + 1)
nv_range_letter = f'{n_letter}:{v_letter}' # z.B. N:V
empty_nv_values = [''] * (v_idx - n_idx + 1) # Anzahl der Spalten
# Timestamps AN, AO, AX, Version AP leeren
an_letter = self.sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"] + 1)
ao_letter = self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Prüfung"] + 1)
ap_letter = self.sheet_handler._get_col_letter(col_indices["Version"] + 1)
ax_letter = self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"] + 1)
# --- Verarbeitung ---
all_sheet_updates = [] # Gesammelte Updates für Batch-Schreiben
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) # Update Batch Größe aus Config
processed_rows_count = 0 # Zählt Zeilen, die geprüft werden (zum Limit zählen)
skipped_count = 0 # Zählt Zeilen, die übersprungen werden (Status S im Endzustand etc.)
updated_url_count = 0 # Zählt Zeilen, wo URL kopiert wurde
cleared_suggestion_count = 0 # Zählt Zeilen, wo Vorschlag gelöscht wurde
# Iteriere durch die Datenzeilen im definierten Bereich
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht
row = all_data[row_index_in_list]
# Stellen Sie sicher, dass die Zeile nicht leer ist
if not any(cell and cell.strip() for cell in row):
#self.logger.debug(f"Zeile {i}: Übersprungen (Leere Zeile).") # Zu viel Lärm
skipped_count += 1
continue
# --- Prüfung, ob Verarbeitung für diese Zeile nötig ist ---
# Kriterium: Status S ist gesetzt (nicht leer) UND NICHT einer der Endzustände.
# Endzustände: "OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)"
s_value = self._get_cell_value_safe(row, "Chat Wiki Konsistenzprüfung").strip()
s_value_upper = s_value.upper()
# Definieren Sie die Endzustände (Großbuchstaben)
end_states = ["OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)"]
# Verarbeitung nötig, wenn S nicht leer ist UND S NICHT im Endzustand ist
processing_needed_for_row = s_value and s_value_upper not in end_states
# Loggen der Prüfergebnisse auf Debug
log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
if log_check:
self.logger.debug(f"Zeile {i} (Wiki Update Check): Status S='{s_value}'. Benötigt Verarbeitung? {processing_needed_for_row}")
if not processing_needed_for_row:
skipped_count += 1
continue
# --- Wenn Verarbeitung nötig: Prüfe Vorschlag U und handle ---
processed_rows_count += 1 # Zähle die Zeile, die geprüft wird (zum Limit zählen)
# Prüfe das Limit für verarbeitete Zeilen
if limit is not None and processed_count > limit:
self.logger.info(f"Verarbeitungslimit ({limit}) für process_wiki_updates_from_chatgpt erreicht. Breche weitere Zeilenprüfung ab.")
break # Schleife abbrechen
vorschlag_u = self._get_cell_value_safe(row, "Chat Vorschlag Wiki Artikel").strip()
url_m = self._get_cell_value_safe(row, "Wiki URL").strip()
self.logger.info(f"Zeile {i}: Prüfe Wiki-Vorschlag U='{vorschlag_u[:100]}...' (aktuell M='{url_m[:100]}...')...")
is_update_candidate = False # Flag, ob U eine gültige, neue URL ist
new_url = ""
# Kriterium 1: Ist Vorschlag U eine URL und sieht nach Wikipedia aus?
condition1_u_is_wiki_url = vorschlag_u.lower().startswith(("http://", "https://")) and "wikipedia.org/wiki/" in vorschlag_u.lower()
if condition1_u_is_wiki_url:
new_url = vorschlag_u # Nehme den Vorschlag als potenzielle neue URL
# Kriterium 2: Unterscheidet sich der Vorschlag U von der aktuellen URL in M?
condition2_u_differs_m = new_url != url_m
if condition2_u_differs_m:
self.logger.debug(f" -> Vorschlag U ({new_url}) unterscheidet sich von M ({url_m}). Prüfe Validität...")
# Kriterium 3: Ist die vorgeschlagene URL ein valider Wikipedia-Artikel?
try:
# is_valid_wikipedia_article_url nutzt den retry_on_failure Decorator
condition3_u_is_valid = is_valid_wikipedia_article_url(new_url) # Annahme: globale Funktion in utils.py
if condition3_u_is_valid:
is_update_candidate = True # Alle Kriterien erfüllt!
else:
self.logger.debug(f" -> URL '{new_url}' ist KEIN valider Artikel laut API Check.")
except Exception as e_validity_check:
self.logger.error(f"FEHLER bei Validitätsprüfung von Vorschlag U '{new_url}': {e_validity_check}")
# Bei Fehler bleibt is_update_candidate False
pass # Fahren Sie fort
else:
self.logger.debug(f" -> Vorschlag U ist identisch mit URL M.")
else:
self.logger.debug(f" -> Vorschlag U ('{vorschlag_u[:100]}...') ist keine Wikipedia URL.")
# --- Verarbeitung des Kandidaten ODER Löschen des ungültigen Vorschlags ---
updates_for_row = [] # Updates nur für diese Zeile sammeln
if is_update_candidate:
# Fall 1: Gültiges Update durchführen
self.logger.info(f"Zeile {i}: Update-Kandidat VALIDIERUNG ERFOLGREICH. Kopiere U->M, setze ReEval-Flag 'x', lösche abhängige Spalten.")
updated_url_count += 1
# Updates sammeln (M, S, U, N-V, AN, AO, AP, AX, A)
updates_for_row.append({'range': f'{m_letter}{i}', 'values': [[new_url]]}) # URL setzen in M
updates_for_row.append({'range': f'{s_letter}{i}', 'values': [["X (URL Copied)"]]},) # Neuer Status in S
updates_for_row.append({'range': f'{u_letter}{i}', 'values': [["URL übernommen"]]},) # Info in U
updates_for_row.append({'range': f'{a_letter}{i}', 'values': [["x"]]},) # ReEval Flag setzen in A
# Spalten N-V leeren
if nv_range_letter:
updates_for_row.append({'range': f'{n_letter}{i}:{v_letter}{i}', 'values': [empty_nv_values]})
else:
self.logger.warning(f"Konnte Spaltenbereich N-V für Leerung nicht ermitteln.")
# Timestamps AN, AO, AX, Version AP leeren
updates_for_row.append({'range': f'{an_letter}{i}', 'values': [['']]})
updates_for_row.append({'range': f'{ao_letter}{i}', 'values': [['']]})
updates_for_row.append({'range': f'{ap_letter}{i}', 'values': [['']]})
updates_for_row.append({'range': f'{ax_letter}{i}', 'values': [['']]})
else:
# Fall 2: Ungültigen Vorschlag löschen/markieren
self.logger.info(f"Zeile {i}: Vorschlag U ('{vorschlag_u[:100]}...') ist ungültig/identisch. Lösche U und setze Status S auf 'X (Invalid Suggestion)'.")
cleared_suggestion_count += 1
updates_for_row.append({'range': f'{s_letter}{i}', 'values': [["X (Invalid Suggestion)"]]},) # Neuer Status in S
updates_for_row.append({'range': f'{u_letter}{i}', 'values': [[""]]},) # Vorschlag U löschen
# KEIN ReEval-Flag setzen
# Sammle die Updates für diese Zeile
all_sheet_updates.extend(updates_for_row)
# Sende gesammelte Sheet Updates wenn das Update-Batch-Limit erreicht ist
# Die Anzahl der Updates pro Zeile variiert stark (ca. 2 bei ungültigem Vorschlag, ca. 10+ bei gültigem).
# Prüfen Sie einfach die Länge der gesammelten Liste.
if len(all_sheet_updates) >= update_batch_row_limit * 5: # Grobe Schätzung, dass im Schnitt 5 Updates pro Zeile anfallen
self.logger.debug(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...")
# Nutzt die batch_update_cells Methode des Sheet Handlers mit Retry
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
if success:
self.logger.info(f" Sheet-Update für {len(all_sheet_updates)} Zellen erfolgreich.")
# Der Fehlerfall wird von batch_update_cells geloggt
# Leere die gesammelten Updates nach dem Senden
all_sheet_updates = []
# Kleine Pause nach jeder geprüften Zeile (nutzt Config)
# Dieser Modus macht API calls (is_valid_wikipedia_article_url), also Pause einbauen
pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.2
#self.logger.debug(f"Warte {pause_duration:.2f}s nach Prüfung...") # Zu viel Lärm
time.sleep(pause_duration)
# --- Finale Sheet Updates senden ---
# Sende alle verbleibenden gesammelten Updates
if all_sheet_updates:
self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...")
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
if success:
self.logger.info(f"FINALES Sheet-Update erfolgreich.")
# Der Fehlerfall wird von batch_update_cells geloggt
self.logger.info(f"Modus 'wiki_updates_from_chatgpt' abgeschlossen. {processed_rows_count} Zeilen geprüft, {updated_url_count} URLs kopiert & für ReEval markiert, {cleared_suggestion_count} ungültige Vorschläge gelöscht/markiert, {skipped_count} Zeilen übersprungen.")
# Keine Pause nach diesem Modus nötig.
# --- Methode zur Re-Extraktion von Wiki-Daten bei fehlendem Timestamp AN ---
# NEUE Utility Methode, die den _process_single_row nutzt.
def process_wiki_reextract_missing_an(self, start_sheet_row=None, end_sheet_row=None, limit=None):
"""
Identifiziert Zeilen, bei denen eine Wiki URL (M) vorhanden ist, aber der
Wikipedia Timestamp (AN) fehlt. Führt _process_single_row für diese Zeilen aus,
beschränkt auf den 'wiki'-Schritt und mit force_reeval=True, um die Extraktion
erneut zu versuchen.
Args:
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung).
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER Zeilen. Defaults to None.
"""
self.logger.info(f"Starte Modus 'wiki_reextract_missing_an': Suche Zeilen mit M gefüllt und AN leer. Bereich: {start_sheet_row}-{end_sheet_row}, Limit: {limit if limit is not None else 'Unbegrenzt'}...")
# --- Daten laden ---
# Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt
# Hier suchen wir nicht nach leeren Spalten für den Start, sondern scannen den Bereich.
# Laden Sie Daten, aber es ist kein automatischer Startindex-Check nötig.
if not self.sheet_handler.load_data():
self.logger.error("FEHLER beim Laden der Daten für wiki_reextract_missing_an.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = self.sheet_handler._header_rows
total_sheet_rows = len(all_data)
# Standard Startzeile, wenn nicht angegeben
if start_sheet_row is None: start_sheet_row = header_rows + 1 # Standardmäßig ab erster Datenzeile
# Berechne Endzeile, wenn nicht gesetzt
if end_sheet_row is None: end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
self.logger.info(f"Suchbereich für M gefüllt & AN leer: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}")
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.")
return
# --- Indizes ---
required_keys = ["Wiki URL", "Wikipedia Timestamp"] # Prüfkriterien
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
if None in col_indices.values():
missing = [k for k, v in col_indices.items() if v is None]
self.logger.critical(f"FEHLER: Benötigte Spaltenschlüssel fehlen in COLUMN_MAP für wiki_reextract_missing_an: {missing}. Breche ab.")
return
m_col_idx = col_indices["Wiki URL"]
an_col_idx = col_indices["Wikipedia Timestamp"]
# --- Verarbeitung ---
processed_count = 0 # Zählt Zeilen, die an _process_single_row übergeben wurden (zum Limit zählen)
skipped_count = 0 # Zählt Zeilen, die übersprungen wurden
# Iteriere durch die Datenzeilen im definierten Bereich
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht
row = all_data[row_index_in_list]
# Stellen Sie sicher, dass die Zeile nicht leer ist
if not any(cell and cell.strip() for cell in row):
#self.logger.debug(f"Zeile {i}: Übersprungen (Leere Zeile).") # Zu viel Lärm
skipped_count += 1
continue
# --- Prüfung, ob Verarbeitung für diese Zeile nötig ist ---
# Kriterium: Wiki URL (M) ist vorhanden und nicht "k.A." etc.
# UND Wikipedia Timestamp (AN) ist leer.
m_value = self._get_cell_value_safe(row, "Wiki URL").strip()
an_value = self._get_cell_value_safe(row, "Wikipedia Timestamp").strip()
is_m_valid_looking = m_value and m_value.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche"]
is_an_empty = not an_value
processing_needed_for_row = is_m_valid_looking and is_an_empty
# Loggen der Prüfergebnisse auf Debug
log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
if log_check:
self.logger.debug(f"Zeile {i} (Wiki Re-extract Check): M ('{m_value[:50]}...') gültig? {is_m_valid_looking}, AN leer? {is_an_empty}. Benötigt Verarbeitung? {processing_needed_for_row}")
if not processing_needed_for_row:
skipped_count += 1
continue
# --- Wenn Verarbeitung nötig: Rufe _process_single_row auf ---
processed_count += 1 # Zähle die Zeile, die verarbeitet wird (zum Limit zählen)
# Prüfe das Limit für verarbeitete Zeilen
if limit is not None and processed_count > limit:
self.logger.info(f"Verarbeitungslimit ({limit}) für wiki_reextract_missing_an erreicht. Breche weitere Zeilenprüfung ab.")
break # Schleife abbrechen
self.logger.info(f"Zeile {i}: M gefüllt & AN leer. Versuche Wiki-Re-Extraktion via _process_single_row...")
try:
# RUFE _process_single_row AUF
# Mit steps_to_run={'wiki'} und force_reeval=True,
# damit nur der Wiki-Schritt ausgeführt wird und Timestamps ignoriert werden
self._process_single_row(
row_num_in_sheet = i,
row_data = row, # Übergibt die aktuellen Rohdaten der Zeile
steps_to_run = {'wiki'}, # Nur der Wiki-Schritt soll laufen
force_reeval = True # Erzwingt die Ausführung des 'wiki' Schritts
)
# _process_single_row loggt intern und führt das Sheet-Update durch
except Exception as e_proc:
# Fangen Sie Fehler aus _process_single_row ab, loggen Sie sie
# und fahren Sie mit der nächsten Zeile fort.
self.logger.exception(f"FEHLER bei Verarbeitung von Zeile {i} in wiki_reextract_missing_an: {e_proc}")
# Hier könnten Sie einen Fehlerindikator in eine spezielle Spalte schreiben
# _process_single_row beinhaltet bereits eine kleine Pause am Ende.
# Hier ist keine zusätzliche Pause nötig, wenn _process_single_row erfolgreich war.
# Wenn _process_single_row eine Exception wirft, kann hier eine kurze Pause sinnvoll sein
# time.sleep(0.1) # Optional: Kurze Pause bei Fehler
self.logger.info(f"Modus 'wiki_reextract_missing_an' abgeschlossen. {processed_count} Zeilen an _process_single_row übergeben, {skipped_count} Zeilen übersprungen.")
# Keine Pause nach diesem Modus nötig.
# --- Das Ende der DataProcessor Klasse ist in der nächsten Nachricht ---
# ==============================================================================
# 7. GLOBALE FUNKTIONEN (die keiner Klasse zugeordnet sind)
# ==============================================================================
# --- Alignment Demo (Header schreiben) ---
# Übernommen aus alignment_demo in Teil 10. Bleibt global, da es direkt das gspread sheet Objekt verwendet.
# Nutzt COLUMN_MAP und Config.VERSION (globale Konstanten/Klasse).
def alignment_demo(sheet):
"""Schreibt die Header-Struktur (Zeilen 1-5, jetzt bis Spalte AY) ins angegebene Sheet."""
# Stellen Sie sicher, dass COLUMN_MAP die höchstbenötigte Spalte AY (Index 50) enthält.
# Und dass alle Schlüssel im new_headers[0] Array in COLUMN_MAP existieren,
# um die Indizes für die Beschreibungen in den anderen Header-Zeilen zu finden.
# Diese Funktion nimmt an, dass COLUMN_MAP komplett und korrekt ist.
# Header-Texte für die ersten 5 Zeilen
# Die Reihenfolge der Spalten muss EXACT mit der Reihenfolge der Schlüssel in COLUMN_MAP übereinstimmen,
# oder zumindest mit der Reihenfolge, wie sie hier im Array new_headers[0] aufgeführt ist,
# da diese Funktion nicht dynamisch aus COLUMN_MAP liest, sondern feste Listen hat.
# Stellen Sie sicher, dass diese Listen die gleiche LÄNGE haben wie COLUMN_MAP.
# Zählen Sie die Einträge: ReEval Flag (0) bis SerpAPI Wiki Search Timestamp (50) = 51 Spalten (Index 0-50).
# Überprüfen Sie, dass jede Liste unten 51 Einträge hat.
new_headers = [
# Zeile 1: Spaltenname
["ReEval Flag", "CRM Name", "CRM Kurzform", "CRM Website", "CRM Ort", "CRM Beschreibung", "CRM Branche", "CRM Beschreibung Branche extern", "CRM Anzahl Techniker", "CRM Umsatz", "CRM Anzahl Mitarbeiter", "CRM Vorschlag Wiki URL", "Wiki URL", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Chat Wiki Konsistenzprüfung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", "Begründung bei Abweichung", "Chat Vorschlag Branche", "Chat Konsistenz Branche", "Chat Begründung Abweichung Branche", "Chat Prüfung FSM Relevanz", "Chat Begründung für FSM Relevanz", "Chat Schätzung Anzahl Mitarbeiter", "Chat Konsistenzprüfung Mitarbeiterzahl", "Chat Begründung Abweichung Mitarbeiterzahl", "Chat Einschätzung Anzahl Servicetechniker", "Chat Begründung Abweichung Anzahl Servicetechniker", "Chat Schätzung Umsatz", "Chat Begründung Abweichung Umsatz", "Linked Serviceleiter gefunden", "Linked It-Leiter gefunden", "Linked Management gefunden", "Linked Disponent gefunden", "Contact Search Timestamp", "Wikipedia Timestamp", "Timestamp letzte Prüfung", "Version", "Tokens", "Website Rohtext", "Website Zusammenfassung", "Website Scrape Timestamp", "Geschätzter Techniker Bucket", "Finaler Umsatz (Wiki>CRM)", "Finaler Mitarbeiter (Wiki>CRM)", "Wiki Verif. Timestamp", "SerpAPI Wiki Search Timestamp"],
# Zeile 2: Quelle der Daten
["CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "System", "System", "System", "System", "System", "Web Scraper", "Chat GPT API", "System", "ML Modell / Skript", "Skript (Wiki/CRM)", "Skript (Wiki/CRM)", "System", "System"],
# Zeile 3: Feldkategorie
["Prozess", "Firmenname", "Firmenname", "Website", "Ort", "Beschreibung (Text)", "Branche", "Branche", "Anzahl Servicetechniker", "Umsatz", "Anzahl Mitarbeiter", "Wikipedia Artikel URL", "Wikipedia Artikel", "Beschreibung (Text)", "Branche", "Umsatz", "Anzahl Mitarbeiter", "Kategorien (Text)", "Verifizierung", "Begründung bei Abweichung", "Wikipedia Artikel", "Wikipedia Artikel", "Branche", "Branche", "Branche", "FSM Relevanz", "FSM Relevanz", "Anzahl Mitarbeiter", "Anzahl Mitarbeiter", "Anzahl Mitarbeiter", "Anzahl Servicetechniker", "Anzahl Servicetechniker", "Umsatz", "Umsatz", "Kontakte zur Firma", "Kontakte zur Firma", "Kontakte zur Firma", "Kontakte zur Firma", "Timestamp", "Timestamp", "Timestamp", "Version des Skripts die verwendet wurde", "ChatGPT Tokens", "Website-Content", "Website Zusammenfassung", "Timestamp", "Anzahl Servicetechniker Bucket", "Umsatz", "Anzahl Mitarbeiter", "Timestamp", "Timestamp"],
# Zeile 4: Kurze Beschreibung
["Systemspalte...", "Enthält den Firmennamen...", "Manuell gepflegte Kurzform...", "Website des Unternehmens.", "Ort des Unternehmens.", "Kurze Beschreibung...", "Aktuelle Branchenzuweisung...", "Externe Branchenbeschreibung...", "Recherchierte Anzahl...", "Umsatz in Mio. € (CRM).", "Anzahl Mitarbeiter (CRM).", "Vorgeschlagene Wikipedia URL...", "Wikipedia URL...", "Erster Absatz...", "Wikipedia-Branche...", "Wikipedia-Umsatz...", "Wikipedia-Mitarbeiterzahl...", "Liste der Wikipedia-Kategorien.", "\"OK\" oder \"X\" Ergebnis...", "Begründung bei Inkonsistenz...", "Chat-Vorschlag Wiki Artikel...", "Nicht genutzt...", "Branchenvorschlag via ChatGPT...", "Vergleich: Übereinstimmung CRM vs. ...", "Begründung bei abweichender...", "FSM-Relevanz: Bewertung...", "Begründung zur FSM-Bewertung.", "Schätzung Anzahl Mitarbeiter...", "Vergleich CRM vs. Wiki vs. ...", "Begründung bei Mitarbeiterabweichung...", "Schätzung Servicetechniker...", "Begründung bei Abweichung...", "Schätzung Umsatz via ChatGPT.", "Begründung bei Umsatzabweichung.", "Anzahl Kontakte (Serviceleiter)...", "Anzahl Kontakte (IT-Leiter)...", "Anzahl Kontakte (Management)...", "Anzahl Kontakte (Disponent)...", "Timestamp der Kontaktsuche.", "Timestamp der Wikipedia-Suche/Extraktion.", "Timestamp der ChatGPT-Bewertung / Letzte Prüfung der Zeile.", "Ausgabe der Skriptversion...", "Token-Zählung...", "Roh extrahierter Text...", "Zusammenfassung des Webseiteninhalts...", "Timestamp des letzten Website-Scrapings (AR, AS).", "Ergebnis der Schätzung durch das trainierte ML-Modell.", "Konsolidierter Umsatz (Mio €) nach Priorität Wiki > CRM.", "Konsolidierte Mitarbeiterzahl nach Priorität Wiki > CRM.", "Timestamp der letzten Wiki-Verifikation (Spalten S-U).", "Timestamp der letzten SerpAPI-Suche nach fehlender Wiki-URL (Modus find_wiki_serp)."]
# Zeile 5: Aufgabe / Funktion
# Stellen Sie sicher, dass die Spaltenreihenfolge hier mit den anderen Zeilen übereinstimmt!
# Dies ist die längste Zeile und kann schwierig zu pflegen sein.
# Zählen Sie die Einträge sorgfältig.
,["Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Wird durch Wikipedia Scraper bereitgestellt", "Wird zunächst nicht verwendet...", "Wird u.a. zur finalen Ermittlung...", "Wird u.a. mit CRM-Umsatz...", "Wird u.a. mit CRM-Anzahl...", "Wenn Website-Daten fehlen...", "\"Es soll durch ChatGPT geprüft werden...", "\"Liegt eine Inkonsistenz...", "\"Sollte durch die Wikipedia-Suche...", "XXX derzeit nicht verwendet...", "\"ChatGPT soll anhand der vorliegenden...", "Die in Spalte CRM festgelegte...", "Weicht die von ChatGPT ermittelte...", "ChatGPT soll anhand der vorliegenden Daten prüfen...", "Die in 'Chat Begründung für FSM Relevanz'...", "Nur wenn kein Wikipedia-Eintrag...", "Entspricht die durch ChatGPT ermittelte...", "Weicht die von ChatGPT geschätzte...", "ChatGPT soll auf Basis öffentlich...", "Weicht die von ChatGPT geschätzte...", "Nur wenn kein Wikipedia-Eintrag...", "ChatGPT soll signifikante Umsatzabweichungen...", "Über SerpAPI wird zusammen...", "Über SerpAPI wird zusammen...", "Über SerpAPI wird zusammen...", "Über SerpAPI wird zusammen...", "Wenn die Kontaktsuche gestartet wird...", "Wenn die Wikipedia-Suche gestartet wird...", "Wenn die ChatGPT-Bewertung gestartet wird...", "Wird durch das System befüllt", "Wird durch tiktoken berechnet", "Wird durch Web Scraper...", "Wird durch ChatGPT API...", "Timestamp wird gesetzt, wenn Website Rohtext/Zusammenfassung geschrieben werden.", "Ergebnis der Schätzung durch das trainierte ML-Modell.", "Vom Skript berechneter Wert, priorisiert Wiki > CRM...", "Vom Skript berechneter Wert, priorisiert Wiki > CRM...", "Timestamp wird gesetzt, wenn Wiki-Verifikation (S-Y) durchgeführt wurde.", "Timestamp wird gesetzt, nachdem versucht wurde, eine fehlende Wiki-URL via SerpAPI zu finden."]
]
# Stellen Sie sicher, dass die Anzahl der Spalten in allen Header-Zeilen gleich ist
num_cols = len(new_headers[0])
if not all(len(row) == num_cols for row in new_headers):
logger.critical(f"FEHLER in alignment_demo: Die Anzahl der Spalten in den Header-Zeilen ist nicht konsistent! Erwartet {num_cols} pro Zeile.")
# Versuchen Sie trotzdem zu schreiben, aber der Fehler ist schwerwiegend.
# Finden Sie die maximale Anzahl Spalten, um den Bereich zu bestimmen
num_cols = max(len(row) for row in new_headers)
# Hilfsfunktion zum Konvertieren des 1-basierten Spaltenindex in Buchstaben (A, B, AA, ...)
def colnum_string(n):
string = ""
while n > 0:
n, remainder = divmod(n - 1, 26)
string = chr(65 + remainder) + string
return string
# Berechnen Sie den Bereich für das Update (z.B. A1:AY5)
end_col_letter = colnum_string(num_cols)
header_range = f"A1:{end_col_letter}{len(new_headers)}"
logger.info(f"Schreibe Alignment-Demo Header in Bereich {header_range}...")
try:
# Führen Sie das Update durch
# Verwenden Sie batch_update für Effizienz, obwohl es nur ein einzelner großer Bereich ist
# oder sheet.update direkt. sheet.update ist einfacher für einen einzelnen Bereich.
sheet.update(values=new_headers, range_name=header_range, value_input_option='USER_ENTERED')
logger.info(f"Alignment-Demo Header erfolgreich geschrieben in Bereich {header_range}.")
except Exception as e:
logger.error(f"FEHLER beim Schreiben der Alignment-Demo Header in Bereich {header_range}: {e}")
logger.debug(traceback.format_exc())
# ==============================================================================
# 8. MAIN FUNCTION (HAUPTEINSTIEGSPUNKT & UI DISPATCHER)
# ==============================================================================
def main():
"""
Haupteinstiegspunkt des Skripts.
Verarbeitet Kommandozeilen-Argumente, richtet Logging ein,
initialisiert Komponenten und dispatchet zu den passenden Modi.
"""
# WICHTIG: Global LOG_FILE wird benötigt
global LOG_FILE
# --- Initial Logging Setup (Konfiguration von Level und Format) ---
# Diese Konfiguration wird wirksam, sobald die Handler hinzugefügt werden.
# Standard-Logging Level festlegen
log_level = logging.DEBUG if getattr(Config, 'DEBUG', False) else logging.INFO # Nutzt Config.DEBUG
log_format = '%(asctime)s - %(levelname)-8s - %(name)-25s - %(message)s' # Angepasstes Format mit breiterem Namen
# Root-Logger konfigurieren (mit Console Handler, File Handler wird später hinzugefügt)
# handlers=[] verhindert default Console Handler, wir fügen ihn manuell hinzu für mehr Kontrolle
logging.basicConfig(level=log_level, format=log_format, handlers=[])
# Console Handler explizit hinzufügen
console_handler = logging.StreamHandler()
console_handler.setLevel(log_level) # Nimm das globale Level
console_handler.setFormatter(logging.Formatter(log_format))
logging.getLogger('').addHandler(console_handler) # Füge zum Root-Logger hinzu
# 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')
parser = argparse.ArgumentParser(
description=f"Firmen-Datenanreicherungs-Skript {current_script_version}. Automatisiert Anreicherung und Validierung aus Google Sheets.",
formatter_class=argparse.RawTextHelpFormatter # Behält Formatierung im Help-Text
)
# Liste der gültigen Modi - MUSS mit den elif-Zweigen unten übereinstimmen!
# Kategorisiert für die Menü-Ausgabe
mode_categories = {
"Batch-Verarbeitung (Schritt-Optimiert)": [
"wiki_verify", "website_scraping", "summarize_website", "branch_eval",
],
"Sequenzielle Verarbeitung (Zeilenweise)": [
"full_run",
],
"Re-Evaluate Markierte Zeilen (Spalte A='x')": [
"reeval",
],
"Einzelne Dienstprogramme / Suchen": [
"find_wiki_serp", "website_lookup", "contacts", "update_wiki_suggestions", # update_wiki umbenannt
"train_technician_model", "alignment", "wiki_reextract_missing_an",
"website_details", # Experimentell
],
"Kombinierte Läufe (Vordefiniert)": [
"combined_all", # Neuer kombinierter Modus
]
}
# Erstellen Sie eine flache Liste aller validen Modi
valid_modes = [mode for modes in mode_categories.values() for mode in modes]
# Dynamisch generieren des Help-Textes für den Modus
mode_help_text = "Betriebsmodus. Wählen Sie einen der folgenden:\n"
for category, modes in mode_categories.items():
mode_help_text += f"\n{category}:\n"
for mode in modes:
mode_help_text += f" - {mode}\n"
parser.add_argument("--mode", type=str, help=mode_help_text)
parser.add_argument("--limit", type=int, help="Maximale Anzahl zu verarbeitender Zeilen in den meisten Modi (prüft Zeilen VOR Überspringung/Filterung).", default=None)
# start_sheet_row wird primär für full_run verwendet
parser.add_argument("--start_sheet_row", type=int, help="Startzeile im Sheet (1-basiert) für 'full_run' und einige Batch-Modi. Standard: Automatische Ermittlung basierend auf Timestamp.", default=None)
# end_sheet_row für Bereiche
parser.add_argument("--end_sheet_row", type=int, help="Endzeile im Sheet (1-basiert) für 'full_run' und einige Batch-Modi. Standard: Ende des Sheets.", default=None)
# Argument für den Re-Eval Modus zur Auswahl der Schritte
# Mögliche Werte für die Schritte: 'wiki', 'chat', 'web', 'ml_predict', etc. (entsprechend den step_type Schlüsseln in _process_single_row)
# Default ist 'all' für alle Schritte, oder eine spezifische Liste
valid_reeval_steps = ['wiki', 'chat', 'web', 'ml_predict'] # Fügen Sie hier weitere Schritt-Keys hinzu
reeval_steps_help = f"Komma-getrennte Liste der Schritte im 'reeval' und 'full_run' Modus (z.B. 'wiki,chat').\nMögliche Schritte: {', '.join(valid_reeval_steps)}.\nStandard: {'all' if not valid_reeval_steps else ','.join(valid_reeval_steps)}" # Standard: alle verfügbaren Schritte
parser.add_argument("--steps", type=str, help=reeval_steps_help, default=','.join(valid_reeval_steps) if valid_reeval_steps else 'all') # Default alle, wenn Liste definiert
# Argumente für find_wiki_serp (falls über CLI gesteuert)
parser.add_argument("--min_umsatz", type=float, help="Mindestumsatz in MIO € (CRM Spalte J) für find_wiki_serp Filter.", default=200.0) # Float für Konsistenz
parser.add_argument("--min_employees", type=int, help="Mindestmitarbeiterzahl (CRM Spalte K) für find_wiki_serp Filter.", default=500)
# Argumente für train_technician_model
parser.add_argument("--model_out", type=str, default=MODEL_FILE, help=f"Pfad für das trainierte Modell (.pkl). Standard: {MODEL_FILE}")
parser.add_argument("--imputer_out", type=str, default=IMPUTER_FILE, help=f"Pfad für den trainierten Imputer (.pkl). Standard: {IMPUTER_FILE}")
parser.add_argument("--patterns_out", type=str, default=PATTERNS_FILE_JSON, help=f"Pfad für die Feature-Spaltenliste (.json). Standard: {PATTERNS_FILE_JSON}") # Ändern zu JSON
# TODO: Fügen Sie hier weitere CLI-Argumente hinzu, falls andere Modi Parameter benötigen
args = parser.parse_args()
# --- Konfiguration laden ---
Config.load_api_keys() # Nutzt jetzt logging intern (print am Anfang)
# --- Logdatei-Konfiguration abschließen ---
# Bestimmen Sie den Log-Modus Namen basierend auf CLI oder Interaktion
log_mode_name = args.mode if args.mode else "interactive"
LOG_FILE = create_log_filename(log_mode_name) # Nutzt globale Funktion
if LOG_FILE:
try:
# Erstellen Sie den FileHandler
file_handler = logging.FileHandler(LOG_FILE, mode='a', encoding='utf-8')
file_handler.setLevel(log_level) # Nimm das globale Level
# Verwenden Sie denselben Formatter wie für den Console Handler
file_handler.setFormatter(logging.Formatter(log_format))
# Füge FileHandler zum Root-Logger hinzu
logging.getLogger('').addHandler(file_handler)
logger.info(f"Logging wird jetzt auch in Datei geschrieben: {LOG_FILE}")
except Exception as e:
# Logge Fehler nur auf Konsole, da FileHandler fehlgeschlagen ist
# logger.exception loggt auch an die Konsole, wenn kein FileHandler da ist
logger.error(f"Konnte FileHandler für Logdatei '{LOG_FILE}' nicht erstellen: {e}")
# Optional: Entfernen Sie evtl. den fehlerhaften Handler aus der Liste
logging.getLogger('').handlers = [h for h in logging.getLogger('').handlers if not isinstance(h, logging.FileHandler)]
# --- JETZT die Startmeldungen loggen (gehen jetzt in Konsole UND Datei) ---
logger.info(f"===== Skript gestartet =====")
logger.info(f"Version: {current_script_version}")
logger.info(f"Logdatei: {LOG_FILE if LOG_FILE else 'FEHLER - Keine Logdatei erstellt'}")
# Loggen Sie relevante CLI Argumente zur Dokumentation des Laufs
logger.info(f"CLI Argumente: {args}")
# --- Vorbereitung (Schema, Handler etc.) ---
load_target_schema() # Annahme: load_target_schema ist global definiert (utils.py)
sheet_handler = None # Initialisiere Variable
try:
sheet_handler = GoogleSheetHandler() # Initialisiere den Sheet Handler
except Exception as e:
logger.critical(f"FATAL: Initialisierung des GoogleSheetHandlers fehlgeschlagen: {e}")
logger.critical(f"Bitte Logdatei prüfen: {LOG_FILE}")
# Das Skript kann ohne Sheet-Zugriff nicht arbeiten
return # Beende Skript
wiki_scraper = None # Initialisiere Variable
try:
# Initialisiere WikipediaScraper
wiki_scraper = WikipediaScraper() # Annahme: WikipediaScraper ist global definiert
except Exception as e:
logger.critical(f"FATAL: Initialisierung des WikipediaScrapers fehlgeschlagen: {e}")
logger.critical(f"Bitte Logdatei prüfen: {LOG_FILE}")
# Das Skript kann ohne Wiki Scraper viele Modi nicht sinnvoll laufen
return # Beende Skript
# TODO: Initialisieren Sie hier weitere Worker-Instanzen, falls Sie separate Klassen haben
# z.B. openai_handler = OpenAIHandler()
# serpapi_handler = SerpAPIHandler()
# Initialisiere DataProcessor Instanz mit Handlern
# Übergeben Sie alle benötigten Handler
data_processor = DataProcessor(sheet_handler=sheet_handler, wiki_scraper=wiki_scraper) # Übergeben Sie die Instanzen
# --- Modusauswahl und Ausführung ---
start_time = time.time()
logger.info(f"Starte Verarbeitung um {datetime.now().strftime('%H:%M:%S')}...")
selected_mode = None # Variable für den tatsächlich ausgeführten Modus
# --- Ermitteln des zu führenden Modus (CLI hat Priorität) ---
if args.mode:
selected_mode = args.mode.lower()
if selected_mode not in valid_modes:
logger.error(f"Ungültiger Modus '{args.mode}' über Kommandozeile angegeben. Gültige Modi: {', '.join(valid_modes)}")
print(f"Fehler: Ungültiger Modus '{args.mode}'. Siehe --help.")
return # Skript beenden
logger.info(f"Betriebsmodus (CLI gewählt): {selected_mode}")
else:
# --- Interaktive Modusauswahl ---
print("\nBitte wählen Sie den Betriebsmodus:")
# Zeigen Sie die Liste der validen Modi kategorisiert an
mode_options_map = {}
option_counter = 1
for category, modes in mode_categories.items():
print(f"\n{category}:")
for mode in modes:
print(f" {option_counter}: {mode}")
mode_options_map[str(option_counter)] = mode # Map Zahl zu Modusname
mode_options_map[mode] = mode # Map Modusname zu Modusname (für direkte Eingabe)
option_counter += 1
# Fügen Sie eine Option zum Abbrechen hinzu
print(f"\n 0: Abbrechen")
mode_options_map['0'] = 'exit'
while selected_mode is None: # Schleife, bis ein gültiger Modus gewählt wurde
try:
mode_input = input(f"Geben Sie den Modusnamen oder die Zahl ein: ").strip().lower()
if mode_input in mode_options_map:
selected_mode = mode_options_map[mode_input]
if selected_mode == 'exit':
logger.info("Modus 'exit' gewählt. Skript wird beendet.")
print("Abgebrochen.")
return # Skript beenden
logger.info(f"Betriebsmodus (interaktiv gewählt): {selected_mode}")
else:
print("Ungültige Eingabe. Bitte wählen Sie eine gültige Option.")
except EOFError: # Benutzer hat Ctrl+D gedrückt
logger.warning("Interaktive Modus-Eingabe abgebrochen (EOFError). Skript wird beendet.")
print("\nEingabe abgebrochen.")
return
except Exception as e:
logger.error(f"Fehler bei interaktiver Modus-Eingabe: {e}")
logger.debug(traceback.format_exc())
print(f"Fehler bei der Modus-Eingabe ({e}).")
return # Skript beenden bei unerwartetem Fehler
# --- Ausführung des gewählten Modus ---
try:
# Holen Sie die CLI-Argumente für Start/End/Limit/Steps
limit_arg = args.limit
start_row_arg = args.start_sheet_row
end_row_arg = args.end_sheet_row
# Sonderbehandlung für --steps Argument (relevant für reeval und full_run)
steps_to_run_set = set()
if args.steps and args.steps.lower() != 'all':
steps_list = [step.strip().lower() for step in args.steps.split(',') if step.strip()]
# Filtern Sie nur erlaubte Schritte
steps_to_run_set = set(step for step in steps_list if step in valid_reeval_steps)
if len(steps_to_run_set) != len(steps_list):
invalid_steps = [step for step in steps_list if step not in valid_reeval_steps]
logger.warning(f"Ignoriere ungültige Schritte im --steps Argument: {invalid_steps}. Führe nur {steps_to_run_set} aus.")
if not steps_to_run_set:
logger.error("Keine gültigen Schritte im --steps Argument gefunden. Re-Eval/Full-Run kann nicht gestartet werden.")
print("Fehler: Keine gültigen Schritte für den Modus ausgewählt.")
return # Skript beenden, wenn keine Schritte ausgewählt sind
else: # --steps ist 'all' oder nicht gesetzt
steps_to_run_set = set(valid_reeval_steps) # Führe standardmäßig alle Re-Eval/Full-Run Schritte aus
# Dispatching basierend auf dem gewählten Modus
logger.info(f"Starte Ausführung des Modus: {selected_mode}")
if selected_mode == "combined_all":
# Führt die wichtigsten Batch-Modi nacheinander aus
logger.info("--- Start Kombinierter Modus: wiki_verify ---")
data_processor.process_verification_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)
logger.info("--- Start Kombinierter Modus: website_scraping ---")
data_processor.process_website_scraping_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)
logger.info("--- Start Kombinierter Modus: summarize_website ---")
data_processor.process_summarization_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)
logger.info("--- Start Kombinierter Modus: branch_eval ---")
data_processor.process_branch_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)
# TODO: Fügen Sie hier weitere Batch-Modi hinzu, falls sie im kombinierten Lauf enthalten sein sollen
elif selected_mode == "wiki_verify":
data_processor.process_verification_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)
elif selected_mode == "website_scraping":
data_processor.process_website_scraping_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)
elif selected_mode == "summarize_website":
data_processor.process_summarization_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)
elif selected_mode == "branch_eval":
data_processor.process_branch_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)
elif selected_mode == "reeval":
# reeval Modus nutzt immer force_reeval=True in _process_single_row
data_processor.process_reevaluation_rows(
row_limit=limit_arg,
clear_flag=True, # Standardmäßig 'x' löschen
# Übergeben Sie die aus dem --steps Argument ermittelten Schritte
process_wiki_steps='wiki' in steps_to_run_set,
process_chatgpt_steps='chat' in steps_to_run_set,
process_website_steps='web' in steps_to_run_set
# TODO: Weitere Schritt-Flags hier übergeben
)
elif selected_mode == "full_run":
# Full_run verarbeitet sequenziell einen Bereich.
# Startzeile wird vom CLI Argument oder automatisch ermittelt (erste leere AO).
# Endzeile vom CLI Argument oder bis Ende Sheet.
# Limit begrenzt die Anzahl der *verarbeiteten* Zeilen im Bereich.
calculated_start_sheet_row = start_row_arg # Beginne mit CLI Argument
if calculated_start_sheet_row is None:
# Automatische Ermittlung der Startzeile (erste Zeile ohne AO)
logger.info("Automatische Ermittlung der Startzeile für sequenzielle Verarbeitung (erste Zeile ohne AO)...")
# get_start_row_index gibt 0-basierten Index in Daten (ohne Header) zurück
start_data_index_no_header = sheet_handler.get_start_row_index(check_column_key="Timestamp letzte Prüfung")
if start_data_index_no_header == -1:
logger.error("FEHLER bei automatischer Ermittlung der Startzeile.")
# Laden fehlgeschlagen, Skript muss beendet werden
return
calculated_start_sheet_row = start_data_index_no_header + sheet_handler._header_rows + 1 # 1-basierte Sheet-Zeile
# Berechnen Sie die tatsächliche Anzahl der zu verarbeitenden Zeilen im Bereich
# (basierend auf Endzeile und Limit)
total_sheet_rows = len(sheet_handler.get_all_data_with_headers())
calculated_end_sheet_row = end_row_arg if end_row_arg is not None else total_sheet_rows
# Der zu betrachtende Bereich ist [calculated_start_sheet_row, calculated_end_sheet_row]
# Anzahl der Zeilen im betrachteten Bereich
rows_in_range = max(0, calculated_end_sheet_row - calculated_start_sheet_row + 1)
# num_to_process ist das Limit, angewendet auf die Zeilen im Bereich
num_to_process = rows_in_range # Standard: alle Zeilen im Bereich
if limit_arg is not None and limit_arg >= 0:
num_to_process = min(rows_in_range, limit_arg)
if num_to_process > 0:
logger.info(f"'full_run': Verarbeite {num_to_process} Zeilen im Sheet-Bereich [{calculated_start_sheet_row}, {calculated_end_sheet_row}].")
# Rufen Sie die sequenzielle Verarbeitungsmethode auf
data_processor.process_rows_sequentially(
start_sheet_row = calculated_start_sheet_row,
num_to_process = num_to_process,
# Übergeben Sie die aus dem --steps Argument ermittelten Flags
process_wiki_steps='wiki' in steps_to_run_set,
process_chatgpt_steps='chat' in steps_to_run_set,
process_website_steps='web' in steps_to_run_set
# TODO: Weitere Schritt-Flags hier übergeben
# force_reeval_in_single_row=False # Normalerweise kein Re-Eval im Full-Run
)
else:
logger.info(f"Keine Zeilen für 'full_run' zu verarbeiten im Bereich [{calculated_start_sheet_row}, {calculated_end_sheet_row}] mit Limit {limit_arg}.")
elif selected_mode == "find_wiki_serp":
# find_wiki_serp nutzt limit, min_employees, min_umsatz und automatische Startzeile (leere AY)
data_processor.process_find_wiki_serp(
start_sheet_row=start_row_arg, # Kann manuell gesetzt werden
end_sheet_row=end_row_arg, # Kann manuell gesetzt werden
limit=limit_arg,
min_employees=args.min_employees,
min_umsatz=args.min_umsatz
)
elif selected_mode == "website_lookup":
# website_lookup sucht leere D. Nutzt limit und scannt ab Zeile 7 standardmäßig.
data_processor.process_serp_website_lookup(
start_sheet_row=start_row_arg, # Kann manuell gesetzt werden
end_sheet_row=end_row_arg, # Kann manuell gesetzt werden
limit=limit_arg
)
elif selected_mode == "contacts":
# contacts sucht leere AM. Nutzt limit und scannt ab Zeile 7 standardmäßig.
data_processor.process_contact_search(
start_sheet_row=start_row_arg, # Kann manuell gesetzt werden
end_sheet_row=end_row_arg, # Kann manuell gesetzt werden
limit=limit_arg
)
elif selected_mode == "update_wiki_suggestions":
# update_wiki_suggestions prüft Status S. Nutzt limit und scannt ab Zeile 7 standardmäßig.
data_processor.process_wiki_updates_from_chatgpt(
start_sheet_row=start_row_arg, # Kann manuell gesetzt werden
end_sheet_row=end_row_arg, # Kann manuell gesetzt werden
limit=limit_arg
)
elif selected_mode == "train_technician_model":
# training braucht keine Zeilenlimits, verwendet prepare_data_for_modeling
data_processor.train_technician_model(
model_out=args.model_out,
imputer_out=args.imputer_out,
patterns_out=args.patterns_out # Dies ist jetzt die JSON Datei
)
elif selected_mode == "alignment":
# alignment_demo ist eine globale Funktion, die das sheet Objekt braucht
if sheet_handler and sheet_handler.sheet:
alignment_demo(sheet_handler.sheet)
else:
logger.error("Sheet-Handler oder Sheet-Objekt nicht verfügbar für Alignment-Demo.")
elif selected_mode == "wiki_reextract_missing_an":
# wiki_reextract_missing_an sucht M gefüllt & AN leer. Nutzt limit und scannt ab Zeile 7.
data_processor.process_wiki_reextract_missing_an(
start_sheet_row=start_row_arg, # Kann manuell gesetzt werden
end_sheet_row=end_row_arg, # Kann manuell gesetzt werden
limit=limit_arg
)
elif selected_mode == "website_details":
# website_details sucht 'x' in A. Nutzt limit und scannt ab Zeile 7.
data_processor.process_website_details(
start_sheet_row=start_row_arg, # Kann manuell gesetzt werden
end_sheet_row=end_row_arg, # Kann manuell gesetzt werden
limit=limit_arg
)
else:
# Dieser Zweig sollte aufgrund der Validierung am Anfang nie erreicht werden
logger.error(f"Unerwarteter Modus '{selected_mode}' erreichte das Ausführungsende des Dispatchers.")
print(f"Interner Fehler: Unbekannter Modus '{selected_mode}'.")
except KeyboardInterrupt:
logger.warning("Skript durch Benutzer unterbrochen (KeyboardInterrupt).")
print("\n! Skript wurde manuell beendet.")
except Exception as e:
# Dieser Block fängt Fehler ab, die in den aufgerufenen Funktionen/Methoden passieren
logger.critical(f"FATAL: Unerwarteter Fehler während der Ausführung von Modus '{selected_mode}': {e}")
# exception() loggt den Fehlertyp, die Nachricht und den Traceback
logger.exception("Traceback des kritischen Fehlers:")
print(f"\n! Ein kritischer Fehler ist aufgetreten: {type(e).__name__} - {e}")
print(f"Bitte prüfen Sie die Logdatei für Details: {LOG_FILE}")
# --- Abschluss ---
end_time = time.time()
duration = end_time - start_time
logger.info(f"Verarbeitung abgeschlossen um {datetime.now().strftime('%H:%M:%S')}.")
logger.info(f"Gesamtdauer: {duration:.2f} Sekunden.")
logger.info(f"===== Skript beendet =====")
# Schließe Logging Handler explizit
logging.shutdown()
# Logfile Pfad für den Nutzer ausgeben (geht auf Konsole)
if LOG_FILE:
print(f"\nVerarbeitung abgeschlossen. Logfile: {LOG_FILE}")
else:
print("\nVerarbeitung abgeschlossen. Es konnte keine Logdatei erstellt werden.")
# ==============================================================================
# 9. ENTRY POINT
# ==============================================================================
# Führt die main-Funktion aus, wenn das Skript direkt gestartet wird
if __name__ == '__main__':
# Stellen Sie sicher, dass alle globalen Imports am Anfang der Datei stehen
# Stellen Sie sicher, dass alle globalen Hilfsfunktionen (retry_on_failure,
# token_count, clean_text, normalize_company_name, simple_normalize_url,
# extract_numeric_value, get_numeric_filter_value, get_gender, get_email_address,
# fuzzy_similarity, is_valid_wikipedia_article_url, serp_wikipedia_lookup,
# serp_website_lookup, search_linkedin_contacts, call_openai_chat,
# summarize_website_content, summarize_batch_openai,
# evaluate_branche_chatgpt, load_target_schema, create_log_filename,
# scrape_website_details (falls implementiert) etc.) vor der main() Funktion
# oder in importierten Modulen definiert sind.
# Stellen Sie sicher, dass alle Klassen (Config, GoogleSheetHandler, WikipediaScraper,
# DataProcessor) vor der main() Funktion definiert sind.
# Die globale Variable LOG_FILE muss vor main() initialisiert werden (z.B. LOG_FILE = None)
# und wird dann in main() gesetzt.
main()