Files
Brancheneinstufung2/brancheneinstufung.py
2025-04-24 14:42:12 +00:00

5156 lines
354 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.
# ==============================================================================
# brancheneinstufung.py - Firmen-Datenanreicherungs-Skript
# Version 1.6.7
# Dieses Skript automatisiert die Anreicherung, Validierung und Standardisierung
# von Unternehmensdaten in einem Google Sheet mittels Web Scraping und APIs.
# Es beinhaltet auch Datenvorbereitung für ein ML-Modell.
# ==============================================================================
import os
import time
import re
import gspread
import wikipedia
import requests
import openai
from bs4 import BeautifulSoup
from oauth2client.service_account import ServiceAccountCredentials
from datetime import datetime
from difflib import SequenceMatcher
import unicodedata
import csv
import gender_guesser.detector as gender
from urllib.parse import urlparse, urlencode, unquote
import argparse
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.impute import SimpleImputer
from sklearn.tree import DecisionTreeClassifier, export_text
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
import json
import pickle
import concurrent.futures
import threading
import traceback # Importiere traceback für detailliertere Fehlermeldungen
import logging
import functools # Für retry decorator
# Optional: tiktoken für Token-Zählung (Modus 8)
try:
import tiktoken
except ImportError:
tiktoken = None
# ==================== KONSTANTEN ====================
CREDENTIALS_FILE = "service_account.json"
API_KEY_FILE = "api_key.txt"
SERP_API_KEY_FILE = "serpApiKey.txt"
GENDERIZE_API_KEY_FILE = "genderize_API_Key.txt"
BRANCH_MAPPING_FILE = "ziel_Branchenschema.csv"
LOG_DIR = "Log"
# --- NEU: Dateinamen für Modell-Artefakte ---
MODEL_FILE = "technician_decision_tree_model.pkl"
IMPUTER_FILE = "median_imputer.pkl"
PATTERNS_FILE_TXT = "technician_patterns.txt"
PATTERNS_FILE_JSON = "technician_patterns.json" # Optional
# ==================== KONFIGURATION ====================
class Config:
VERSION = "v1.7.0" # Versionsnummer erhöhen
LANG = "de" # Standard Wikipedia Sprache
SHEET_URL = "https://docs.google.com/spreadsheets/d/1u_gHr9JUfmV1-iviRzbSe3575QEp7KLhK5jFV_gJcgo" # Ihre Sheet URL
MAX_RETRIES = 3 # Maximale Anzahl von Wiederholungen für fehlgeschlagene Operationen
RETRY_DELAY = 5 # Wartezeit in Sekunden zwischen Retries (Basis)
SIMILARITY_THRESHOLD = 0.65 # Schwellenwert für Namensähnlichkeit (z.B. bei Wikipedia)
DEBUG = True # Steuerflag für debug_print (sollte konsequent auf logging umgestellt werden)
WIKIPEDIA_SEARCH_RESULTS = 5 # Anzahl der Suchergebnisse, die von wikipedia.search geprüft werden
HTML_PARSER = "html.parser" # HTML Parser für BeautifulSoup
TOKEN_MODEL = "gpt-3.5-turbo" # OpenAI Modell für Token-Zählung (und ggf. Standard für API Calls)
USER_AGENT = 'Mozilla/5.0 (compatible; Datenanreicherungsskript/1.0; +https://ihre-website.de/bot)' # User-Agent für Web-Anfragen
# --- Konfiguration für Batching & Parallelisierung ---
BATCH_SIZE = 10 # Batch-Größe für Wiki Verification (_process_batch)
PROCESSING_BATCH_SIZE = 20 # Wie viele Zeilen pro Verarbeitungs-Batch sammeln (z.B. für paralleles Website Scraping)
OPENAI_BATCH_SIZE_LIMIT = 4 # Max. Texte pro OpenAI Call in summarize_batch_openai
MAX_SCRAPING_WORKERS = 10 # Threads für paralleles Website-Scraping
UPDATE_BATCH_ROW_LIMIT = 50 # Anzahl der Zeilen, nach denen gesammelte Sheet Updates gesendet werden (nicht überall konsistent verwendet)
MAX_BRANCH_WORKERS = 10 # Threads für parallele Branch-Bewertung
OPENAI_CONCURRENCY_LIMIT = 5 # Max. gleichzeitige OpenAI Calls für Branch Bewertung
PROCESSING_BRANCH_BATCH_SIZE = PROCESSING_BATCH_SIZE # Nutze dieselbe Batch-Größe wie Website, oder definiere neu (z.B. 20)
API_KEYS = {}
@classmethod
def load_api_keys(cls):
"""Lädt API-Schlüssel aus den definierten Dateien."""
logging.info("Lade API-Schlüssel...")
# Annahme: Die Dateipfade sind korrekt und die Dateien existieren (oder werden behandelt)
cls.API_KEYS['openai'] = cls._load_key_from_file(API_KEY_FILE)
cls.API_KEYS['serpapi'] = cls._load_key_from_file(SERP_API_KEY_FILE)
cls.API_KEYS['genderize'] = cls._load_key_from_file(GENDERIZE_API_KEY_FILE)
if cls.API_KEYS.get('openai'):
openai.api_key = cls.API_KEYS['openai']
logging.info("OpenAI API Key erfolgreich geladen.")
else:
logging.warning(f"OpenAI API Key konnte nicht geladen werden (Datei '{API_KEY_FILE}' fehlt oder ist leer?).")
if cls.API_KEYS.get('serpapi'):
logging.info("SerpAPI Key erfolgreich geladen.")
else:
logging.warning(f"SerpAPI Key konnte nicht geladen werden (Datei '{SERP_API_KEY_FILE}' fehlt oder ist leer?). Einige Modi/Funktionen könnten fehlschlagen.")
if cls.API_KEYS.get('genderize'):
logging.info("Genderize API Key erfolgreich geladen.")
else:
logging.warning(f"Genderize API Key konnte nicht geladen werden (Datei '{GENDERIZE_API_KEY_FILE}' fehlt oder ist leer?). Kontakt-Gender wird möglicherweise nicht ermittelt.")
@staticmethod
def _load_key_from_file(filepath):
"""Hilfsfunktion zum Laden eines Schlüssels aus einer Datei."""
try:
with open(filepath, "r", encoding="utf-8") as f: # Encoding hinzugefügt
key = f.read().strip()
if key:
# logging.debug(f"Schlüssel aus '{filepath}' erfolgreich geladen.") # Zu detailliert für normale Läufe
return key
else:
# logging.warning(f"Datei '{filepath}' ist leer.") # Weniger Lärm
return None
except FileNotFoundError:
# Info, da das Fehlen eines Keys nicht immer ein Fehler sein muss
# logging.info(f"API-Schlüsseldatei '{filepath}' nicht gefunden.") # Weniger Lärm
return None
except Exception as e:
# Error, wenn beim Lesen ein anderer Fehler auftritt
logging.error(f"Fehler beim Lesen der Schlüsseldatei '{filepath}': {e}")
return None
# Globales Mapping-Dictionary und Schema-String
BRANCH_MAPPING = {}
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar."
ALLOWED_TARGET_BRANCHES = []
# ==================== LOGGING SETUP (INITIAL) ====================
# Initiales Logging Setup. File Handler wird in main() hinzugefügt.
# Root-Logger konfigurieren (noch ohne File Handler).
# handlers=[] verhindert default Console Handler, wir fügen ihn manuell hinzu.
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)-8s - %(name)-15s - %(message)s', handlers=[])
# Console Handler explizit hinzufügen
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG) # Debug Level an Konsole
console_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)-8s - %(name)-15s - %(message)s'))
logging.getLogger('').addHandler(console_handler) # Füge zum Root-Logger hinzu
# debug_print wird beibehalten, um bestehenden Code schnell laufen zu lassen,
# sollte aber konsequent durch logging.debug/info/etc. ersetzt werden.
def debug_print(message):
"""Gibt Nachrichten aus, wenn Config.DEBUG True ist. Schreibt auch in die Logdatei."""
# Loggen direkt mit dem Standard logging-Modul als INFO
logging.info(message)
# ==================== RETRY DECORATOR ====================
def retry_on_failure(func):
"""
Decorator, der eine Funktion mit exponentiellem Backoff wiederholt,
wenn eine Exception auftritt (insbesondere Netzwerk-/API-Fehler).
"""
@functools.wraps(func) # Behält Metadaten der Originalfunktion
def wrapper(*args, **kwargs):
func_name = func.__name__
# Versuche, das 'self' Argument für Methoden zu extrahieren
# args[0] ist self bei Instanzmethoden
self_arg = args[0] if args and hasattr(args[0], func_name) and not isinstance(args[0], type) else None
effective_func_name = f"{self_arg.__class__.__name__}.{func_name}" if self_arg else func_name
for attempt in range(Config.MAX_RETRIES):
try:
return func(*args, **kwargs)
except Exception as e:
error_msg = str(e)
# Spezifische Fehlerbehandlung (Beispiel)
is_rate_limit = False
if isinstance(e, gspread.exceptions.APIError):
if hasattr(e, 'response') and e.response.status_code == 429: # Google Rate Limit
is_rate_limit = True
logging.warning(f"⚠️ Google API Fehler bei {effective_func_name} (Versuch {attempt+1}/{Config.MAX_RETRIES}): Status {e.response.status_code} - {error_msg[:150]}...")
if hasattr(e, 'response') and e.response.text: logging.debug(f" Details: {e.response.text[:200]}...")
elif isinstance(e, requests.exceptions.RequestException):
if hasattr(e, 'response') and e.response is not None and e.response.status_code == 429: # HTTP 429 (auch SerpAPI, Genderize etc.)
is_rate_limit = True
logging.warning(f"⚠️ Netzwerkfehler bei {effective_func_name} (Versuch {attempt+1}/{Config.MAX_RETRIES}): {type(e).__name__} - {error_msg[:150]}...")
elif isinstance(e, openai.error.OpenAIError):
if hasattr(e, 'response') and e.response.status_code == 429: # OpenAI Rate Limit
is_rate_limit = True
logging.warning(f"⚠️ OpenAI Fehler bei {effective_func_name} (Versuch {attempt+1}/{Config.MAX_RETRIES}): {type(e).__name__} - {error_msg[:150]}...")
elif isinstance(e, wikipedia.exceptions.WikipediaException):
logging.warning(f"⚠️ Wikipedia Bibliothek Fehler bei {effective_func_name} (Versuch {attempt+1}/{Config.MAX_RETRIES}): {type(e).__name__} - {error_msg[:150]}...")
else:
logging.warning(f"⚠️ Unbekannter Fehler bei {effective_func_name} (Versuch {attempt+1}/{Config.MAX_RETRIES}): {type(e).__name__} - {error_msg[:150]}...")
if attempt < Config.MAX_RETRIES - 1:
wait_time = Config.RETRY_DELAY * (2 ** attempt if is_rate_limit else 1) # Exponential Backoff nur bei Rate Limits
logging.info(f" Warte {wait_time:.2f}s vor nächstem Versuch...")
time.sleep(wait_time)
else:
logging.error(f"❌ Endgültiger Fehler bei {effective_func_name} nach {Config.MAX_RETRIES} Versuchen.")
# Logge den Traceback beim letzten Versuch als ERROR
logging.exception(f"Traceback für endgültigen Fehler in {effective_func_name}:")
return None # Oder eine spezifische Fehlerkennung zurückgeben, je nach Funktion
return None # Sollte bei erfolgreichen Retries nie erreicht werden, aber zur Sicherheit
return wrapper
# ==================== GLOBAL HELPER FUNCTIONS ====================
# Funktionen, die keine Instanz einer spezifischen Klasse benötigen.
def create_log_filename(mode):
"""Erstellt einen eindeutigen Log-Dateinamen basierend auf Datum, Version und Modus."""
if not os.path.exists(LOG_DIR):
os.makedirs(LOG_DIR)
now = datetime.now().strftime("%Y-%m-%d_%H-%M")
ver_short = Config.VERSION.replace(".", "")
return os.path.join(LOG_DIR, f"{now}_{ver_short}_Modus{mode}.txt")
# clean_text Funktion
def clean_text(text):
"""Bereinigt Text von Wikipedia etc. (Unicode, Referenzen, Whitespace)."""
if text is None: return "k.A."
try:
text = str(text)
if not text.strip(): return "k.A."
text = unicodedata.normalize("NFC", text)
text = re.sub(r'\[\d+\]', '', text)
text = re.sub(r'\[Bearbeiten\s*\|\s*Quelltext bearbeiten\]', '', text, flags=re.IGNORECASE)
text = re.sub(r'\s+', ' ', text).strip()
return text if text else "k.A."
except Exception as e:
logging.error(f"Fehler bei clean_text für Input '{str(text)[:50]}...': {e}")
return "k.A."
# simple_normalize_url Funktion
def simple_normalize_url(url):
"""Normalisiert URL zu domain.tld (ohne www, schema, pfad) oder k.A.."""
if not url or not isinstance(url, str): return "k.A."
url = url.strip()
if not url or url.lower() == 'k.a.': return "k.A."
if not url.lower().startswith(("http://", "https://")): url = "https://" + url
try:
parsed = urlparse(url)
domain_part = parsed.netloc
if not domain_part: return "k.A."
domain_part = domain_part.split(":", 1)[0] # Entferne Port
if '@' in domain_part: domain_part = domain_part.split('@', 1)[1] # Entferne Auth
try: domain_part = domain_part.encode('ascii').decode('idna')
except UnicodeDecodeError: pass
domain_part = domain_part.lower()
if domain_part.startswith("www."): domain_part = domain_part[4:]
return domain_part if domain_part and '.' in domain_part else "k.A."
except Exception as e:
logging.error(f"Fehler bei URL-Normalisierung für '{url}': {e}")
return "k.A."
# normalize_string Funktion
def normalize_string(s):
"""Normalisiert Umlaute und Sonderzeichen, entfernt führende/nachfolgende Leerzeichen."""
if not s or not isinstance(s, str): return ""
s = str(s)
s = unicodedata.normalize("NFC", s)
replacements = { 'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue', 'ß': 'ss', 'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Å': 'A', 'Æ': 'AE', 'à': 'a', 'á': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'æ': 'ae', 'Ç': 'C', 'ç': 'c', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e', 'Ì': 'I', 'Í': 'I', 'Î': 'I', 'Ï': 'I', 'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i', 'Ñ': 'N', 'ñ': 'n', 'Ò': 'O', 'Ó': 'O', 'Ô': 'O', 'Õ': 'O', 'Ø': 'O', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', 'ø': 'o', 'Œ': 'OE', 'œ': 'oe', 'Š': 'S', 'š': 's', 'Ž': 'Z', 'ž': 'z', 'Ý': 'Y', 'ý': 'y', 'ÿ': 'y', 'Đ': 'D', 'đ': 'd', 'č': 'c', 'Č': 'C', 'ć': 'c', 'Ć': 'C', 'ł': 'l', 'Ł': 'L', 'ğ': 'g', 'Ğ': 'G', 'ş': 's', 'Ş': 'S', 'ă': 'a', 'Ă': 'A', 'ı': 'i', 'İ': 'I', 'ň': 'n', 'Ň': 'N', 'ř': 'r', 'Ř': 'R', 'ő': 'o', 'Ő': 'O', 'ű': 'u', 'Ű': 'U', 'ț': 't', 'Ț': 'T', 'ș': 's', 'Ș': 'S' }
for src, target in replacements.items(): s = s.replace(src, target)
return s.strip()
# normalize_company_name Funktion
def normalize_company_name(name):
"""Entfernt gängige Rechtsformzusätze, Interpunktion und generische Begriffe für Vergleiche."""
if not name: return ""
name = clean_text(name)
if name == "k.A.": return ""
forms = [ r'gmbh', r'ges\.?\s*m\.?\s*b\.?\s*h\.?', r'gesellschaft mit beschränkter haftung', r'ug', r'u\.g\.', r'unternehmergesellschaft', r'haftungsbeschränkt', r'ag', r'a\.g\.', r'aktiengesellschaft', r'ohg', r'o\.h\.g\.', r'offene handelsgesellschaft', r'kg', r'k\.g\.', r'kommanditgesellschaft', r'gmbh\s*&\s*co\.?\s*kg', r'ges\.?\s*m\.?\s*b\.?\s*h\.?\s*&\s*co\.?\s*k\.g\.?', r'ag\s*&\s*co\.?\s*kg', r'a\.g\.?\s*&\s*co\.?\s*k\.g\.?', r'e\.k\.', r'e\.kfm\.', r'e\.kfr\.', r'eingetragene[rn]? kauffrau', r'eingetragene[rn]? kaufmann', r'ltd\.?', r'limited', r'ltd\s*&\s*co\.?\s*kg', r's\.?a\.?r\.?l\.?', r'sàrl', r'sagl', r's\.?a\.?', r'société anonyme', r'sociedad anónima', r's\.?p\.?a\.?', r'società per azioni', r'b\.?v\.?', r'besloten vennootschap', r'n\.?v\.?', r'naamloze vennootschap', r'plc\.?', r'public limited company', r'inc\.?', r'incorporated', r'corp\.?', r'corporation', r'llc\.?', r'limited liability company', r'kgaa', r'kommanditgesellschaft auf aktien', r'se', r'societas europaea', r'e\.?g\.?', r'eingetragene genossenschaft', r'genossenschaft', r'genmbh', r'e\.?v\.?', r'eingetragener verein', r'verein', r'stiftung', r'ggmbh', r'gemeinnützige gmbh', r'gug', r'partg', r'partnerschaftsgesellschaft', r'partgmbb', r'og', r'o\.g\.', r'offene gesellschaft', r'e\.u\.', r'eingetragenes unternehmen', r'ges\.?n\.?b\.?r\.?', r'gesellschaft nach bürgerlichem recht', r'kollektivgesellschaft', r'einzelfirma', r'co\.', r'und co', r' & co', r'gruppe', r'group', r'holding', r'international', r'systeme', r'technik', r'logistik', r'solutions', r'services', r'management', r'consulting', r'produktion', r'vertrieb', r'entwicklung', r'maschinenbau', r'anlagenbau' ]
forms_sorted = sorted(forms, key=len, reverse=True)
pattern = r'\b(' + '|'.join(forms_sorted) + r')\b'
normalized = re.sub(pattern, '', name, flags=re.IGNORECASE)
normalized = re.sub(r'[.,;:]', '', normalized)
normalized = re.sub(r'[\-/]', ' ', normalized)
normalized = re.sub(r'\s+', ' ', normalized).strip()
return normalized.lower()
# extract_numeric_value Funktion (für Wikipedia Infoboxen)
def extract_numeric_value(raw_value, is_umsatz=False):
"""Extrahiert und normalisiert Zahlenwerte (Umsatz in Mio, Mitarbeiter).
Berücksichtigt jetzt auch Apostroph (') als Tausendertrenner und Einheiten."""
if not raw_value: return "k.A."
raw_value_str = str(raw_value)
if not raw_value_str.strip() or raw_value_str.strip().lower() in ['k.a.', 'n/a', '-']:
return "k.A."
processed_value = clean_text(raw_value_str)
if processed_value == "k.A.": return "k.A."
logging.debug(f"extract_numeric_value: Verarbeite Wert: '{raw_value_str}' -> Bereinigt: '{processed_value}' (is_umsatz={is_umsatz})")
processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|über|unter|mehr als|weniger als|bis zu)\s+', '', processed_value)
processed_value = re.sub(r'[€$£¥]', '', processed_value).strip()
processed_value = re.split(r'\s*(-||bis)\s*', processed_value, 1)[0].strip()
processed_value_no_thousands = processed_value.replace('.', '').replace("'", "")
processed_value_final = processed_value_no_thousands.replace(',', '.')
match = re.search(r'([\d.]+)', processed_value_final)
if not match:
logging.debug(f"extract_numeric_value: Keine numerischen Zeichen gefunden nach Bereinigung/Vorverarbeitung: '{processed_value_final}' (aus '{raw_value_str}')")
return "k.A."
num_str = match.group(1)
try:
if not num_str or num_str == '.': raise ValueError("Leerer oder ungültiger Zahlenstring")
num = float(num_str)
except ValueError as e:
logging.error(f"extract_numeric_value: Fehler bei Float-Umwandlung des extrahierten Strings '{num_str}' (aus '{processed_value}'): {e}")
return "k.A."
original_lower = raw_value_str.lower()
multiplier = 1.0
unit_found_log = ""
if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower): multiplier = 1000000000.0; unit_found_log = "Mrd"
elif re.search(r'\bmio\s*\b|\bmillionen\s*\b|\bmill\.\s*\b', original_lower): multiplier = 1000000.0; unit_found_log = "Mio"
elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): multiplier = 1000.0; unit_found_log = "Tsd"
num = num * multiplier
if unit_found_log: logging.debug(f" -> Multiplikator '{unit_found_log}' ({multiplier}) basierend auf Originalstring angewendet, Ergebnis: {num:.2f}")
else: logging.debug(f" -> Kein Multiplikator angewendet, Ergebnis: {num:.2f}")
if is_umsatz:
umsatz_mio = round(num / 1000000.0)
logging.info(f" -> Finaler Umsatz (Mio): {umsatz_mio}")
return str(int(umsatz_mio)) if umsatz_mio >= 0 else "k.A."
else:
mitarbeiter_int = round(num)
logging.info(f" -> Finale Mitarbeiterzahl: {mitarbeiter_int}")
return str(int(mitarbeiter_int)) if mitarbeiter_int >= 0 else "k.A."
# get_numeric_filter_value Funktion (für Filterlogik)
def get_numeric_filter_value(value_str, is_umsatz=False):
"""
Extrahiert und normalisiert Zahlenwerte für die Filterlogik (Umsatz in Mio, Mitarbeiter int).
Gibt 0 zurück, wenn der Wert leer, k.A., nicht numerisch ist, oder 0 ergibt.
Beachtet Einheiten (Tsd, Mio, Mrd) für Umsatz und konvertiert zu Millionen € wenn is_umsatz=True.
Beachtet Tsd für Mitarbeiter und konvertiert zu Int wenn is_umsatz=False.
"""
if value_str is None or pd.isna(value_str) or str(value_str).strip() == '': return 0
raw_value_str = str(value_str).strip()
# Füge '0' hinzu, um 0 als "leer" für die Filterlogik zu interpretieren, wie gewünscht
if raw_value_str.lower() in ['k.a.', 'n/a', '-', '0']: return 0
try:
processed_value = clean_text(raw_value_str)
if processed_value == "k.A.": return 0
processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|über|unter|mehr als|weniger als|bis zu)\s+', '', processed_value)
processed_value = re.sub(r'[€$£¥]', '', processed_value).strip()
processed_value = re.split(r'\s*(-||bis)\s*', processed_value, 1)[0].strip()
processed_value_no_thousands = processed_value.replace('.', '').replace("'", "")
processed_value_final = processed_value_no_thousands.replace(',', '.')
match = re.search(r'([\d.]+)', processed_value_final)
if not match: return 0
num_str = match.group(1)
if not num_str or num_str == '.': return 0
num = float(num_str)
original_lower = raw_value_str.lower()
if is_umsatz:
if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower): num = num * 1000.0
elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): num = num / 1000.0
return num if num > 0 else 0
else:
if re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): num = num * 1000.0
return round(num) if round(num) > 0 else 0
except Exception as e:
logging.debug(f"Fehler in get_numeric_filter_value für Wert '{raw_value_str}': {e}")
return 0
# fuzzy_similarity Funktion
def fuzzy_similarity(str1, str2):
"""Berechnet Ähnlichkeit zwischen 0 und 1 (case-insensitive)."""
if not str1 or not str2: return 0.0
return SequenceMatcher(None, str(str1).lower(), str(str2).lower()).ratio()
# Token Count Funktion (optional)
@retry_on_failure # Kann fehlschlagen, wenn OpenAI API nicht erreichbar ist (obwohl nur ein lokales Lib)
def token_count(text):
"""Zählt Tokens via tiktoken oder schätzt über Leerzeichen."""
if not text or not isinstance(text, str): return 0
if tiktoken:
try:
if not hasattr(token_count, 'enc_cache'): token_count.enc_cache = {}
current_model_for_token = getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo') # Nutze Config
if current_model_for_token not in token_count.enc_cache:
token_count.enc_cache[current_model_for_token] = tiktoken.encoding_for_model(current_model_for_token)
enc = token_count.enc_cache[current_model_for_token]
return len(enc.encode(text))
except Exception as e:
# Fehler loggen, aber keinen Fehler werfen, Fallback nutzen
logging.warning(f"Fehler beim Token-Counting mit tiktoken für Modell '{current_model_for_token}': {e}")
# Fallback zur Schätzung
return len(text.split())
else:
# Fallback Schätzung
return len(text.split())
# ==================== API HELPER FUNCTIONS (Global) ====================
# Funktionen, die spezifische APIs aufrufen.
# call_openai_chat Funktion
@retry_on_failure # Wichtig, dass dieser Decorator angewendet wird
def call_openai_chat(prompt, temperature=0.3, model=None):
"""Zentrale Funktion für OpenAI Chat API Aufrufe."""
if not Config.API_KEYS.get('openai'):
logging.error("Fehler: OpenAI API Key nicht konfiguriert.")
return None
if not prompt:
logging.error("Fehler: Leerer Prompt für OpenAI.")
return None
current_model = model if model else Config.TOKEN_MODEL
try:
# Token zählen vor dem Senden (optional, gut für Debugging)
# try: prompt_tokens = token_count(prompt)
# except Exception as e_tc: prompt_tokens = -1; logging.debug(f"Token count error: {e_tc}")
# logging.debug(f"Sende Prompt an OpenAI ({current_model})... Tokens: {prompt_tokens}")
response = openai.ChatCompletion.create(
model=current_model,
messages=[{"role": "user", "content": prompt}],
temperature=temperature
)
result = response.choices[0].message.content.strip()
# Token zählen für die Antwort (optional)
# try: total_tokens = response.usage.total_tokens
# except: total_tokens = -1
# logging.debug(f"OpenAI Antwort erhalten. Gesamt Tokens: {total_tokens}")
return result
except openai.error.InvalidRequestError as e:
# InvalidRequestError deutet oft auf Token Limit hin oder ungültigen Input
logging.error(f"OpenAI Invalid Request Error: {e}")
if "maximum context length" in str(e):
logging.error("FEHLER: Prompt war zu lang (Token Limit).")
return None # Bei diesem Fehler keinen Retry versuchen (hängt vom Prompt ab)
except openai.error.RateLimitError as e:
# RateLimitError wird vom retry_on_failure Decorator behandelt
logging.warning(f"OpenAI Rate Limit Error: {e}")
raise e # Fehler weitergeben für Retry
except openai.error.AuthenticationError as e:
logging.critical(f"OpenAI Authentication Error: {e}. Überprüfen Sie Ihren API-Schlüssel!")
return None # Kein Retry bei Auth Error
except openai.error.OpenAIError as e: # Fängt andere APIError etc. ab
logging.error(f"OpenAI API Fehler: {e}")
raise e # Fehler weitergeben für Retry
except Exception as e:
logging.error(f"Allgemeiner Fehler bei OpenAI-Aufruf: {e}")
raise e # Fehler weitergeben
# summarize_website_content Funktion
# @retry_on_failure # Summarize Batch Funktion nutzt Retry
def summarize_website_content(raw_text):
"""Erstellt Zusammenfassung von Website-Rohtext via OpenAI."""
if not raw_text or raw_text == "k.A." or raw_text.strip() == "":
return "k.A."
# Kürze den Rohtext, falls er sehr lang ist
max_raw_length = 3000 # Zeichenlimit für den Input der Zusammenfassung
if len(raw_text) > max_raw_length:
logging.debug(f"Kürze Rohtext für Zusammenfassung von {len(raw_text)} auf {max_raw_length} Zeichen.")
raw_text = raw_text[:max_raw_length]
prompt = (
"Du bist ein KI-Assistent, der Webinhalte analysiert.\n"
"Fasse den folgenden Text einer Unternehmenswebsite prägnant zusammen. "
"Konzentriere dich auf:\n"
"- Haupttätigkeitsfeld des Unternehmens\n"
"- Wichtigste Produkte und/oder Dienstleistungen\n"
"- Zielgruppe (falls erkennbar)\n\n"
f"Website-Text:\n```\n{raw_text}\n```\n\n"
"Zusammenfassung (max. 100 Wörter):"
)
# call_openai_chat hat bereits retry
summary = call_openai_chat(prompt, temperature=0.2)
return summary if summary else "k.A. (Summarization Failed)"
# evaluate_branche_chatgpt Funktion
# @retry_on_failure # Wird vom Batch Caller oder _process_single_row mit Retry gehandhabt? Nein, call_openai_chat hat Retry.
def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kategorien, website_summary):
"""
Ordnet das Unternehmen basierend auf Infos exakt einer Branche des Ziel-Schemas zu.
Validiert Vorschlag und nutzt Fallback auf CRM-Kurzform, falls ungültig.
"""
global ALLOWED_TARGET_BRANCHES, TARGET_SCHEMA_STRING
if not ALLOWED_TARGET_BRANCHES:
logging.critical("FEHLER in evaluate_branche_chatgpt: Ziel-Branchenschema nicht geladen.")
return {"branch": crm_branche, "consistency": "error_schema_missing", "justification": "Fehler: Ziel-Schema nicht geladen"}
allowed_branches_lookup = {b.lower(): b for b in ALLOWED_TARGET_BRANCHES}
prompt_parts = [TARGET_SCHEMA_STRING]
prompt_parts.append("\nOrdne das Unternehmen anhand folgender Angaben exakt einer Branche des Ziel-Branchenschemas (Kurzformen) zu:")
info_count = 0
if crm_branche and crm_branche != "k.A.": prompt_parts.append(f"- CRM-Branche (Referenz): {crm_branche}"); info_count += 1
if beschreibung and beschreibung != "k.A.": prompt_parts.append(f"- Beschreibung: {beschreibung[:500]}..."); info_count += 1
if wiki_branche and wiki_branche != "k.A.": prompt_parts.append(f"- Wikipedia-Branche: {wiki_branche[:300]}"); info_count += 1
if wiki_kategorien and wiki_kategorien != "k.A.": prompt_parts.append(f"- Wikipedia-Kategorien: {wiki_kategorien[:500]}..."); info_count += 1
if website_summary and website_summary != "k.A.": prompt_parts.append(f"- Website-Zusammenfassung: {website_summary[:500]}..."); info_count += 1
if info_count < 2:
logging.warning("Warnung in evaluate_branche_chatgpt: Zu wenige Informationen (<2) für Branchenevaluierung.")
return {"branch": crm_branche, "consistency": "error_no_info", "justification": "Fehler: Zu wenige Informationen für eine Einschätzung"}
prompt_parts.append("\nWICHTIG: Antworte NUR mit dem exakten Kurznamen einer Branche aus der obigen Liste. Verwende KEINE Präfixe wie 'Hersteller / Produzenten >' oder 'Service provider (Dienstleister) >'.")
prompt_parts.append("\nAntworte ausschließlich im folgenden Format (keine Einleitung, kein Schlusssatz):")
prompt_parts.append("Branche: <Exakter Kurzname der Branche aus der Liste>")
# Prompt entfernt Übereinstimmung, da wir das selbst berechnen
prompt_parts.append("Begründung: <Sehr kurze Begründung für deinen Branchenvorschlag>")
prompt = "\n".join(prompt_parts)
# logging.debug(f"Erstellter Prompt für Branchenevaluierung:\n---\n{prompt}\n---") # Zu ausführlich
chat_response = call_openai_chat(prompt, temperature=0.0) # Niedrige Temperatur für konsistente Zuordnung
if not chat_response:
logging.error("Fehler in evaluate_branche_chatgpt: Keine Antwort von OpenAI erhalten.")
return {"branch": crm_branche, "consistency": "error_api_no_response", "justification": "Fehler: Keine Antwort von API"}
# logging.debug(f"OpenAI Antwort für Branchenevaluierung: {chat_response}") # Zu ausführlich
lines = chat_response.strip().split("\n")
result = {"branch": None, "consistency": None, "justification": ""}
suggested_branch = ""
parsed_branch = False
for line in lines:
line_lower = line.lower()
if line_lower.startswith("branche:"):
suggested_branch = line.split(":", 1)[1].strip().strip('"\'')
parsed_branch = True
elif line_lower.startswith("begründung:"):
result["justification"] = line.split(":", 1)[1].strip()
if not parsed_branch or not suggested_branch:
logging.error(f"Fehler in evaluate_branche_chatgpt: Konnte 'Branche:' nicht oder nur leer aus Antwort parsen: {chat_response}")
return {"branch": crm_branche, "consistency": "error_parsing", "justification": f"Fehler: Parsing der API Antwort fehlgeschlagen. Antwort: {chat_response[:200]}..."}
final_branch = None
suggested_branch_lower = suggested_branch.lower()
if suggested_branch_lower in allowed_branches_lookup:
final_branch = allowed_branches_lookup[suggested_branch_lower]
# logging.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist gültig ('{final_branch}').") # Zu ausführlich
result["consistency"] = "pending_comparison" # Temporärer Status
else:
# logging.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist NICHT im Ziel-Schema. Starte Fallback...") # Zu ausführlich
crm_short_branch = "k.A."
if crm_branche and ">" in crm_branche: crm_short_branch = crm_branche.split(">", 1)[1].strip()
elif crm_branche and crm_branche != "k.A.": crm_short_branch = crm_branche.strip()
# logging.debug(f" Fallback: Prüfe extrahierte CRM-Kurzform: '{crm_short_branch}'") # Zu ausführlich
crm_short_branch_lower = crm_short_branch.lower()
if crm_short_branch != "k.A." and crm_short_branch_lower in allowed_branches_lookup:
final_branch = allowed_branches_lookup[crm_short_branch_lower]
result["consistency"] = "fallback_crm_valid"
fallback_reason = f"Fallback: Ungültiger GPT-Vorschlag ('{suggested_branch}'). Gültige CRM-Kurzform '{final_branch}' verwendet."
result["justification"] = f"{fallback_reason} (GPT Begründung war: {result.get('justification', 'Keine')})"
logging.info(f"Fallback auf gültige CRM-Kurzform erfolgreich: '{final_branch}'")
else:
final_branch = suggested_branch # Behalte ungültigen Vorschlag
result["consistency"] = "fallback_invalid"
error_reason = f"Fehler: Ungültiger GPT-Vorschlag ('{suggested_branch}') und keine gültige CRM-Kurzform ('{crm_short_branch}') als Fallback verfügbar."
result["justification"] = f"{error_reason} (GPT Begründung war: {result.get('justification', 'Keine')})"
logging.warning(f"Fallback fehlgeschlagen. Ungültiger Vorschlag: '{final_branch}', Ungültige CRM-Kurzform: '{crm_short_branch}'")
final_branch = "FEHLER - UNGÜLTIGE ZUWEISUNG" # Setze finalen Branch auf Fehler
result["branch"] = final_branch
# --- Konsistenzprüfung (Finale Bewertung) ---
crm_short_to_compare = "k.A."
if crm_branche and ">" in crm_branche: crm_short_to_compare = crm_branche.split(">", 1)[1].strip()
elif crm_branche and crm_branche != "k.A.": crm_short_to_compare = crm_branche.strip()
if result["branch"] != "FEHLER - UNGÜLTIGE ZUWEISUNG":
if result["branch"].lower() == crm_short_to_compare.lower():
if result["consistency"] == "pending_comparison": result["consistency"] = "ok"
elif result["consistency"] == "pending_comparison": result["consistency"] = "X"
if result["consistency"] == "pending_comparison":
logging.warning("Konsistenzprüfung blieb im Status 'pending_comparison', setze auf 'error_comparison_failed'.")
result["consistency"] = "error_comparison_failed"
elif result["consistency"] is None:
logging.error("Konsistenz blieb unerwartet None, setze auf 'error_unknown_state'.")
result["consistency"] = "error_unknown_state"
# logging.debug(f"Finale Branch-Evaluation: {result}") # Zu ausführlich
return result
# is_valid_wikipedia_article_url Funktion
@retry_on_failure # Apply retry to the specific API call
def is_valid_wikipedia_article_url(wiki_url):
"""
Prüft über die MediaWiki API, ob eine gegebene Wikipedia-URL
auf einen existierenden Artikel verweist (und keine Weiterleitung/Begriffsklärung ist).
"""
if not wiki_url or not isinstance(wiki_url, str) or not wiki_url.lower().startswith(("http://", "https://")) or "wikipedia.org/wiki/" not in wiki_url.lower():
logging.debug(f"is_valid_wikipedia_article_url: Ungültiges Format oder keine Wikipedia-URL: '{wiki_url}'")
return False
title = "URL_PARSE_ERROR"
try:
title_part = wiki_url.split('/wiki/', 1)[1]
title = unquote(title_part).replace('_', ' ')
api_url = f"https://{getattr(Config, 'LANG', 'de')}.wikipedia.org/w/api.php"
params = { "action": "query", "titles": title, "format": "json", "formatversion": 2, "prop": "info|pageprops", "redirects": 1 }
logging.debug(f"is_valid_wikipedia_article_url: Prüfe Titel '{title}' via MediaWiki API an {api_url}...")
response = requests.get(api_url, params=params, timeout=10, headers={'User-Agent': getattr(Config, 'USER_AGENT', 'Mozilla/5.0')})
response.raise_for_status()
data = response.json()
# logging.debug(f" -> API Antwort für '{title}': {str(data)[:200]}...") # Too verbose
if 'query' in data and 'pages' in data['query']:
pages = data['query']['pages']
if pages:
page_info = pages[0]
if page_info.get('missing', False): logging.debug(f" API Check für '{title}': Seite fehlt."); return False
if page_info.get('invalid', False): logging.debug(f" API Check für '{title}': Titel ungültig."); return False
if 'pageprops' in page_info and 'disambiguation' in page_info['pageprops']: logging.debug(f" API Check für '{title}': Seite ist eine Begriffsklärung."); return False
logging.info(f" API Check für '{title}': Scheint ein valider Artikel zu sein.")
return True
else: logging.warning(f" API Check für '{title}': Leere 'pages'-Liste."); return False
else: logging.warning(f" API Check für '{title}': Unerwartetes API-Antwortformat."); return False
except requests.exceptions.RequestException as e: logging.error(f" API Check für '{title}': Netzwerkfehler - {e}"); raise e
except Exception as e: logging.error(f" API Check für '{title}': Allgemeiner Fehler - {e}"); raise e
# serp_website_lookup Funktion
@retry_on_failure
def serp_website_lookup(company_name):
"""Ermittelt Website via SERP API (Google Suche)."""
serp_key = Config.API_KEYS.get('serpapi')
if not serp_key: logging.error("SerpAPI Key nicht verfügbar für Website Lookup."); return "k.A."
if not company_name: logging.warning("serp_website_lookup: Kein Firmenname."); return "k.A."
blacklist = ["bloomberg.com", "northdata.de", "finanzen.net", "handelsblatt.com", "wikipedia.org", "linkedin.com", "xing.com", "kununu.com", "firmenwissen.de", "gelbeseiten.de", "cylex.de", "companyme.com"]
query = f'{company_name} offizielle Website'
params = { "engine": "google", "q": query, "api_key": serp_key, "hl": "de", "gl": "de", "num": 10 } # Top 10 Ergebnisse prüfen
api_url = "https://serpapi.com/search"
try:
response = requests.get(api_url, params=params, timeout=15)
response.raise_for_status()
data = response.json()
if "knowledge_graph" in data and "website" in data["knowledge_graph"]:
kg_url = data["knowledge_graph"]["website"]
if kg_url and not any(bad_domain in kg_url.lower() for bad_domain in blacklist):
normalized_url = simple_normalize_url(kg_url)
if normalized_url != "k.A.":
logging.info(f"SERP Lookup: Website '{normalized_url}' aus Knowledge Graph für '{company_name}' gefunden.")
return normalized_url
if "organic_results" in data:
for result in data["organic_results"]:
url = result.get("link", "")
if url and not any(bad_domain in url.lower() for bad_domain in blacklist) and url.lower().startswith("http"):
normalized_url = simple_normalize_url(url)
if normalized_url != "k.A.":
# Zusätzliche Plausibilitätsprüfung: Enthält die Domain Teile des Firmennamens?
domain_part = normalized_url.replace('www.', '').split('.')[0]
# Sehr einfache Prüfung: Erstes Wort der Domain vs Erstes Wort des Firmennamens
norm_company_first_word = normalize_company_name(company_name).split()[0] if normalize_company_name(company_name) else ""
if norm_company_first_word and norm_company_first_word in domain_part:
logging.info(f"SERP Lookup: Website '{normalized_url}' aus Organic Results für '{company_name}' gefunden (Domain-Match).")
return normalized_url
# Zweite Chance: Wenn kein Namensmatch, aber es der erste organische Treffer ist ODER sehr hohe Titelähnlichkeit
elif result.get("position", 999) == 1 or fuzzy_similarity(result.get("title", ""), company_name) > 0.8: # Hohe Titelähnlichkeit als Signal
logging.info(f"SERP Lookup: Website '{normalized_url}' aus Organic Results für '{company_name}' gefunden (Top Result / Titelähnlichkeit).")
return normalized_url
else:
logging.debug(f"SERP Lookup: URL '{normalized_url}' übersprungen (Domain '{domain_part}' passt nicht gut zu '{company_name}', kein Top Result).")
logging.info(f"SERP Lookup: Keine passende Website für '{company_name}' gefunden.")
return "k.A."
except requests.exceptions.RequestException as e: logging.error(f"Fehler bei SERP API Website Lookup für '{company_name}': {e}"); raise e
except Exception as e: logging.error(f"Allgemeiner Fehler bei SERP API Website Lookup für '{company_name}': {e}"); return "k.A."
# serp_wikipedia_lookup Funktion
@retry_on_failure
def serp_wikipedia_lookup(company_name, website=None, min_score=0.4):
"""
Sucht über SerpAPI (Google) nach dem wahrscheinlichsten Wikipedia-Artikel.
Verwendet flexible Query, sammelt Top-10-Kandidaten, bewertet nach Titelähnlichkeit
und Keywords, bevorzugt deutsche/englische Artikel.
Args:
company_name (str): Der Name des Unternehmens.
website (str, optional): Die Website des Unternehmens. Defaults to None.
min_score (float, optional): Mindest-Score (Kombination aus Ähnlichkeit
und Boni) für einen gültigen Treffer. Defaults to 0.4.
Returns:
str: Die URL des relevantesten Wikipedia-Artikels oder None.
"""
serp_key = Config.API_KEYS.get('serpapi')
if not serp_key: logging.error("SerpAPI Key nicht verfügbar für Wikipedia Lookup."); return None
if not company_name: logging.warning("serp_wikipedia_lookup: Kein Firmenname."); return None
query = f'{company_name} Wikipedia'
logging.info(f"Starte SerpAPI Wikipedia-Suche für '{company_name}' mit Query: '{query}'")
params = { "engine": "google", "q": query, "api_key": serp_key, "hl": getattr(Config, 'LANG', 'de'), "gl": getattr(Config, 'LANG', 'de'), "num": 10 }
api_url = "https://serpapi.com/search"
try:
response = requests.get(api_url, params=params, timeout=15)
response.raise_for_status()
data = response.json()
candidates = []
if "organic_results" in data:
logging.debug(f" -> Prüfe {len(data['organic_results'])} organische Ergebnisse...")
for result in data["organic_results"]:
link = result.get("link")
# Filtere gültige Wiki-Artikel-Links (de oder en oder konfigurierte Sprache)
if link and "wikipedia.org/wiki/" in link.lower() \
and (link.startswith(f"https://{getattr(Config, 'LANG', 'de')}.wikipedia.org") or link.startswith("https://en.wikipedia.org")) \
and not any(x in link for x in ['Datei:', 'Spezial:', 'Portal:', 'Hilfe:', 'Diskussion:']):
try:
title = unquote(link.split('/wiki/', 1)[1].split('#')[0]).replace('_', ' ')
candidates.append({'url': link, 'title': title})
logging.debug(f" -> Kandidat gefunden: '{title}' ({link})")
except Exception as e_title_extract: logging.warning(f" -> Fehler beim Extrahieren des Titels aus Link {link}: {e_title_extract}"); continue
if not candidates: logging.warning(f" -> SerpAPI: Keine de/en Wikipedia-Kandidaten-URLs gefunden für '{company_name}'."); return None
best_match_url = None; highest_score = -1.0
normalized_search_name = normalize_company_name(company_name)
logging.debug(f" -> Bewerte {len(candidates)} Kandidaten...")
for cand in candidates:
url = cand['url']; title = cand['title']
try: normalized_title = normalize_company_name(title); title_lower = title.lower();
except Exception as e_norm: logging.warning(f"Fehler beim Normalisieren des Titels '{title}': {e_norm}"); continue
similarity = SequenceMatcher(None, normalized_title, normalized_search_name).ratio()
score = similarity
logging.debug(f" -> Kandidat '{title}': Basis-Ähnlichkeit={similarity:.2f}")
bonus = 0.0
if "(unternehmen)" in title_lower: bonus += 0.2; logging.debug(" -> Bonus +0.2 für '(unternehmen)'")
elif any(form in title_lower for form in [' gmbh', ' ag', ' kg', ' ltd', ' inc', ' corp', ' s.a.', ' se', ' group']): bonus += 0.1; logging.debug(" -> Bonus +0.1 für Rechtsform/Gruppen-Keyword")
if url.startswith(f"https://{getattr(Config, 'LANG', 'de')}.wikipedia.org"): bonus += 0.05; logging.debug(f" -> Bonus +0.05 für {getattr(Config, 'LANG', 'de')}.wikipedia.org")
total_score = score + bonus
logging.debug(f" -> Gesamtscore für '{title}': {total_score:.3f} (Ähnlichkeit={similarity:.2f}, Bonus={bonus:.2f})")
if total_score > highest_score and total_score >= min_score:
highest_score = total_score
best_match_url = url
logging.debug(f" ====> Neuer bester Kandidat: {best_match_url} (Score: {highest_score:.3f}) ====")
if best_match_url: logging.info(f" -> SerpAPI: Bester relevanter Wikipedia-Link ausgewählt: {best_match_url} (Score: {highest_score:.3f})"); return best_match_url
else: logging.warning(f" -> SerpAPI: Keiner der {len(candidates)} Kandidaten erreichte den Mindestscore ({min_score}) für '{company_name}'."); return None
except requests.exceptions.RequestException as e: logging.error(f"Fehler bei der SerpAPI Wikipedia Suche für '{company_name}': {e}"); raise e
except Exception as e: logging.error(f"Allgemeiner Fehler bei der SerpAPI Wikipedia Suche für '{company_name}': {e}"); return None
# search_linkedin_contacts Funktion
@retry_on_failure
def search_linkedin_contacts(company_name, website, position_query, crm_kurzform, num_results=10):
"""Sucht LinkedIn Kontakte via SERP API."""
serp_key = Config.API_KEYS.get('serpapi')
if not serp_key: logging.error("SerpAPI Key nicht verfügbar für LinkedIn Suche."); return []
if not all([company_name, position_query, crm_kurzform]): return []
# Query anpassen für bessere Ergebnisse
query = f'site:linkedin.com/in "{position_query}" "{crm_kurzform}"' # Suche nach Kurzform im Titel
params = { "engine": "google", "q": query, "api_key": serp_key, "hl": getattr(Config, 'LANG', 'de'), "gl": getattr(Config, 'LANG', 'de'), "num": num_results }
api_url = "https://serpapi.com/search"
try:
response = requests.get(api_url, params=params, timeout=15)
response.raise_for_status()
data = response.json()
contacts = []
if "organic_results" in data:
for result in data["organic_results"]:
title = result.get("title", "")
linkedin_url = result.get("link", "")
if not linkedin_url or "linkedin.com/in/" not in linkedin_url: continue
if crm_kurzform.lower() not in title.lower():
logging.debug(f"LinkedIn Treffer übersprungen: Kurzform '{crm_kurzform}' nicht in Titel '{title}'")
continue
name_part = ""; pos_part = position_query
separators = ["", "-", "|", " at ", " bei "]
title_cleaned = title.replace("...", "").strip()
found_sep = False
for sep in separators:
if sep in title_cleaned:
parts = title_cleaned.split(sep, 1)
name_part = parts[0].strip().replace(" | LinkedIn", "").replace(" - LinkedIn", "").replace(" - Profil", "").strip()
potential_pos = parts[1].strip()
if crm_kurzform.lower() in potential_pos.lower(): potential_pos = potential_pos.replace(crm_kurzform, "", 1).strip()
potential_pos = potential_pos.split(" | LinkedIn")[0].split(" - LinkedIn")[0].strip()
pos_part = potential_pos if potential_pos else position_query
found_sep = True; break
if not found_sep:
name_part = title_cleaned.split(" | LinkedIn")[0].split(" - LinkedIn")[0].strip()
if position_query.lower() in name_part.lower(): name_part = name_part.replace(position_query, "", 1).strip()
firstname = ""; lastname = ""
name_parts = name_part.split()
if len(name_parts) > 1: firstname = name_parts[0]; lastname = " ".join(name_parts[1:])
elif len(name_parts) == 1: firstname = name_parts[0]
if not firstname: logging.debug(f"Kontakt übersprungen: Name konnte nicht extrahiert werden aus Titel '{title}'"); continue
contact_data = { "Firmenname": company_name, "CRM Kurzform": crm_kurzform, "Website": website, "Vorname": firstname, "Nachname": lastname, "Position": pos_part, "LinkedInURL": linkedin_url }
contacts.append(contact_data)
logging.debug(f"Gefundener LinkedIn Kontakt: {firstname} {lastname} - {pos_part}")
logging.info(f"LinkedIn Suche für '{position_query}' bei '{crm_kurzform}' ergab {len(contacts)} Kontakte.")
return contacts
except requests.exceptions.RequestException as e: logging.error(f"Fehler bei der SERP API LinkedIn Suche: {e}"); raise e
except Exception as e: logging.error(f"Allgemeiner Fehler bei der SERP API LinkedIn Suche: {e}"); return []
# get_gender Funktion
def get_gender(firstname):
"""Ermittelt Geschlecht via gender-guesser und Fallback Genderize API (mit Retry)."""
if not firstname or not isinstance(firstname, str): return "unknown"
firstname_clean = firstname.strip().split(" ")[0]
if not firstname_clean: return "unknown"
try:
d = gender.Detector(case_sensitive=False)
result_gg = d.get_gender(firstname_clean)
if result_gg in ["andy", "unknown", "mostly_male", "mostly_female"]: result_gg = d.get_gender(firstname_clean, country='germany')
logging.debug(f"GenderGuesser für '{firstname_clean}': {result_gg}")
except Exception as e_gg: logging.warning(f"Fehler bei gender-guesser für '{firstname_clean}': {e_gg}"); result_gg = "unknown"
@retry_on_failure
def call_genderize(name):
genderize_key = Config.API_KEYS.get('genderize')
if not genderize_key: logging.debug("Genderize API-Schlüssel nicht verfügbar."); return None
params = {"name": name, "apikey": genderize_key, "country_id": "DE"}
api_url = "https://api.genderize.io"
logging.debug(f"Genderize API-Anfrage für '{name}'...")
response = requests.get(api_url, params=params, timeout=5)
response.raise_for_status()
data = response.json()
logging.debug(f" -> Genderize Antwort: {data}")
return data
if result_gg in ["andy", "unknown", "mostly_male", "mostly_female"]:
genderize_data = call_genderize(firstname_clean)
if genderize_data:
api_gender = genderize_data.get("gender"); probability = genderize_data.get("probability", 0)
if api_gender and probability and probability > 0.7: logging.debug(f" -> Übernehme Genderize Ergebnis '{api_gender}' (Prob: {probability})"); return api_gender
else: logging.debug(f" -> Genderize unsicher/kein Ergebnis. Nutze Fallback: '{result_gg}'"); return result_gg if result_gg.startswith("mostly_") else "unknown"
else: logging.debug(f" -> Genderize API Call fehlgeschlagen. Nutze Fallback: '{result_gg}'"); return result_gg if result_gg.startswith("mostly_") else "unknown"
else: return result_gg
# get_email_address Funktion
def get_email_address(firstname, lastname, website):
"""Generiert E-Mail: vorname.nachname@domain.tld."""
if not all([firstname, lastname, website]) or not all(isinstance(x, str) for x in [firstname, lastname, website]): return ""
domain = simple_normalize_url(website)
if domain == "k.A." or not '.' in domain: return ""
normalized_first = normalize_string(firstname).lower()
normalized_last = normalize_string(lastname).lower()
normalized_first = re.sub(r'[^\w]+', '-', normalized_first)
normalized_last = re.sub(r'[^\w]+', '-', normalized_last)
normalized_first = re.sub(r'-+', '-', normalized_first).strip('-')
normalized_last = re.sub(r'-+', '-', normalized_last).strip('-')
if normalized_first and normalized_last and domain: return f"{normalized_first}.{normalized_last}@{domain}"
else: return ""
# load_target_schema Funktion (für Branch Mapping)
def load_target_schema(csv_filepath=BRANCH_MAPPING_FILE):
"""Lädt Liste erlaubter Ziele (Kurzformen) aus Spalte A der CSV."""
global BRANCH_MAPPING, TARGET_SCHEMA_STRING, ALLOWED_TARGET_BRANCHES
BRANCH_MAPPING = {}
allowed_branches_set = set()
logging.info(f"Lade Ziel-Schema aus '{csv_filepath}'...")
try:
with open(csv_filepath, encoding="utf-8-sig") as f:
reader = csv.reader(f)
for row in reader:
if len(row) >= 1:
target = row[0].strip()
if target: allowed_branches_set.add(target)
except FileNotFoundError: logging.critical(f"Fehler: Schema-Datei '{csv_filepath}' nicht gefunden."); ALLOWED_TARGET_BRANCHES = []
except Exception as e: logging.critical(f"Fehler beim Laden des Ziel-Schemas aus '{csv_filepath}': {e}"); ALLOWED_TARGET_BRANCHES = []
ALLOWED_TARGET_BRANCHES = sorted(list(allowed_branches_set), key=str.lower)
logging.info(f"Ziel-Schema geladen. {len(ALLOWED_TARGET_BRANCHES)} eindeutige Zielbranchen gefunden.")
if ALLOWED_TARGET_BRANCHES:
schema_lines = ["Ziel-Branchenschema: Folgende Branchenbereiche sind gültig (Kurzformen):"]
schema_lines.extend(f"- {branch}" for branch in ALLOWED_TARGET_BRANCHES)
schema_lines.append("WICHTIG: Antworte NUR mit dem exakten Kurznamen einer Branche aus der obigen Liste. Verwende KEINE Präfixe.")
schema_lines.append("Antworte ausschließlich im folgenden Format (keine Einleitung, kein Schlusssatz):")
schema_lines.append("Branche: <Exakter Kurzname der Branche>")
schema_lines.append("Begründung: <Sehr kurze Begründung>")
TARGET_SCHEMA_STRING = "\n".join(schema_lines)
else: TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar."
# map_external_branch Funktion (kann global bleiben oder in DataProcessor, wenn sie Sheet-Daten nutzt)
# Wenn sie nur String-Mapping macht, global lassen. Wenn sie Logik mit Sheet-Spalten verbindet, in DP.
# Basierend auf Beschreibung macht sie nur String-Mapping -> Global lassen.
def map_external_branch(external_branch):
"""
Versucht, eine externe Branchenbezeichnung mithilfe des Mappings in das Ziel-Schema zu überführen.
Nutzt Normalisierung und Teilstring-Matching als Fallback.
(Diese Funktion scheint im aktuellen Code nicht verwendet zu werden, da evaluate_branche_chatgpt das Mapping direkt gegen das Zielschema prüft)
"""
logging.warning("map_external_branch wurde aufgerufen, scheint aber unbenutzt zu sein.")
return external_branch # Unverändert zurückgeben, da Logik nicht implementiert/genutzt
# alignment_demo Funktion (Schreibt direkt ins Sheet, braucht sheet Objekt)
def alignment_demo(sheet):
"""Schreibt die Header-Struktur (Zeilen 1-5, jetzt bis Spalte AY) ins angegebene Sheet."""
new_headers = [
[ # Spaltenname (Zeile 1)
"ReEval Flag", "CRM Name", "CRM Kurzform", "CRM Website", "CRM Ort", "CRM Beschreibung", "CRM Branche", "CRM Beschreibung Branche extern", "CRM Anzahl Techniker", "CRM Umsatz", "CRM Anzahl Mitarbeiter", "CRM Vorschlag Wiki URL", "Wiki URL", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Chat Wiki Konsistenzprüfung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", "Begründung bei Abweichung", "Chat Vorschlag Branche", "Chat Konsistenz Branche", "Chat Begründung Abweichung Branche", "Chat Prüfung FSM Relevanz", "Chat Begründung für FSM Relevanz", "Chat Schätzung Anzahl Mitarbeiter", "Chat Konsistenzprüfung Mitarbeiterzahl", "Chat Begründung Abweichung Mitarbeiterzahl", "Chat Einschätzung Anzahl Servicetechniker", "Chat Begründung Abweichung Anzahl Servicetechniker", "Chat Schätzung Umsatz", "Chat Begründung Abweichung Umsatz", "Linked Serviceleiter gefunden", "Linked It-Leiter gefunden", "Linked Management gefunden", "Linked Disponent gefunden", "Contact Search Timestamp", "Wikipedia Timestamp", "Timestamp letzte Prüfung", "Version", "Tokens", "Website Rohtext", "Website Zusammenfassung",
"Website Scrape Timestamp", "Geschätzter Techniker Bucket", "Finaler Umsatz (Wiki>CRM)", "Finaler Mitarbeiter (Wiki>CRM)", "Wiki Verif. Timestamp", "SerpAPI Wiki Search Timestamp"
],
[ # Quelle der Daten (Zeile 2)
"CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "System", "System", "System", "System", "System", "Web Scraper", "Chat GPT API",
"System", "ML Modell / Skript", "Skript (Wiki/CRM)", "Skript (Wiki/CRM)", "System", "System"
],
[ # Feldkategorie (Zeile 3)
"Prozess", "Firmenname", "Firmenname", "Website", "Ort", "Beschreibung (Text)", "Branche", "Branche", "Anzahl Servicetechniker", "Umsatz", "Anzahl Mitarbeiter", "Wikipedia Artikel URL", "Wikipedia Artikel", "Beschreibung (Text)", "Branche", "Umsatz", "Anzahl Mitarbeiter", "Kategorien (Text)", "Verifizierung", "Begründung bei Abweichung", "Wikipedia Artikel", "Wikipedia Artikel", "Branche", "Branche", "Branche", "FSM Relevanz", "FSM Relevanz", "Anzahl Mitarbeiter", "Anzahl Mitarbeiter", "Anzahl Mitarbeiter", "Anzahl Servicetechniker", "Anzahl Servicetechniker", "Umsatz", "Umsatz", "Kontakte zur Firma", "Kontakte zur Firma", "Kontakte zur Firma", "Kontakte zur Firma", "Timestamp", "Timestamp", "Timestamp", "Version des Skripts die verwendet wurde", "ChatGPT Tokens", "Website-Content", "Website Zusammenfassung",
"Timestamp", "Anzahl Servicetechniker Bucket", "Umsatz", "Anzahl Mitarbeiter", "Timestamp", "Timestamp"
],
[ # Kurze Beschreibung (Zeile 4)
"Systemspalte...", "Enthält den Firmennamen...", "Manuell gepflegte Kurzform...", "Website des Unternehmens.", "Ort des Unternehmens.", "Kurze Beschreibung...", "Aktuelle Branchenzuweisung...", "Externe Branchenbeschreibung...", "Recherchierte Anzahl...", "Umsatz in Mio. € (CRM).", "Anzahl Mitarbeiter (CRM).", "Vorgeschlagene Wikipedia URL...", "Wikipedia URL...", "Erster Absatz...", "Wikipedia-Branche...", "Wikipedia-Umsatz...", "Wikipedia-Mitarbeiterzahl...", "Liste der Wikipedia-Kategorien.", "\"OK\" oder \"X\" Ergebnis...", "Begründung bei Inkonsistenz...", "Chat-Vorschlag Wiki Artikel...", "Nicht genutzt...", "Branchenvorschlag via ChatGPT...", "Vergleich: Übereinstimmung CRM vs. ...", "Begründung bei abweichender...", "FSM-Relevanz: Bewertung...", "Begründung zur FSM-Bewertung.", "Schätzung Anzahl Mitarbeiter...", "Vergleich CRM vs. Wiki vs. ...", "Begründung bei Mitarbeiterabweichung...", "Schätzung Servicetechniker...", "Begründung bei Abweichung...", "Schätzung Umsatz via ChatGPT.", "Begründung bei Umsatzabweichung.", "Anzahl Kontakte (Serviceleiter)...", "Anzahl Kontakte (IT-Leiter)...", "Anzahl Kontakte (Management)...", "Anzahl Kontakte (Disponent)...", "Timestamp der Kontaktsuche.", "Timestamp der Wikipedia-Suche/Extraktion.", "Timestamp der ChatGPT-Bewertung / Letzte Prüfung der Zeile.", "Ausgabe der Skriptversion...", "Token-Zählung...", "Roh extrahierter Text...", "Zusammenfassung des Webseiteninhalts...",
"Timestamp des letzten Website-Scrapings (AR, AS).", "Geschätzter Bucket (1-7) für Servicetechniker...", "Konsolidierter Umsatz (Mio €) nach Priorität Wiki > CRM...", "Konsolidierte Mitarbeiterzahl nach Priorität Wiki > CRM...", "Timestamp der letzten Wiki-Verifikation (Spalten S-Y).", "Timestamp der letzten SerpAPI-Suche nach fehlender Wiki-URL (Modus find_wiki_serp)."
],
[ # Aufgabe / Funktion (Zeile 5)
"Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Wird durch Wikipedia Scraper bereitgestellt", "Wird zunächst nicht verwendet...", "Wird u.a. zur finalen Ermittlung...", "Wird u.a. mit CRM-Umsatz...", "Wird u.a. mit CRM-Anzahl...", "Wenn Website-Daten fehlen...", "\"Es soll durch ChatGPT geprüft werden...", "\"Liegt eine Inkonsistenz...", "\"Sollte durch die Wikipedia-Suche...", "XXX derzeit nicht verwendet...", "\"ChatGPT soll anhand der vorliegenden...", "Die in Spalte CRM festgelegte...", "Weicht die von ChatGPT ermittelte...", "ChatGPT soll anhand der vorliegenden Daten prüfen...", "Die in 'Chat Begründung für FSM Relevanz'...", "Nur wenn kein Wikipedia-Eintrag...", "Entspricht die durch ChatGPT ermittelte...", "Weicht die von ChatGPT geschätzte...", "ChatGPT soll auf Basis öffentlich...", "Weicht die von ChatGPT geschätzte...", "Nur wenn kein Wikipedia-Eintrag...", "ChatGPT soll signifikante Umsatzabweichungen...", "Über SerpAPI wird zusammen...", "Über SerpAPI wird zusammen...", "Über SerpAPI wird zusammen...", "Über SerpAPI wird zusammen...", "Wenn die Kontaktsuche gestartet wird...", "Wenn die Wikipedia-Suche gestartet wird...", "Wenn die ChatGPT-Bewertung gestartet wird...", "Wird durch das System befüllt", "Wird durch tiktoken berechnet", "Wird durch Web Scraper...", "Wird durch ChatGPT API...",
"Timestamp wird gesetzt, wenn Website Rohtext/Zusammenfassung geschrieben werden.", "Ergebnis der Schätzung durch das trainierte ML-Modell.", "Vom Skript berechneter Wert, priorisiert Wiki > CRM...", "Vom Skript berechneter Wert, priorisiert Wiki > CRM...", "Timestamp wird gesetzt, wenn Wiki-Verifikation (S-Y) durchgeführt wurde.", "Timestamp wird gesetzt, nachdem versucht wurde, eine fehlende Wiki-URL via SerpAPI zu finden."
]
]
num_cols = len(new_headers[0])
def colnum_string(n):
string = ""
while n > 0: n, remainder = divmod(n - 1, 26); string = chr(65 + remainder) + string
return string
end_col_letter = colnum_string(num_cols)
header_range = f"A1:{end_col_letter}{len(new_headers)}"
try:
sheet.update(values=new_headers, range_name=header_range)
logging.info(f"Alignment-Demo abgeschlossen: Header in Bereich {header_range} geschrieben.")
except Exception as e:
logging.error(f"FEHLER beim Schreiben der Alignment-Demo Header: {e}")
# --- Neue Kriterien Funktionen (Global) ---
# Diese Funktionen prüfen eine Zeile und geben True/False zurück
# Annahme: COLUMN_MAP ist global verfügbar und korrekt
def criteria_m_filled_an_empty(row_data):
"""Kriterium: Wiki URL (M) gefüllt (nicht leer/k.A.) UND Wiki Timestamp (AN) leer."""
m_idx = COLUMN_MAP.get("Wiki URL")
an_idx = COLUMN_MAP.get("Wikipedia Timestamp")
if m_idx is None or an_idx is None:
logging.error("Kriterium 'm_filled_an_empty': Benötigte Spalten nicht in COLUMN_MAP gefunden.")
return False
# Sicherstellen, dass die Zeile lang genug ist, um auf die Spalten zuzugreifen
max_idx = max(m_idx, an_idx) if m_idx is not None and an_idx is not None else None
if max_idx is not None and len(row_data) <= max_idx:
# logging.debug(f"Kriterium 'm_filled_an_empty': Zeile zu kurz für Spalten.") # Zu laut
return False
elif max_idx is None: # Kann Indizes nicht finden
return False
m_value = row_data[m_idx] if m_idx is not None and len(row_data) > m_idx else ""
an_value = row_data[an_idx] if an_idx is not None and len(row_data) > an_idx else ""
m_is_filled = bool(str(m_value).strip()) and str(m_value).strip().lower() not in ["k.a.", "kein artikel gefunden"]
an_is_empty = not bool(str(an_value).strip())
return m_is_filled and an_is_empty
# Beispiel für weitere Kriterien-Funktionen (basierend auf Logik aus process_find_wiki_with_serp)
def criteria_size_meets_threshold(row_data, min_employees=500, min_umsatz=200):
"""Kriterium: Unternehmensgröße erfüllt Schwelle (Umsatz CRM > min_umsatz MIO € ODER Mitarbeiter CRM > min_employees)."""
umsatz_idx = COLUMN_MAP.get("CRM Umsatz")
ma_idx = COLUMN_MAP.get("CRM Anzahl Mitarbeiter")
if umsatz_idx is None or ma_idx is None:
logging.error("Kriterium 'size_meets_threshold': Benötigte Spalten (Umsatz/MA) nicht in COLUMN_MAP.")
return False
# Sicherstellen, dass die Zeile lang genug ist
max_idx = max(umsatz_idx, ma_idx)
if len(row_data) <= max_idx:
# logging.debug(f"Kriterium 'size_meets_threshold': Zeile zu kurz.") # Zu laut
return False
umsatz_val_str = row_data[umsatz_idx]
ma_val_str = row_data[ma_idx]
# Nutze die globale get_numeric_filter_value Funktion
umsatz_val_mio = get_numeric_filter_value(umsatz_val_str, is_umsatz=True)
ma_val_num = get_numeric_filter_value(ma_val_str, is_umsatz=False)
meets_criteria = umsatz_val_mio > min_umsatz or ma_val_num > min_employees
# Optional: Log, wenn Kriterium nicht erfüllt ist (kann laut sein)
# if not meets_criteria:
# logging.debug(f"Kriterium 'size_meets_threshold' nicht erfüllt. Umsatz (Mio): {umsatz_val_mio:.2f}, MA: {ma_val_num}. Schwellen: Umsatz > {min_umsatz} Mio, MA > {min_employees}.")
return meets_criteria
def criteria_ao_empty(row_data):
"""Kriterium: Timestamp letzte Prüfung (AO) leer."""
ao_idx = COLUMN_MAP.get("Timestamp letzte Prüfung")
if ao_idx is None:
logging.error("Kriterium 'ao_empty': Benötigte Spalte nicht in COLUMN_MAP.")
return False
if len(row_data) <= ao_idx: return False
ao_value = row_data[ao_idx]
return not bool(str(ao_value).strip())
def criteria_ar_empty(row_data):
"""Kriterium: Website Rohtext (AR) leer (oder k.A. Varianten)."""
ar_idx = COLUMN_MAP.get("Website Rohtext")
if ar_idx is None:
logging.error("Kriterium 'ar_empty': Benötigte Spalte nicht in COLUMN_MAP.")
return False
if len(row_data) <= ar_idx: return False
ar_value = row_data[ar_idx]
empty_values_for_ar = ["", "k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]
return str(ar_value).strip().lower() in empty_values_for_ar
def criteria_ax_empty(row_data):
"""Kriterium: Wiki Verif. Timestamp (AX) leer."""
ax_idx = COLUMN_MAP.get("Wiki Verif. Timestamp")
if ax_idx is None:
logging.error("Kriterium 'ax_empty': Benötigte Spalte nicht in COLUMN_MAP.")
return False
if len(row_data) <= ax_idx: return False
ax_value = row_data[ax_idx]
return not bool(str(ax_value).strip())
# --- Ende Neue Kriterien Funktionen ---
# --- Temporäre Funktion für wiki_reextract Modus (bis Kriterien-Modus in DP implementiert ist) ---
# Diese Funktion wird nur von der main Funktion in diesem Übergangsszenario aufgerufen.
# Annahme: sheet_handler und data_processor sind initialisierte Instanzen.
# Annahme: _process_single_row ist eine Methode der DataProcessor Klasse und akzeptiert die Prozess-Flags.
# Annahme: criteria_m_filled_an_empty ist global definiert.
def process_wiki_reextract_missing_an(sheet_handler, data_processor, limit=None):
"""
Findet Zeilen, bei denen Wiki URL (M) gefüllt ist und Wiki Timestamp (AN) fehlt.
Führt für diese Zeilen eine forcierte Wiki-Extraktion (nur Wiki-Schritt) durch.
Args:
sheet_handler (GoogleSheetHandler): Initialisierte Instanz.
data_processor (DataProcessor): Initialisierte Instanz.
limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None.
"""
logging.info(f"Starte Modus: Prozessiere Wiki Re-Extraction für Zeilen mit M gefüllt & AN leer. Limit: {limit if limit is not None else 'Unbegrenzt'}")
# Daten neu laden
if not sheet_handler.load_data():
logging.error("Fehler beim Laden der Daten.")
return
all_data = sheet_handler.get_all_data_with_headers()
header_rows = 5
if not all_data or len(all_data) <= header_rows:
logging.warning("Keine Daten zum Verarbeiten gefunden.")
return
rows_to_process = []
logging.info("Suche nach Zeilen, die dem Kriterium 'M gefüllt & AN leer' entsprechen...")
# Iteriere über alle Datenzeilen (ab Zeile 6)
for idx_in_list in range(header_rows, len(all_data)):
row_data = all_data[idx_in_list]
row_num_in_sheet = idx_in_list + 1
# Prüfen, ob die Zeile dem Kriterium entspricht
try:
if criteria_m_filled_an_empty(row_data): # Nutze die Kriterien-Funktion
rows_to_process.append({'row_num': row_num_in_sheet, 'data': row_data})
except Exception as e_crit:
# Fehler im Kriterium selbst abfangen
logging.error(f"FEHLER beim Prüfen des Kriteriums für Zeile {row_num_in_sheet}: {e_crit}")
# Diese Zeile wird nicht für die Verarbeitung ausgewählt.
found_count = len(rows_to_process)
logging.info(f"{found_count} Zeilen entsprechen dem Kriterium 'M gefüllt & AN leer'.")
if found_count == 0:
logging.info("Keine Zeilen gefunden, die dem Kriterium entsprechen.")
return
processed_count = 0
for task in rows_to_process:
if limit is not None and processed_count >= limit:
logging.info(f"Limit ({limit}) für Verarbeitung erreicht. Breche weitere Verarbeitung ab.")
break
row_num = task['row_num']
row_data = task['data']
try:
# Rufe _process_single_row auf der data_processor Instanz auf
# Setze process_wiki=True, aber alle anderen auf False
# Setze force_reeval=True, um die AN Timestamp-Prüfung in _process_single_row zu überspringen
# Die _process_single_row Logik für force_reeval wird dann die URL in M nutzen/validieren.
data_processor._process_single_row(
row_num_in_sheet = row_num,
row_data = row_data,
process_wiki = True, # NUR Wiki-Verarbeitung
process_chatgpt = False, # Keine ChatGPT-Schritte
process_website = False, # Keine Website-Schritte
force_reeval = True # Timestamps IGNORIEREN
)
processed_count += 1
except Exception as e_proc:
logging.exception(f"FEHLER bei Verarbeitung einer Kriterium-Zeile ({row_num}): {e_proc}")
# Fährt fort mit der nächsten Zeile
logging.info(f"Prozess 'M gefüllt & AN leer' abgeschlossen. {processed_count} von {found_count} gefundenen Zeilen bearbeitet (Limit war: {limit}).")
# --- Ende Temporäre Funktion ---
# ==================== GOOGLE SHEET HANDLER ====================
# Annahmen: Globale Variablen/Konstanten: retry_on_failure, Config, CREDENTIALS_FILE, Config.SHEET_URL, COLUMN_MAP
# Annahme: logging ist konfiguriert
class GoogleSheetHandler:
"""
Verwaltet die Interaktion mit dem Google Sheet (Authentifizierung, Lesen, Schreiben).
"""
def __init__(self):
"""Initialisiert den Handler, verbindet und lädt initiale Daten."""
self.sheet = None
self.sheet_values = []
self.headers = [] # Speichert die erste Zeile als Header-Namen
try:
self._connect() # Versucht Verbindung bei Initialisierung
if self.sheet:
# Erste Datenladung bei Initialisierung (kann optional sein, wenn load_data explizit aufgerufen wird)
# Es ist oft besser, load_data explizit in den Modi aufzurufen.
# Belassen wir es vorerst, wenn es das aktuelle Verhalten ist.
self.load_data()
except Exception as e:
# Kritischer Fehler, Skript sollte hier stoppen, wenn keine Verbindung
logging.critical(f"FATAL: Fehler bei Initialisierung von GoogleSheetHandler: {e}")
raise ConnectionError(f"Google Sheet Handler Init failed: {e}")
@retry_on_failure # Decorator wird hier angewendet
def _connect(self):
"""Stellt Verbindung zum Google Sheet her."""
self.sheet = None # Sicherstellen, dass sheet vor try None ist
logging.info("Verbinde mit Google Sheets...")
try:
# Annahme: CREDENTIALS_FILE existiert und ist korrekt
scope = ["https://www.googleapis.com/auth/spreadsheets"]
creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope)
gc = gspread.authorize(creds)
# Annahme: Config.SHEET_URL existiert und ist korrekt
sh = gc.open_by_url(Config.SHEET_URL)
self.sheet = sh.sheet1 # Greift auf das erste Blatt zu (Annahme)
logging.info("Verbindung zu Google Sheets erfolgreich.")
except gspread.exceptions.APIError as e:
logging.error(f"FEHLER bei Google API Verbindung: Status {e.response.status_code} - {e.response.text[:200]}")
raise e # Fehler weitergeben, damit retry greift
except FileNotFoundError:
logging.critical(f"FEHLER: Service Account Credentials File '{CREDENTIALS_FILE}' nicht gefunden.")
raise FileNotFoundError(f"Service Account Credentials File '{CREDENTIALS_FILE}' not found.")
except Exception as e:
logging.error(f"FEHLER bei der Google Sheets Verbindung: {type(e).__name__} - {e}")
raise e
@retry_on_failure # Decorator wird hier angewendet
def load_data(self):
"""Lädt alle Daten aus dem Sheet und aktualisiert self.sheet_values und self.headers."""
if not self.sheet:
logging.error("Fehler: Keine Sheet-Verbindung zum Laden der Daten.")
self.sheet_values = []
self.headers = []
return False # Signalisiert Fehler
logging.info("Lade Daten aus Google Sheet...")
try:
# Annahme: 'Tabelle1' ist der korrekte Blattname, oder dynamisch ermitteln?
# Ihre aktuelle get_all_values() nutzt default sheet1
self.sheet_values = self.sheet.get_all_values() # Daten neu holen
if not self.sheet_values:
logging.warning("Warnung: Google Sheet scheint leer zu sein oder keine Daten zurückgegeben.")
self.headers = []
return True # Kein Fehler beim Laden, aber keine Daten
# Annahme: Erste Zeile sind Header
if len(self.sheet_values) >= 1:
self.headers = self.sheet_values[0] # Speichere die erste Zeile als Header
else:
self.headers = [] # Sollte nicht passieren, wenn sheet_values nicht leer war
logging.info(f"Daten neu geladen: {len(self.sheet_values)} Zeilen insgesamt.")
return True # Signalisiert Erfolg
except gspread.exceptions.APIError as e:
logging.error(f"Google API Fehler beim Laden der Sheet Daten: Status {e.response.status_code} - {e.response.text[:200]}")
raise e # Damit retry greift
except Exception as e:
logging.error(f"Allgemeiner Fehler beim Laden der Google Sheet Daten: {e}")
raise e # Damit retry greift
def get_data(self):
"""
Gibt die aktuell im Handler gespeicherten Datenzeilen zurück (ohne die ersten X Header-Zeilen).
Annahme: Es gibt 5 Header-Zeilen.
"""
header_rows = 5
if not self.sheet_values or len(self.sheet_values) <= header_rows:
if self.sheet_values:
logging.debug(f"Warnung in get_data: Nur {len(self.sheet_values)} Zeilen vorhanden, weniger als {header_rows} Header-Zeilen erwartet.")
return []
return self.sheet_values[header_rows:]
def get_all_data_with_headers(self):
"""Gibt alle aktuell im Handler gespeicherten Daten inklusive Header zurück."""
if not self.sheet_values:
logging.debug("Warnung in get_all_data_with_headers: Keine Daten im Handler gespeichert.")
return []
return self.sheet_values
def _get_col_letter(self, col_idx_1_based):
""" Konvertiert 1-basierten Spaltenindex in Buchstaben (A, B, ..., Z, AA, ...). """
string = ""
n = col_idx_1_based
if n < 1: return None # Ungültiger Index
while n > 0:
n, remainder = divmod(n - 1, 26)
string = chr(65 + remainder) + string
return string
def get_start_row_index(self, check_column_key, min_sheet_row=7):
"""
Findet den 0-basierten Index der ersten Zeile IN DEN DATEN (ohne Header),
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.
"""
if not self.load_data(): return -1 # Fehlerindikator, load_data loggt intern
header_rows = 5
all_data_with_headers = self.get_all_data_with_headers()
if not all_data_with_headers or len(all_data_with_headers) <= header_rows:
logging.warning(f"get_start_row_index: Nicht genügend Daten im Sheet (<= {header_rows} Zeilen).")
return 0 # Start bei Index 0, wenn keine echten Daten da sind
check_column_index = COLUMN_MAP.get(check_column_key)
if check_column_index is None:
logging.critical(f"FEHLER: Schlüssel '{check_column_key}' nicht in COLUMN_MAP gefunden!")
return -1
actual_col_letter = self._get_col_letter(check_column_index + 1)
# Konvertiere min_sheet_row in 0-basierten Index für die gesamte Liste (inkl. Header)
search_start_index_in_all_data = max(header_rows, min_sheet_row - 1) # Suche beginnt frühestens nach Headern
logging.info(f"get_start_row_index: Suche ab Sheet-Zeile {search_start_index_in_all_data + 1} nach EXAKT LEEREM Wert (=='') in Spalte '{check_column_key}' ({actual_col_letter})...")
# Iteriere über die gesamte Liste ab dem berechneten Startindex
for i in range(search_start_index_in_all_data, len(all_data_with_headers)):
row = all_data_with_headers[i]
current_sheet_row = i + 1 # 1-basierte Zeilennummer
cell_value = ""; is_exactly_empty = True
if len(row) > check_column_index:
cell_value = row[check_column_index]
if cell_value != "": is_exactly_empty = False
# else: cell_value bleibt "", is_exactly_empty bleibt True (korrekt)
# Detailliertes Logging nur für die ersten paar Zeilen und alle 1000 Zeilen
log_debug = (i < search_start_index_in_all_data + 5 or (i - search_start_index_in_all_data) % 1000 == 0 or is_exactly_empty)
if log_debug:
logging.debug(f" -> Prüfe Sheet-Zeile {current_sheet_row} (Index {i}): Wert in {actual_col_letter}='{cell_value}' (Typ: {type(cell_value)}). Ist exakt leer ('')? {is_exactly_empty}")
if is_exactly_empty:
# Geben Sie den 0-basierten Index IN DER DATENLISTE (ohne Header) zurück
data_index = i - header_rows
logging.info(f"Erste Zeile ab {min_sheet_row} mit EXAKT LEEREM Wert in Spalte {actual_col_letter} gefunden: Sheet-Zeile {current_sheet_row} (Daten-Index {data_index})")
return data_index
# Wenn die Schleife endet, wurden keine leeren Zeilen ab dem Startindex gefunden.
# Geben Sie den Index NACH der letzten Datenzeile zurück.
last_data_index = len(all_data_with_headers) - header_rows
logging.info(f"Alle Zeilen ab Sheet-Zeile {search_start_index_in_all_data + 1} haben einen nicht-leeren Wert in Spalte {actual_col_letter}.")
logging.info(f"Nächster 0-basierter Daten-Index wäre {last_data_index}.")
return last_data_index
@retry_on_failure # Decorator wird hier angewendet
def batch_update_cells(self, update_data):
"""
Führt ein Batch-Update im Google Sheet durch.
Args:
update_data (list): Eine Liste von Dictionaries, jedes mit 'range' und 'values'.
Returns:
bool: True bei Erfolg, False bei Fehler nach Retries.
"""
if not self.sheet:
logging.error("FEHLER: Keine Sheet-Verbindung für Batch-Update.")
return False
if not update_data:
# logging.debug("Keine Daten für Batch-Update vorhanden.") # Weniger Lärm
return True
success = False
try:
# Begrenze die Größe des Batch-Updates, falls die Liste sehr lang wird
# Die API hat ein Limit pro Batch-Update Call. Es ist besser, die Liste hier zu chunking,
# auch wenn die aufrufende Funktion schon sammelt. update_batch_row_limit könnte hier genutzt werden.
# Aktuell senden wir die gesamte Liste, was bei sehr großen Listen schief gehen kann.
# Fürs Erste behalten wir das bei.
logging.debug(f" -> Versuche sheet.batch_update mit {len(update_data)} Operationen...")
self.sheet.batch_update(update_data, value_input_option='USER_ENTERED')
success = True
# logging.debug(f" -> sheet.batch_update erfolgreich abgeschlossen.") # Aufrufer loggt Erfolg
except gspread.exceptions.APIError as e:
logging.error(f" -> FEHLER (Google API Error) beim Batch-Update: Status {e.response.status_code}")
try: error_details = e.response.json(); logging.error(f" -> Details: {str(error_details)[:500]}...")
except: logging.error(f" -> Raw Response Text: {e.response.text[:500]}...")
raise e # Fehler weitergeben für Retry
except Exception as e:
logging.error(f" -> FEHLER (Allgemein) beim Batch-Update: {type(e).__name__} - {e}")
logging.exception("Traceback beim Batch-Update:")
raise e # Fehler weitergeben
return success
# --- Ende GoogleSheetHandler Klasse ---
# ==================== WIKIPEDIA SCRAPER ====================
# Annahmen: Globale Helfer Funktionen wie clean_text, normalize_company_name, extract_numeric_value, simple_normalize_url, fuzzy_similarity, is_valid_wikipedia_article_url sind global definiert.
# Annahme: Config, logging sind verfügbar.
class WikipediaScraper:
"""
Handles searching Wikipedia articles and extracting relevant company data.
"""
def __init__(self, user_agent=None):
"""
Initialisiert den Scraper mit einer Requests-Session.
"""
self.logger = logging.getLogger(__name__ + ".WikipediaScraper") # Spezifischer Logger
self.user_agent = user_agent or getattr(Config, 'USER_AGENT', 'Mozilla/5.0 (compatible; Datenanreicherungsskript/1.0)')
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.")
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']
}
try:
# Annahme: wikipedia Bibliothek ist importiert
wiki_lang = getattr(Config, 'LANG', 'de')
wikipedia.set_lang(wiki_lang)
wikipedia.set_rate_limiting(True)
self.logger.info(f"Wikipedia library language set to '{wiki_lang}'. Rate limiting enabled.")
except Exception as e: self.logger.warning(f"Fehler beim Setzen der Wikipedia-Sprache oder Rate Limiting: {e}")
# _get_full_domain (kann global bleiben oder private helper) - Belassen wir global, wird auch von anderen Helfern genutzt.
# _generate_search_terms (kann global bleiben oder private helper) - Belassen wir global.
@retry_on_failure
def _get_page_soup(self, url):
"""Holt HTML von einer URL und gibt ein BeautifulSoup-Objekt zurück."""
if not url or not isinstance(url, str) or not url.startswith("http"): self.logger.warning(f"_get_page_soup: Ungültige URL '{url}'."); return None
try:
self.logger.debug(f"_get_page_soup: Rufe URL ab: {url}")
response = self.session.get(url, timeout=20)
response.raise_for_status()
response.encoding = response.apparent_encoding # Bessere Erkennung
soup = BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser'))
self.logger.debug(f"_get_page_soup: Parsen von {url} erfolgreich.")
return soup
except requests.exceptions.Timeout: self.logger.error(f"_get_page_soup: Timeout beim Abrufen von {url}"); raise
except requests.exceptions.RequestException as e: self.logger.error(f"_get_page_soup: Netzwerkfehler beim Abrufen von HTML von {url}: {e}"); raise e
except Exception as e: self.logger.error(f"_get_page_soup: Fehler beim Parsen von HTML von {url}: {e}"); raise e
def _validate_article(self, page, company_name, website):
"""Validiert, ob ein Wikipedia-Artikel zum Unternehmen passt."""
if not page or not company_name: return False
self.logger.debug(f"Validiere Artikel '{page.title}' für Firma '{company_name}' (Website: {website})...")
full_domain = simple_normalize_url(website) # Globaler Helper
normalized_company = normalize_company_name(company_name) # Globaler Helper
normalized_title = normalize_company_name(page.title) # Globaler Helper
if not normalized_company or not normalized_title: self.logger.warning("Validierung nicht möglich, da Normalisierung fehlschlug."); return False
similarity = SequenceMatcher(None, normalized_title, normalized_company).ratio()
self.logger.debug(f" -> Gesamt-Ähnlichkeit: {similarity:.2f} ('{normalized_title}' vs '{normalized_company}')")
company_tokens = normalized_company.split(); title_tokens = normalized_title.split()
first_word_match = False; first_two_words_match = False
if len(company_tokens) > 0 and len(title_tokens) > 0:
if company_tokens[0] == title_tokens[0]:
first_word_match = True
if len(company_tokens) > 1 and len(title_tokens) > 1 and company_tokens[1] == title_tokens[1]: first_two_words_match = True
domain_found = False
if full_domain:
self.logger.debug(f" -> Suche nach Domain '{full_domain}' in Links von {page.url}...")
soup = self._get_page_soup(page.url) # Ruft eigene Methode auf (mit Retry)
if soup:
# Vereinfachte Link-Prüfung: Suche in Infobox oder externen Links
infobox = soup.select_one('table[class*="infobox"]')
if infobox: # Prüfe Infobox Links mit Keywords
website_links = infobox.find_all('a', href=True)
for link in website_links:
href = link.get('href', '')
if href.startswith('http') and full_domain in simple_normalize_url(href):
link_text = link.get_text(strip=True).lower()
th = link.find_previous(['th', 'td']) # Prüfe vorheriges TH oder TD
th_text = th.get_text(strip=True).lower() if th else ""
if any(kw in link_text for kw in ['website', 'webseite', 'offizielle']) or any(kw in th_text for kw in ['website', 'webseite', 'webauftritt']):
logging.debug(f" -> Domain '{full_domain}' in Infobox-Link gefunden (mit Keyword).")
domain_found = True; break
if not domain_found: # Wenn in Infobox, aber ohne Keyword
for link in website_links:
href = link.get('href', '')
if href.startswith('http') and full_domain in simple_normalize_url(href):
logging.debug(f" -> Domain '{full_domain}' in Infobox-Link gefunden (ohne Keyword).")
domain_found = True; break
if not domain_found: # Suche in allen externen Links, wenn nicht in Infobox gefunden
self.logger.debug(" -> Domain nicht in Infobox-Links gefunden, suche in externen Links...")
all_external_links = soup.find_all('a', href=True, class_=re.compile(r'.*\bexternal\b.*'))
if all_external_links: # Bevorzuge external class
for link in all_external_links:
href = link.get('href', '')
if href.startswith('http') and full_domain in simple_normalize_url(href):
if not any(site in href for site in ['wikipedia.org', 'wikimedia.org', 'wikidata.org', 'archive.org', 'webcitation.org']):
logging.debug(f" -> Domain '{full_domain}' in externem Link gefunden.")
domain_found = True; break
if not domain_found: # Suche in allen Links als Fallback
all_links = soup.find_all('a', href=True)
for link in all_links:
href = link.get('href', '')
if href.startswith('http') and full_domain in simple_normalize_url(href):
if not any(site in href for site in ['wikipedia.org', 'wikimedia.org', 'wikidata.org', 'archive.org', 'webcitation.org']):
logging.debug(f" -> Domain '{full_domain}' in irgendeinem Link gefunden (Fallback).")
domain_found = True; break
else: self.logger.warning(f" -> Konnte HTML für Link-Prüfung von {page.url} nicht laden.")
# Dynamische Schwellenwert-Entscheidung
standard_threshold = getattr(Config, 'SIMILARITY_THRESHOLD', 0.65)
is_valid = False; reason = "Keine Validierungsregel traf zu"
if similarity >= standard_threshold: is_valid = True; reason = f"Gesamt-Ähnlichkeit >= {standard_threshold:.2f}"
elif domain_found and first_two_words_match and similarity >= 0.30: is_valid = True; reason = f"Domain gefunden UND erste 2 Worte stimmen überein UND Ähnlichkeit >= 0.30"
elif domain_found and first_word_match and similarity >= 0.35: is_valid = True; reason = f"Domain gefunden UND erstes Wort stimmt überein UND Ähnlichkeit >= 0.35"
elif first_two_words_match and similarity >= 0.40: is_valid = True; reason = f"Erste zwei Worte stimmen überein UND Ähnlichkeit >= 0.40"
elif domain_found and similarity >= 0.45: is_valid = True; reason = f"Domain gefunden UND Ähnlichkeit >= 0.45"
elif first_word_match and similarity >= 0.50: is_valid = True; reason = f"Erstes Wort stimmt überein UND Ähnlichkeit >= 0.50"
log_level = logging.INFO if is_valid else logging.DEBUG
self.logger.log(log_level, f" => Artikel '{page.title}' {'VALIDIERT' if is_valid else 'NICHT validiert'} (Grund: {reason}. Details: Sim={similarity:.2f}, Domain? {domain_found}, 1stWord? {first_word_match}, 2ndWord? {first_two_words_match})")
return is_valid
# extract_categories Funktion
def extract_categories(self, soup):
"""Extrahiert Wikipedia-Kategorien aus dem Soup-Objekt."""
if not soup: return "k.A."
cats_filtered = []
try:
cat_div = soup.find('div', id="mw-normal-catlinks")
if cat_div:
ul = cat_div.find('ul')
if ul:
cats = [clean_text(li.get_text()) for li in ul.find_all('li')]
cats_filtered = [c for c in cats if c and "kategorien:" not in c.lower()]
self.logger.debug(f"Kategorien gefunden: {cats_filtered}")
else: self.logger.debug("Kein 'ul' in 'mw-normal-catlinks' gefunden.")
else: self.logger.debug("Kein 'div#mw-normal-catlinks' gefunden.")
except Exception as e: self.logger.error(f"Fehler beim Extrahieren der Kategorien: {e}")
return ", ".join(cats_filtered) if cats_filtered else "k.A."
# _extract_first_paragraph_from_soup Funktion
def _extract_first_paragraph_from_soup(self, soup):
"""Extrahiert den ersten aussagekräftigen Absatz aus dem Soup-Objekt."""
if not soup: return "k.A."
paragraph_text = "k.A."
try:
content_div = soup.find('div', class_='mw-parser-output')
search_area = content_div if content_div else soup
# Finde erste p-Tags, die keine Block-Elemente als direkte Kinder haben
paragraphs = [p for p in search_area.find_all('p', recursive=False) if not p.find(['img', 'table', 'figure', 'div'], recursive=False)]
if not paragraphs: # Fallback: alle p-Tags suchen
paragraphs = [p for p in search_area.find_all('p', recursive=True) if not p.find(['img', 'table', 'figure', 'div'], recursive=False)]
self.logger.debug(f"Suche ersten Absatz in {len(paragraphs)} gefundenen <p>-Tags...")
for idx, p in enumerate(paragraphs):
# Entferne störende Elemente wie Referenzen oder Koordinaten
for sup in p.find_all('sup', class_='reference'): sup.decompose()
for span in p.find_all('span', id='coordinates'): span.decompose()
text = clean_text(p.get_text(separator=' ', strip=True)) # Globaler Helper
# Prüfe, ob der Text nach Bereinigung lang und aussagekräftig ist
if text and len(text) > 40: # Min. 40 Zeichen
self.logger.debug(f" -> Ersten gültigen Absatz (Index {idx}) gefunden: {text[:100]}...")
paragraph_text = text[:1000] # Begrenze Länge
break # Stoppe nach dem ersten passenden Absatz
else:
self.logger.debug(f" -> Überspringe <p> Tag (Index {idx}), Text zu kurz oder leer nach clean_text: '{text[:50]}...'")
except Exception as e:
self.logger.error(f"Fehler beim Extrahieren des ersten Absatzes: {e}")
if paragraph_text == "k.A.": self.logger.warning("Kein passender erster Absatz gefunden.")
return paragraph_text
# _extract_infobox_value Funktion
def _extract_infobox_value(self, soup, target):
"""
Extrahiert gezielt Branche, Umsatz oder Mitarbeiter aus der Infobox.
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}")
infobox = soup.select_one('table[class*="infobox"]')
if not infobox: self.logger.debug(" -> KEINE Infobox gefunden."); return "k.A."
self.logger.debug(f" -> Infobox gefunden.")
value_found = "k.A."
try:
rows = infobox.find_all('tr')
self.logger.debug(f" -> Analysiere {len(rows)} Zeilen in der Infobox.")
for idx, row in enumerate(rows):
# self.logger.debug(f" --- Prüfe Roh-HTML Zeile {idx}: {str(row)[:150]}...") # Zu ausführlich
cells = row.find_all(['th', 'td'], recursive=False)
header_text = None; value_cell = None
if len(cells) >= 2 and cells[0].name == 'th' and cells[1].name == 'td':
header_text = cells[0].get_text(strip=True)
value_cell = cells[1]
# self.logger.debug(f" -> Zeile {idx}: Struktur TH + TD erkannt.") # Zu ausführlich
elif len(cells) >= 2 and cells[0].name == 'td' and cells[1].name == 'td':
# Prüfe, ob das erste TD Header-ähnlich formatiert ist (fett)
first_cell_is_header_like = False
style = cells[0].get('style', '').lower()
if 'font-weight' in style and ('bold' in style or '700' in style or '800' in style or '900' in style): first_cell_is_header_like = True
elif cells[0].find(['b', 'strong'], recursive=False): first_cell_is_header_like = True
if first_cell_is_header_like:
header_text = cells[0].get_text(strip=True)
value_cell = cells[1]
# self.logger.debug(f" -> Zeile {idx}: Struktur TD(Header-like) + TD erkannt.") # Zu ausführlich
# else: self.logger.debug(f" -> Zeile {idx}: Struktur TD + TD, aber erstes TD nicht als Header erkannt.") # Zu ausführlich
# else: self.logger.debug(f" -> Zeile {idx}: Übersprungen (Struktur passt nicht).") # Zu ausführlich
if header_text is not None and value_cell is not None:
# self.logger.debug(f" -> Verarbeite Zeile {idx} mit Header='{header_text}'") # Zu ausführlich
header_text_lower = header_text.lower()
matched_keyword = None
for kw in keywords:
if kw in header_text_lower: matched_keyword = kw; break
if matched_keyword:
self.logger.debug(f" --> Keyword '{matched_keyword}' gefunden in Header '{header_text}'!")
# --- Debugging Logik hinzugefügt ---
self.logger.debug(f" -> Roh-HTML der Value Cell vor Decompose: {str(value_cell)[:200]}...")
# --- Ende Debugging Logik ---
# Entferne bekannte störende Elemente (Referenzen, versteckter Text)
for sup in value_cell.find_all(['sup', 'span']):
if (sup.name == 'sup' and sup.has_attr('class') and 'reference' in sup['class']) or \
(sup.name == 'span' and sup.get('style') and 'display:none' in sup['style']):
self.logger.debug(f" -> Entferne störendes Element: {sup.get_text(strip=True)}")
sup.decompose()
# --- Debugging Logik hinzugefügt ---
self.logger.debug(f" -> Roh-HTML der Value Cell nach Decompose: {str(value_cell)[:200]}...")
# --- Ende Debugging Logik ---
raw_value_text = value_cell.get_text(separator=' ', strip=True)
self.logger.debug(f" -> Roher TD/Value-Text nach get_text: '{raw_value_text[:100]}'")
cleaned_raw_value = clean_text(raw_value_text) # Globaler Helper
self.logger.debug(f" -> Bereinigter Value-Text nach clean_text: '{cleaned_raw_value[:100]}'")
if target == 'branche':
# Branche kann komplexe Formate haben, oft vor Klammern oder Newlines
clean_val = re.sub(r'\s*\([^)]*\)', '', cleaned_raw_value).strip() # Entferne Text in Klammern
clean_val = clean_val.split('\n')[0].strip() # Nimm nur die erste Zeile bei Newlines
value_found = clean_val if clean_val else "k.A."
self.logger.info(f" --> Branche extrahiert: '{value_found}'")
elif target == 'umsatz':
# extract_numeric_value kümmert sich um Einheiten und Format
# Globaler Helper
numeric_val = extract_numeric_value(cleaned_raw_value, is_umsatz=True)
value_found = numeric_val
self.logger.info(f" --> Umsatz extrahiert (aus '{cleaned_raw_value[:50]}'): '{value_found}'")
elif target == 'mitarbeiter':
# extract_numeric_value kümmert sich um Einheiten und Format
# Globaler Helper
numeric_val = extract_numeric_value(cleaned_raw_value, is_umsatz=False)
value_found = numeric_val
self.logger.info(f" --> Mitarbeiter extrahiert (aus '{cleaned_raw_value[:50]}'): '{value_found}'")
# Wenn ein Wert für den gesuchten Target gefunden wurde, stoppen Sie die Suche in der Infobox.
# Wenn wir z.B. nach Umsatz suchen und den finden, wollen wir nicht weitersuchen.
# Wenn wir aber Branche, Umsatz UND Mitarbeiter extrahieren wollen, müssen wir alle Zeilen durchgehen.
# Die aktuelle Struktur ruft _extract_infobox_value 3x auf. Das ist OK.
# Wenn ein Wert FÜR DIESEN TARGET gefunden wurde, kann die Schleife über die Zeilen abgebrochen werden.
if value_found != "k.A.":
self.logger.debug(f" -> Wert für '{target}' in Zeile {idx} gefunden. Stoppe Infobox-Suche für dieses Target.")
break
if value_found != "k.A.": self.logger.debug(f" -> Finaler Wert für '{target}': '{value_found}'")
else: self.logger.debug(f" -> Kein passender Eintrag für '{target}' in der Infobox gefunden.")
except Exception as e:
self.logger.exception(f"Fehler beim Durchlaufen der Infobox-Zeilen für '{target}': {e}")
return "k.A."
return value_found
# extract_company_data Funktion
def extract_company_data(self, page_url):
"""
Extrahiert Firmendaten von einer Wikipedia-URL.
"""
default_result = {'url': page_url if page_url else 'k.A.', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}
if not page_url or not isinstance(page_url, str) or "wikipedia.org" not in page_url.lower():
self.logger.warning(f"extract_company_data: Ungültige URL '{page_url}'.")
return default_result
self.logger.info(f"Extrahiere Daten für Wiki-URL: {page_url}")
soup = self._get_page_soup(page_url) # Ruft eigene Methode auf (mit Retry)
if not soup:
self.logger.error(f" -> Fehler: Konnte Seite {page_url} nicht laden oder parsen.")
default_result['url'] = page_url # Behalte die URL, auch wenn Extraktion fehlschlägt
return default_result
# Extrahieren der Daten (ruft eigene Methoden auf)
self.logger.debug(" -> Extrahiere ersten Absatz...")
first_paragraph = self._extract_first_paragraph_from_soup(soup)
self.logger.debug(" -> Extrahiere Kategorien...")
categories_val = self.extract_categories(soup)
self.logger.debug(" -> Extrahiere Branche aus Infobox...")
branche_val = self._extract_infobox_value(soup, 'branche')
self.logger.debug(" -> Extrahiere Umsatz aus Infobox...")
umsatz_val = self._extract_infobox_value(soup, 'umsatz')
self.logger.debug(" -> Extrahiere Mitarbeiter aus Infobox...")
mitarbeiter_val = self._extract_infobox_value(soup, 'mitarbeiter')
result = {
'url': page_url, 'first_paragraph': first_paragraph, 'branche': branche_val,
'umsatz': umsatz_val, 'mitarbeiter': mitarbeiter_val, 'categories': categories_val
}
self.logger.info(f" -> Extrahierte Daten: P={result['first_paragraph'][:30]}..., B='{result['branche']}', U='{result['umsatz']}', M='{result['mitarbeiter']}', C={result['categories'][:30]}...")
return result
@retry_on_failure # Decorator wird hier angewendet
def search_company_article(self, company_name, website=None):
"""
Sucht einen passenden Wikipedia-Artikel und gibt das page-Objekt zurück.
Behandelt explizit Begriffsklärungsseiten.
"""
if not company_name: self.logger.warning("Wikipedia search skipped: No company name."); return None
# _generate_search_terms ist global
search_terms = _generate_search_terms(company_name, website)
if not search_terms: self.logger.warning(f"Keine Suchbegriffe für '{company_name}' generiert."); return None
self.logger.info(f"Starte Wikipedia-Suche für '{company_name}' (Website: {website}) mit Begriffen: {search_terms}")
processed_titles = set() # Verfolgt bereits geprüfte Titel
# Lokale Helferfunktion für die rekursive Prüfung und Validierung
def check_page(title_to_check):
if title_to_check in processed_titles: self.logger.debug(f" -> Titel '{title_to_check}' bereits geprüft, überspringe."); return None
processed_titles.add(title_to_check)
try:
self.logger.debug(f" -> Prüfe potenziellen Artikel: '{title_to_check}'")
# wikipedia.page hat eigenes Retry-Verhalten und Exceptions
# auto_suggest=False um unerwünschte automatische Korrekturen zu vermeiden
page = wikipedia.page(title_to_check, auto_suggest=False, preload=True)
# Ruft eigene Methode auf
if self._validate_article(page, company_name, website):
return page
else: self.logger.debug(f" -> Titel '{title_to_check}' nicht validiert."); return None
except wikipedia.exceptions.PageError: self.logger.debug(f" -> Seite '{title_to_check}' nicht gefunden (PageError)."); return None
except wikipedia.exceptions.DisambiguationError as e_inner:
self.logger.info(f" -> Begriffsklärung '{title_to_check}' gefunden. Prüfe Optionen...")
self.logger.debug(f" Optionen: {e_inner.options}")
best_option_page = None
# Prüfe die Optionen der Begriffsklärungsseite rekursiv
for option in e_inner.options:
# Einfache Heuristik zur Identifizierung potenzieller Firmen
option_lower = option.lower()
is_company_candidate = False
if "(unternehmen)" in option_lower: is_company_candidate = True
elif any(form in option_lower for form in [' gmbh', ' ag', ' kg', ' ltd', ' inc', ' corp', ' s.a.', ' se', ' group']): is_company_candidate = True
# Prüfe auch auf hohe Namensähnlichkeit mit dem Originalnamen
elif SequenceMatcher(None, normalize_company_name(option), normalize_company_name(company_name)).ratio() > 0.7: is_company_candidate = True
if is_company_candidate:
# Rekursiver Aufruf von check_page für die Option
validated_option_page = check_page(option)
if validated_option_page:
self.logger.info(f" -> Option '{option}' erfolgreich validiert!")
if best_option_page is None: best_option_page = validated_option_page # Nimm den ersten validen als "besten"
if best_option_page: return best_option_page
else: self.logger.warning(f" -> Keine passende/validierte Unternehmens-Option in Begriffsklärung '{title_to_check}' gefunden."); return None
except requests.exceptions.RequestException as e_req: self.logger.warning(f" -> Netzwerkfehler beim Laden/Validieren von '{title_to_check}': {e_req}. Überspringe Titel."); time.sleep(1); return None # Fehler hier abfangen, um Suche nicht abzubrechen
except Exception as e_page: self.logger.error(f" -> Fehler bei Verarbeitung von Titel '{title_to_check}': {type(e_page).__name__} - {e_page}"); return None # Fehler hier abfangen, um Suche nicht abzubrechen
# --- Haupt-Suchlogik ---
self.logger.debug(f" -> Versuche direkten Match für '{company_name}'...")
validated_page = check_page(company_name)
if validated_page: return validated_page
self.logger.debug(f" -> Kein direkter Treffer/validiert. Starte Suche mit generierten Begriffen: {search_terms}")
for term in search_terms:
try:
self.logger.debug(f" -> Suche mit Begriff: '{term}'...")
# wikipedia.search hat eigenes Retry-Verhalten und Exceptions
search_results = wikipedia.search(term, results=getattr(Config, 'WIKIPEDIA_SEARCH_RESULTS', 5))
self.logger.debug(f" -> Suchergebnisse für '{term}': {search_results}")
if not search_results: continue
for title in search_results:
validated_page = check_page(title) # Ruft lokale Helferfunktion auf
if validated_page: return validated_page
time.sleep(0.1) # Kleines Delay zwischen Titelprüfungen
except requests.exceptions.RequestException as e_search_req: self.logger.error(f"Netzwerkfehler während Wikipedia-Suche für '{term}': {e_search_req}"); time.sleep(2); raise e_search_req # Fehler weitergeben für Retry
except Exception as e_search: self.logger.error(f"Allgemeiner Fehler während Wikipedia-Suche für '{term}': {e_search}"); continue # Nächsten Begriff versuchen
self.logger.warning(f"Kein passender & validierter Wikipedia-Artikel für '{company_name}' gefunden nach Prüfung aller Begriffe und Optionen.")
return None
# --- Ende WikipediaScraper Klasse ---
# ==================== BATCH PROCESSING HELPER (Global) ====================
# Diese Funktion wird von DataProcessor.process_verification_batch aufgerufen.
# Sie kann global bleiben oder eine private Methode von DataProcessor werden.
# Wenn sie global bleibt, benötigt sie das sheet Objekt.
def _process_batch(sheet, batches, row_numbers):
"""
Hilfsfunktion für process_verification_batch: Verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen.
Aktualisiert NUR die Spalten S bis U. Zeitstempel werden von der aufrufenden Funktion gesetzt.
Args:
sheet (gspread.Worksheet): Das Worksheet-Objekt zum Schreiben.
batches (list): Eine Liste von Strings, jeder ist der Prompt-Teil für eine Zeile.
row_numbers (list): Liste der zugehörigen Sheet-Zeilennummern (1-basiert).
"""
if not batches: return
# --- Prompt Erstellung ---
aggregated_prompt = (
"Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln für Unternehmen. "
"Für jeden der folgenden Einträge prüfe, ob der vorhandene Wikipedia-Artikel (URL, Absatz, Kategorien) plausibel zum Firmennamen und zur Beschreibung passt. "
"Gib das Ergebnis für jeden Eintrag ausschließlich im folgenden Format auf einer neuen Zeile aus:\n"
"Eintrag <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\n"
"Einträge:\n"
"----------\n"
)
aggregated_prompt += "".join(batches)
aggregated_prompt += "----------\nBitte nur die 'Eintrag X: Antwort'-Zeilen ausgeben."
logging.info(f"Verarbeite Verifizierungs-Batch für Zeilen {row_numbers[0]} bis {row_numbers[-1]} ({len(batches)} Einträge).")
prompt_tokens = token_count(aggregated_prompt) # Annahme: token_count ist global
logging.debug(f"Token-Zahl für Verifizierungs-Batch: {prompt_tokens}")
chat_response = call_openai_chat(aggregated_prompt, temperature=0.0) # Annahme: call_openai_chat ist global mit Retry
if not chat_response:
logging.error(f"Fehler: Keine Antwort von OpenAI für Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]}.")
return # Batch Verarbeitung fehlgeschlagen
# Parse die aggregierte Antwort
answers = {} # {row_num: answer_text}
lines = chat_response.strip().split('\n')
for line in lines:
match = re.match(r"Eintrag (\d+): (.*)", line.strip())
if match:
row_num = int(match.group(1))
answer_text = match.group(2).strip()
if row_num in row_numbers: answers[row_num] = answer_text
# Bereite Batch-Update für Spalten S-U vor
updates = []
# Benötigte Spaltenindizes (Annahme: COLUMN_MAP ist global)
s_idx = COLUMN_MAP.get("Chat Wiki Konsistenzprüfung"); u_idx = COLUMN_MAP.get("Chat Vorschlag Wiki Artikel"); t_idx = COLUMN_MAP.get("Chat Begründung Wiki Inkonsistenz")
if None in [s_idx, u_idx, t_idx]: logging.error("FEHLER: Spaltenindizes für S, T, U fehlen in COLUMN_MAP."); return # Kann nicht schreiben
# Konvertiere Indizes in Buchstaben
s_l = GoogleSheetHandler()._get_col_letter(s_idx + 1) # Temporäre Nutzung der Methode, da GoogleSheetHandler global nicht direkt zugänglich
t_l = GoogleSheetHandler()._get_col_letter(t_idx + 1)
u_l = GoogleSheetHandler()._get_col_letter(u_idx + 1)
for row_num in row_numbers:
answer = answers.get(row_num, "k.A. (Keine Antwort im Batch)") # Fallback
wiki_confirm = ""; alt_article = ""; wiki_explanation = ""
if answer.upper() == "OK": wiki_confirm = "OK"
elif answer.startswith("X |"):
parts = answer.split("|", 2)
wiki_confirm = "X"
if len(parts) > 1:
detail = parts[1].strip()
if detail.startswith("Alternativer Artikel:"): alt_article = detail.split(":", 1)[1].strip()
elif detail == "Kein passender Artikel gefunden": alt_article = detail
else: alt_article = detail # Unerwartetes Format im Detail
if len(parts) > 2:
reason_part = parts[2].strip()
if reason_part.startswith("Begründung:"): wiki_explanation = reason_part.split(":", 1)[1].strip()
else: wiki_explanation = reason_part # Unerwartetes Format in Begründung
else: # Unerwartetes Format oder "Kein Wikipedia-Eintrag vorhanden." (sollte durch Suche vorher abgefangen sein)
wiki_confirm, wiki_explanation = "?", f"Unerwartetes Format: {answer[:100]}"
alt_article = ""
# Füge Updates für S, T, U hinzu (basierend auf Spaltenbeschreibung: S=Konstistenz, T=Begründung, U=Vorschlag)
updates.append({'range': f'{s_l}{row_num}', 'values': [[wiki_confirm]]})
updates.append({'range': f'{t_l}{row_num}', 'values': [[wiki_explanation]]}) # T ist Begründung
updates.append({'range': f'{u_l}{row_num}', 'values': [[alt_article]]}) # U ist Vorschlag
# Führe das Batch-Update für S-U durch
if updates:
# sheet wird übergeben, nutze es direkt
try:
sheet.batch_update(updates, value_input_option='USER_ENTERED')
logging.info(f"Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]} (S-U) erfolgreich in Google Sheet aktualisiert.")
except Exception as e:
logging.error(f"FEHLER beim Batch-Update (S-U) für Batch {row_numbers[0]}-{row_numbers[-1]}: {e}")
# Hier sollte der Fehler für Retry an den Aufrufer (process_verification_batch) weitergegeben werden.
# Da _process_batch als globaler Helfer konzipiert ist, werfen wir den Fehler hier nicht direkt.
# Die retry-Logik muss in process_verification_batch um den Aufruf von _process_batch herum sein.
# Oder _process_batch wird eine Methode und nutzt den @retry_on_failure Decorator.
# Aktuell ist es ein globaler Helfer. Lassen wir es so, der Aufrufer muss retryen.
pass # Nicht reraisen, um andere Batches nicht zu blockieren
# --- Ende Batch Processing Helper ---
# ==================== DATA PROCESSOR ====================
# Annahmen: GoogleSheetHandler, WikipediaScraper Klassen sind definiert
# Annahmen: Alle globalen Helper-Funktionen (clean_text, get_numeric_filter_value, etc.) sind definiert
# Annahme: COLUMN_MAP, Config, logging sind verfügbar.
class DataProcessor:
"""
Verarbeitet Daten aus dem Google Sheet, führt verschiedene Anreicherungs-
und Analyseprozesse durch, inklusive Timestamp-basierter Überspringung,
erzwungener Neuverarbeitung und granularer Schrittauswahl. Orchestriert
die verschiedenen Verarbeitungsmodi (sequenziell, batch, re-eval, kriterien).
Enthält auch die Datenvorbereitung und das Training des ML-Modells.
"""
def __init__(self, sheet_handler, wiki_scraper):
"""
Initialisiert den DataProcessor mit Handlern.
Args:
sheet_handler (GoogleSheetHandler): Eine initialisierte Instanz.
wiki_scraper (WikipediaScraper): Eine initialisierte Instanz.
# Fügen Sie hier weitere Handler/Scraper hinzu, falls nötig (z.B. WebsiteScraper falls separat)
"""
if sheet_handler is None:
logging.critical("DataProcessor Init FEHLER: Kein gültiger sheet_handler übergeben!")
raise ValueError("DataProcessor benötigt einen gültigen GoogleSheetHandler.")
if wiki_scraper is None:
logging.critical("DataProcessor Init FEHLER: Kein gültiger wiki_scraper übergeben!")
raise ValueError("DataProcessor benötigt einen gültigen WikipediaScraper.")
self.sheet_handler = sheet_handler
self.wiki_scraper = wiki_scraper # Speichert den übergebenen wiki_scraper
# Fügen Sie hier Instanzvariablen für weitere Handler hinzu:
# self.website_scraper = website_scraper # Beispiel
# self.api_client = api_client # Beispiel
logging.info("DataProcessor initialisiert.")
# --- Private Helfermethode: Zugriff auf Zellwert mit row_data ---
# Diese Methode gehört in die Klasse und nimmt die rohe Zeilendatenliste entgegen
def _get_cell_value(self, row_data, key):
"""Lokale Hilfsfunktion zum sicheren Zugriff auf Zellwerte innerhalb von Methoden, die row_data als Parameter erhalten."""
idx = COLUMN_MAP.get(key)
if idx is not None and len(row_data) > idx:
return row_data[idx] if row_data[idx] is not None else ''
return ""
# --- Private Helfermethode: Prüft ob ein Schritt nötig ist (basierend auf Timestamp/Status) ---
# Diese Methode gehört in die Klasse
def _is_step_processing_needed(self, row_data, step_key, force_reeval, related_inputs_updated=False):
"""
Prüft, ob ein spezifischer Verarbeitungsschritt für diese Zeile ausgeführt werden soll,
basierend auf Timestamp, force_reeval und ob Eingangsdaten aktualisiert wurden.
Args:
row_data (list): Die rohen Daten für die Zeile.
step_key (str): Schlüssel des Timestamps in COLUMN_MAP, der diesen Schritt markiert (z.B. "Wikipedia Timestamp", "Timestamp letzte Prüfung"). Für Schritte ohne dedizierten Timestamp kann None übergeben werden, dann ist das Kriterium NUR related_inputs_updated oder force_reeval.
force_reeval (bool): Erzwingt die Ausführung, ignoriert Timestamps.
related_inputs_updated (bool, optional): Flag, ob Eingangsdaten für diesen Schritt gerade aktualisiert wurden (z.B. Wiki Daten für Branch Eval). Defaults to False.
Returns:
bool: True, wenn der Schritt ausgeführt werden soll, sonst False.
"""
if force_reeval:
# logging.debug(f" -> Step Check '{step_key}': True (force_reeval aktiv)") # Zu laut
return True
# Schritt hat keinen spezifischen Timestamp
if step_key is None:
# Logik: Wenn kein Timestamp, ist der Schritt nötig, wenn Inputs aktualisiert wurden.
# (Oder man definiert eine andere Logik, z.B. immer laufen wenn Flags gesetzt?)
# Für Abhängigkeiten ist related_inputs_updated der Trigger.
# Ohne force_reeval und ohne Timestamp ist der Schritt nur nötig, wenn Inputs neu sind.
needs_processing = related_inputs_updated
# logging.debug(f" -> Step Check '{step_key}' (Ohne TS): Nötig? {needs_processing} (Inputs aktualisiert? {related_inputs_updated})") # Zu laut
return needs_processing
timestamp_col_index = COLUMN_MAP.get(step_key)
if timestamp_col_index is None:
logging.error(f" -> Step Check Fehler: Timestamp Schlüssel '{step_key}' nicht in COLUMN_MAP gefunden.")
return False # Kann nicht geprüft werden
ts_value = row_data[timestamp_col_index] if len(row_data) > timestamp_col_index else ""
ts_is_set = bool(str(ts_value).strip())
# Ein Schritt ist nötig, wenn der Timestamp fehlt ODER relevante Inputs gerade aktualisiert wurden
needs_processing = not ts_is_set or related_inputs_updated
# logging.debug(f" -> Step Check '{step_key}': Nötig? {needs_processing} (TS gesetzt? {ts_is_set}, Inputs aktualisiert? {related_inputs_updated})") # Zu laut
return needs_processing
# --- Die zentrale Methode zur Verarbeitung einer einzelnen Zeile ---
# Diese Methode gehört in die Klasse
# @retry_on_failure # Retry macht hier wenig Sinn, besser auf den einzelnen API Calls innerhalb
def _process_single_row(self, row_num_in_sheet, row_data,
process_wiki=True, process_chatgpt=True, process_website=True,
force_reeval=False):
"""
Verarbeitet die Daten für eine einzelne Zeile basierend auf ausgewählten Schritten
und Timestamp/Status-Logik (falls nicht force_reeval). Diese ist die zentrale
Logik für sequenzielle, re-eval und kriterienbasierte Modi.
Args:
row_num_in_sheet (int): Die 1-basierte Zeilennummer im Google Sheet.
row_data (list): Die rohen Listendaten für diese Zeile.
process_wiki (bool, optional): Soll die Gruppe Wiki-Schritte ausgeführt werden?. Defaults to True.
process_chatgpt (bool, optional): Soll die Gruppe ChatGPT-Schritte ausgeführt werden?. Defaults to True.
process_website (bool, optional): Soll die Gruppe Website-Schritte ausgeführt werden?. Defaults to True.
force_reeval (bool, optional): Ignoriert Timestamps und erzwingt Ausführung ausgewählter Schritte. Defaults to False.
"""
# Logge welche Gruppen von Schritten für DIESE Zeile versucht werden sollen (basierend auf den Flags)
groups_to_attempt_log = []
if process_website: groups_to_attempt_log.append("Website")
if process_wiki: groups_to_attempt_log.append("Wiki")
if process_chatgpt: groups_to_attempt_log.append("ChatGPT")
# Hinweis: Dies sind nur Gruppen-Flags. Detailliertere Step-Flags können im Refactoring hier verwendet werden.
# z.B. flags_for_steps = {'process_wiki_extraction': True, 'process_branch_evaluation': False, ...}
logging.info(f"--- Starte Verarbeitung für Zeile {row_num_in_sheet} ({'Re-Eval' if force_reeval else 'Standard'}) - Gruppen: {', '.join(groups_to_attempt_log) if groups_to_attempt_log else 'Keine ausgewählt!'} ---")
updates = []
now_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
any_processing_done = False # Flag, ob überhaupt etwas in dieser Zeile verarbeitet wurde (für Version-Timestamp)
# Flags, die signalisieren, ob ein VORHERIGER Schritt erfolgreich aktualisiert wurde
# Dies wird für NACHFOLGENDE Schritte als related_inputs_updated verwendet
wiki_data_updated_in_this_run = False # Hat Wiki Search/Extract Daten in diesem Lauf geändert?
website_data_updated_in_this_run = False # Hat Website Scraping/Summarize in diesem Lauf Daten geändert?
# Initiale Werte lesen (aus den erhaltenen row_data) - Nutze private Helfermethode
company_name = self._get_cell_value(row_data, "CRM Name")
website_url_crm = self._get_cell_value(row_data, "CRM Website") # CRM Website URL (Initial)
crm_branche = self._get_cell_value(row_data, "CRM Branche")
crm_beschreibung = self._get_cell_value(row_data, "CRM Beschreibung")
konsistenz_s = self._get_cell_value(row_data, "Chat Wiki Konsistenzprüfung").strip() # Trimme hier schon
# Lade aktuelle Daten (könnten alt sein, werden ggf. überschrieben) für Inputs nachfolgender Schritte
# Wir lesen hier die Werte, die ZU BEGINN der Verarbeitung dieser Zeile im Sheet stehen.
# Wenn ein Schritt Daten aktualisiert (z.B. Wiki-Daten), wird die lokale Variable (z.B. final_wiki_data) aktualisiert.
# Nachfolgende Schritte in DIESER Zeile nutzen dann die aktualisierte lokale Variable.
current_wiki_data = {
'url': self._get_cell_value(row_data, "Wiki URL") or 'k.A.',
'first_paragraph': self._get_cell_value(row_data, "Wiki Absatz") or 'k.A.',
'branche': self._get_cell_value(row_data, "Wiki Branche") or 'k.A.',
'umsatz': self._get_cell_value(row_data, "Wiki Umsatz") or 'k.A.',
'mitarbeiter': self._get_cell_value(row_data, "Wiki Mitarbeiter") or 'k.A.',
'categories': self._get_cell_value(row_data, "Wiki Kategorien") or 'k.A.'
}
# Erstelle lokale Variablen für die aktuellen Daten, die im Lauf aktualisiert werden können
final_wiki_data = dict(current_wiki_data)
current_website_raw = self._get_cell_value(row_data, "Website Rohtext") or "k.A."
current_website_summary = self._get_cell_value(row_data, "Website Zusammenfassung") or "k.A."
# Lokale Variablen, die im Lauf aktualisiert werden können
final_website_raw = current_website_raw
final_website_summary = current_website_summary
# --- 1. Website Handling (Lookup, Scraping AR, Summarize AS) ---
# Dieser Block wird nur ausgeführt, wenn die GRUPPE "Website" ausgewählt ist
if process_website:
# Website Scraping (AR) & Summarize (AS) sind nötig, wenn:
# (_is_step_processing_needed für AT ODER force_reeval) UND Website-URL vorhanden.
# Hier prüfen wir den Timestamp AT. Website-Lookup hat keinen eigenen Timestamp.
website_scrape_needed = self._is_step_processing_needed(row_data, "Website Scrape Timestamp", force_reeval, related_inputs_updated=False) # related_inputs_updated für Website ist immer False
if website_scrape_needed:
any_processing_done = True
logging.info(f"Zeile {row_num_in_sheet}: Starte Website Verarbeitung (Scraping/Summarize) (Grund: {'Re-Eval' if force_reeval else 'AT fehlt'})...")
# Website Lookup (D) nur, wenn URL fehlt ( unabhängig von steps-Flags, da es ein Pre-Requisite ist)
# UND der Website-Step überhaupt ausgewählt ist.
website_url_to_process = website_url_crm # Starte mit der CRM URL
if not website_url_crm or website_url_crm.strip().lower() == "k.a.":
logging.debug(" -> Suche Website via SERP (URL fehlt)...")
new_website = serp_website_lookup(company_name) # Globaler Funktion mit Retry
if new_website != "k.A.":
website_url_to_process = new_website # Update die URL für die Verarbeitung
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["CRM Website"] + 1)}{row_num_in_sheet}', 'values': [[website_url_to_process]]})
logging.info(f" -> Neue Website gefunden und für Update vorgemerkt: {website_url_to_process}")
else:
logging.warning(f" -> Keine neue Website via SERP gefunden für '{company_name}'.")
# website_url_to_process bleibt die ursprüngliche (fehlende) URL
if website_url_to_process and website_url_to_process.strip().lower() not in ["k.a.", ""]:
logging.debug(f" -> Scrape Rohtext von {website_url_to_process}...")
final_website_raw = get_website_raw(website_url_to_process) # Globaler Funktion mit Retry
# Website Summary (AS) wird gemacht, wenn Scraping erfolgreich war.
if final_website_raw != "k.A." and final_website_raw.strip():
logging.debug(f" -> Fasse Rohtext zusammen (Länge: {len(final_website_raw)})...")
final_website_summary = summarize_website_content(final_website_raw) # Globaler Funktion mit Retry
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[final_website_summary]]})
else:
logging.warning(" -> Kein gültiger Rohtext zum Zusammenfassen vorhanden.")
final_website_summary = "k.A." # Sicherstellen, dass lokale Variable korrekt ist
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [[final_website_raw]]}) # Rohtext immer schreiben (k.A. oder Inhalt)
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Scrape Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # Timestamp AT setzen
# Flag setzen, da Website-Daten aktualisiert wurden (AR und/oder AS)
website_data_updated_in_this_run = True
else:
logging.warning(f" -> Keine gültige Website URL vorhanden/gefunden für '{company_name}'. Website Verarbeitung (Scraping/Summarize) übersprungen.")
final_website_raw, final_website_summary = "k.A.", "k.A." # Sicherstellen, dass lokale Vars gesetzt sind
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Scrape Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # AT trotzdem setzen, um Versuch zu markieren
# --- 2. Wikipedia Handling (Search M, Extract N-R, Verify S-U) ---
# Dieser Block wird nur ausgeführt, wenn die GRUPPE "Wiki" ausgewählt ist
if process_wiki:
# Wiki Search & Extraction (M, N-R) ist nötig, wenn:
# (_is_step_processing_needed für AN ODER S='X (URL Copied)'?)
wiki_search_extract_needed = self._is_wiki_search_extract_needed(row_data, force_reeval) # _is_wiki_search_extract_needed prüft AN und S='X' und force_reeval
if wiki_search_extract_needed:
any_processing_done = True
# Grund-Message wird von _is_wiki_search_extract_needed implizit geprüft, hier im Log wiederholen
logging.info(f"Zeile {row_num_in_sheet}: Starte Wikipedia Verarbeitung (Search/Extract) (Grund: {'Re-Eval' if force_reeval else 'Standard (AN fehlt oder S=X)'})...")
url_in_m = self._get_cell_value(row_data, "Wiki URL").strip() # Lese URL, die ZU BEGINN da war
url_to_extract = None
search_was_needed = False
# --- Logik für URL-Bestimmung (wie zuvor, mit kleinen Anpassungen) ---
# Wenn force_reeval: Nutze M direkt, wenn gültig. Sonst Suche.
# Wenn S="X (URL Copied)": Ignoriere M, mache Suche.
# Wenn AN fehlt (Standard): Wenn M gültig, valide M. Sonst Suche.
# Beachte: Wir nutzen hier die URL, die ZU BEGINN der Zeilenverarbeitung in M stand.
if force_reeval:
logging.debug(" -> Wiki Search/Extract: Re-Eval Modus aktiv.")
if url_in_m and url_in_m.lower() not in ["k.a.", "kein artikel gefunden"] and url_in_m.lower().startswith("http"):
logging.info(f" -> Re-Eval: Nutze vorhandene URL aus Spalte M direkt: {url_in_m}")
url_to_extract = url_in_m
else:
logging.warning(f" -> Re-Eval: Spalte M ist leer oder ungültig ('{url_in_m}'). Starte neue Suche...")
search_was_needed = True
elif konsistenz_s.upper() == "X (URL COPIED)": # S='X (URL Copied)' ist ein direkter Such-Trigger
logging.warning(f" -> Status S ist 'X (URL Copied)', ignoriere URL '{url_in_m}' in M und starte neue Suche...")
search_was_needed = True
elif not self._get_cell_value(row_data, "Wikipedia Timestamp").strip(): # Nur wenn AN fehlt und S nicht 'X(Copied)' ist, UND kein reeval
if url_in_m and url_in_m.lower() not in ["k.a.", "kein artikel gefunden"] and url_in_m.lower().startswith("http"):
logging.debug(f" -> AN fehlt, prüfe Validität der URL aus M: {url_in_m}")
try:
# Extrahiere Titel für wikipedia.page (kann fehlschlagen)
title_from_url = url_in_m.split('/wiki/')[-1].replace('_', ' ')
# Nutze self.wiki_scraper Methode für Validierung
if self.wiki_scraper._validate_article(None, company_name, website_url_crm): # validate_article braucht page obj, aber wir haben nur URL
# Das ist kompliziert. is_valid_wikipedia_article_url prüft ob es ein Artikel ist.
# validate_article prüft ob der Artikel ZUR FIRMA passt.
# Wenn AN fehlt und M da ist: Prüfen, ob M auf einen validen Artikel verweist, UND ob dieser Artikel zur Firma passt.
# is_valid_wikipedia_article_url (global) prüft nur auf Artikel.
# validate_article (scraper Methode) prüft auf Passung zur Firma.
# Ideal wäre: check_page() aus search_company_article, die beides tut.
# Da check_page lokal ist: Duplizieren wir die Logik hier oder machen check_page zu einer scraper Methode.
# Machen wir check_page zu einer Scraper Methode.
# NEU: Rufe Scraper Methode auf, die URL prüft UND validiert
validated_page = self.wiki_scraper.check_url_and_validate(url_in_m, company_name, website_url_crm) # <<< NEUE SCRAPER METHODE NÖTIG
if validated_page:
url_to_extract = validated_page.url
logging.info(f" -> Vorhandene URL aus M '{url_to_extract}' ist valide und passt zur Firma.")
else:
logging.warning(f" -> Vorhandene URL aus M '{url_in_m}' ist NICHT valide oder passt nicht zur Firma. Starte neue Suche...")
search_was_needed = True
except Exception as e_val_m: # Fängt Fehler bei URL parsing oder check_url_and_validate ab
logging.warning(f" -> Fehler/Problem bei Prüfung der URL aus M '{url_in_m}': {type(e_val_m).__name__} - {e_val_m}. Starte neue Suche...")
search_was_needed = True
else:
logging.info(f" -> AN fehlt und M leer/ungültig. Starte Wikipedia-Suche für '{company_name}'...")
search_was_needed = True
# Führe Suche aus, wenn search_was_needed True ist
if search_was_needed:
# Nutze self.wiki_scraper
validated_page = self.wiki_scraper.search_company_article(company_name, website_url_crm) # Nutze CRM Website URL
if validated_page:
url_to_extract = validated_page.url
else:
final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}
wiki_data_updated_in_this_run = True
logging.warning(f" -> Wikipedia Suche für '{company_name}' fand keinen validen Artikel.")
# Datenextraktion, wenn eine URL zum Extrahieren bestimmt wurde (kann auch "Kein Artikel gefunden" sein)
if url_to_extract: # Dies ist der URL, der *jetzt* in M stehen sollte (oder Kein Artikel gefunden)
logging.info(f" -> Extrahiere Daten von URL: {url_to_extract}...")
# Prüfe, ob die URL überhaupt valide ist, bevor extrahiert wird
if url_to_extract != 'Kein Artikel gefunden' and url_to_extract.lower().startswith("http"):
# Nutze self.wiki_scraper
extracted_data = self.wiki_scraper.extract_company_data(url_to_extract)
if extracted_data:
# Aktualisiere die lokale Variable final_wiki_data
final_wiki_data.update(extracted_data)
wiki_data_updated_in_this_run = True
logging.info(f" -> Datenextraktion erfolgreich.")
else:
logging.error(f" -> Fehler bei Datenextraktion von {url_to_extract}. Setze extrahierte Daten auf 'k.A.'")
# URL bleibt, aber extrahierte Felder werden auf k.A. gesetzt
final_wiki_data.update({'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'})
wiki_data_updated_in_this_run = True # Markiere als aktualisiert, auch wenn mit k.A.
else:
# Wenn url_to_extract "Kein Artikel gefunden" ist oder ungültig, setze extrahierte Felder auf k.A.
# URL (M) wird ja oben schon gesetzt
logging.info(f" -> Keine gültige URL zum Extrahieren ({url_to_extract}). Setze extrahierte Daten auf 'k.A.'.")
final_wiki_data.update({'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'})
# wiki_data_updated_in_this_run = True # Bereits oben gesetzt, wenn search_was_needed
# Sheet Updates für M-R und AN (nur wenn dieser Wiki Search/Extract Schritt lief)
# Diese Updates spiegeln die final_wiki_data am Ende dieses Blocks wider.
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki URL"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('url', 'k.A.')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Absatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('first_paragraph', 'k.A.')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Branche"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('branche', 'k.A.')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('umsatz', 'k.A.')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('mitarbeiter', 'k.A.')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Kategorien"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('categories', 'k.A.')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wikipedia Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # AN Timestamp setzen
# Setze S ('Chat Wiki Konsistenzprüfung') und AX zurück, wenn eine Neubewertung nötig ist
# S und AX werden durch die Wiki Verify Batch Logik gesetzt/geprüft.
# Hier setzen wir S und AX nur zurück, wenn sich die URL geändert hat ODER force_reeval war
# ODER S vorher X(Copied) war. Das triggert die Wiki Verify Batch Logik später.
# Lese die URL, die ZU BEGINN in M stand, für diesen Vergleich
url_changed = (self._get_cell_value(row_data, "Wiki URL").strip() != final_wiki_data.get('url', 'k.A.'))
if force_reeval or konsistenz_s.upper() == "X (URL COPIED)" or url_changed: # konsistenz_s ist der Wert ZU BEGINN
s_idx = COLUMN_MAP.get("Chat Wiki Konsistenzprüfung"); ax_idx = COLUMN_MAP.get("Wiki Verif. Timestamp")
if s_idx is not None:
s_let = self.sheet_handler._get_col_letter(s_idx + 1)
updates.append({'range': f'{s_let}{row_num_in_sheet}', 'values': [["?"]]}) # Fragezeichen für Neubewertung
grund_message_parts = []
if force_reeval: grund_message_parts.append('Re-Eval')
if konsistenz_s.upper() == "X (URL COPIED)": grund_message_parts.append("S='X (URL Copied)'")
if url_changed: grund_message_parts.append('URL geändert')
grund_message = ", ".join(grund_message_parts)
logging.info(f" -> Status S zurückgesetzt auf '?' für erneute Verifikation (Grund: {grund_message}).")
if ax_idx is not None:
ax_let = self.sheet_handler._get_col_letter(ax_idx + 1)
updates.append({'range': f'{ax_let}{row_num_in_sheet}', 'values': [['']]}) # AX leeren, triggert Wiki Verify Batch
# --- 3. Wikipedia Verifizierung (S-U, AX) ---
# Dies ist ein UNTER-Schritt der Wiki-Verarbeitungsgruppe.
# Er wird ausgeführt, wenn die GRUPPE "Wiki" ausgewählt ist (process_wiki=True)
# UND (_is_step_processing_needed für AX ODER Wiki Daten gerade aktualisiert wurden)
# Wir müssen hier prüfen, ob der spezifische Verify-Schritt ausgewählt ist, falls wir granularer steuern wollen.
# Mit den aktuellen Flags (process_wiki, process_chatgpt, process_website) ist Verify Teil von process_wiki.
# Also: Wenn process_wiki True UND (_is_step_processing_needed für AX ODER wiki_data_updated_in_this_run)
wiki_verify_needed = process_wiki and self._is_step_processing_needed(row_data, "Wiki Verif. Timestamp", force_reeval, wiki_data_updated_in_this_run)
if wiki_verify_needed:
any_processing_done = True
logging.info(f"Zeile {row_num_in_sheet}: Starte Wikipedia Verifizierung (Grund: {'Re-Eval' if force_reeval else f'AX fehlt oder Wiki Daten aktualisiert ({wiki_data_updated_in_this_run})'})...")
# Hier ist die Logik, die den ChatGPT-Call für die Verifizierung macht (zeilenweise)
# Annahme: call_openai_chat ist global mit Retry
# Annahme: COLUMN_MAP Indizes für S, T, U sind vorhanden
# Daten für die Verifizierung sammeln (nutze final_wiki_data)
company_name = self._get_cell_value(row_data, "CRM Name")
crm_desc = self._get_cell_value(row_data, "CRM Beschreibung")
entry_text = (
f"Eintrag {row_num_in_sheet}:\n"
f" Firmenname: {company_name}\n"
f" CRM-Beschreibung: {crm_desc[:200]}...\n"
f" Wikipedia-URL: {final_wiki_data.get('url', 'k.A.')}\n" # Nutze final_wiki_data
f" Wiki-Absatz: {final_wiki_data.get('first_paragraph', 'k.A.')[:200]}...\n" # Nutze final_wiki_data
f" Wiki-Kategorien: {final_wiki_data.get('categories', 'k.A.')[:200]}...\n" # Nutze final_wiki_data
f"----\n"
)
# Prompt für EINE Zeile erstellen
prompt = ( # ... Prompt Definition wie oben in Teil 3 ... )
"Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln für Unternehmen.\n"
"Prüfe, ob der folgende Wikipedia-Artikel plausibel zum Firmennamen und zur Beschreibung passt.\n"
"Gib das Ergebnis ausschließlich im folgenden Format aus:\n"
"Antwort: <Antwort>\n\n"
"Mögliche Antworten (Kurzform):\n"
"- 'OK' (wenn der Artikel gut passt)\n"
"- 'X | Alternativer Artikel: <URL> | Begründung: <Kurze Begründung>'\n"
"- 'X | Kein passender Artikel gefunden | Begründung: <Kurze Begründung>'\n\n"
"Eintrag:\n"
"----------\n"
f"{entry_text}"
"----------\nBitte nur die 'Antwort: ...'-Zeile ausgeben."
)
chat_response = call_openai_chat(prompt, temperature=0.0)
wiki_confirm = ""; alt_article = ""; wiki_explanation = ""
if chat_response: # <= ZEILE 2262
match = re.match(r"Antwort: (.*)", chat_response.strip()) # <= ZEILE 2263
if match: # <= ZEILE 2264
answer_text = match.group(1).strip() # <= ZEILE 2265
logging.debug(f"Zeile {row_num_in_sheet} Verifizierungsantwort: '{answer_text}'") # <= ZEILE 2266
if answer_text.upper() == "OK": # <= ZEILE 2267
wiki_confirm = "OK"
elif answer_text.startswith("X |"): # <= ZEILE 2268
parts = answer_text.split("|", 2) # <= ZEILE 2269
wiki_confirm = "X" # <= ZEILE 2270
alt_article = "" # Initialisieren (oder "" von oben nutzen)
wiki_explanation = "" # Initialisieren
if len(parts) > 1: # <= ZEILE 2271
detail = parts[1].strip() # <= ZEILE 2272
# Annahme: wiki_explanation kann von parts[2] gesetzt werden
if len(parts) > 2: # <= ZEILE 2273
# wiki_explanation = parts[2].split(":", 1)[1].strip() if parts[2].strip().startswith("Begründung:") else parts[2].strip() # Alte Logik
reason_part = parts[2].strip() # <= ZEILE 2274
if reason_part.lower().startswith("begründung:"): # <= ZEILE 2275
wiki_explanation = reason_part.split(":", 1)[1].strip() # <= ZEILE 2276
else:
# Wenn es nicht mit "Begründung:" anfängt, nehmen wir den ganzen Rest als Begründung
wiki_explanation = reason_part # <= ZEILE 2277
# Verarbeite den Detail-Teil (parts[1]) unabhängig von len(parts)>2
if detail.lower().startswith("alternativer artikel:"): # <= ZEILE 2274 (im Screenshot falsch eingerückt?)
alt_article = detail.split(":", 1)[1].strip() # <= ZEILE 2275 (im Screenshot falsch eingerückt?)
elif detail.lower() == "kein passender artikel gefunden": # <= ZEILE 2276 (im Screenshot falsch eingerückt?)
alt_article = detail # Der Text selbst ist der Vorschlag
else:
# Wenn der Detail-Teil weder URL noch "Kein passender Artikel" ist, behandeln wir ihn als Begründung,
# falls noch keine Begründung aus parts[2] gefunden wurde.
# Oder loggen wir es als unerwartet? Loggen ist sicherer.
logging.warning(f"Zeile {row_num_in_sheet}: Unerwartetes Detail-Format nach 'X |': '{detail}'")
# Setze es als Begründung nur, wenn parts[2] nicht existierte oder leer war?
# Vereinfacht: Wenn Detail-Teil nicht URL/Kein gefunden, füge ihn ZUR Begründung hinzu.
if wiki_explanation: wiki_explanation += f" | Unerw. Detail: {detail}"
else: wiki_explanation = f"Unerw. Detail: {detail}"
# ELSE für if answer_text.upper() == "OK": und elif answer_text.startswith("X |"):
# Dies wird erreicht, wenn die Antwort kein "OK" und kein "X |" Format hat.
else: # <= ZEILE 2280
# Korrigierte Einrückung für diesen Block: muss unter das 'else:' gehören
wiki_confirm = "?" # <= ZEILE 2281 (korrekt eingerückt)
wiki_explanation = f"Unerwartetes Format: {answer_text[:100]}..." # <= ZEILE 2281 (als separate Zeile)
alt_article = "" # <= ZEILE 2281 (als separate Zeile)
# Loggen Sie den unerwarteten Fall
logging.error(f"Zeile {row_num_in_sheet}: Unerwartetes Verifizierungs-Antwortformat: '{answer_text}'.") # <= ZEILE 2282 (korrekt eingerückt)
# ELSE für if match: (wenn der Regex "Antwort: (.*)" nicht matchte)
else: # <= ZEILE 2284
# Korrigierte Einrückung für diesen Block: muss unter das 'else:' gehören
wiki_confirm = "?" # <= ZEILE 2285 (korrekt eingerückt)
wiki_explanation = f"Parsing Fehler: {chat_response[:100]}..." # <= ZEILE 2285 (als separate Zeile)
alt_article = "" # <= ZEILE 2285 (als separate Zeile)
logging.error(f"Zeile {row_num_in_sheet}: Parsing Fehler für Verifizierungsantwort (Regex Match fehlgeschlagen): {chat_response}.") # <= ZEILE 2286 (korrekt eingerückt)
# ELSE für if chat_response: (wenn call_openai_chat None zurückgab)
else: # <= ZEILE 2284 (anderes else, gehört zu if chat_response:)
# Korrigierte Einrückung für diesen Block: muss unter das 'else:' gehören
wiki_confirm = "Fehler" # <= ZEILE 2285 (korrekt eingerückt)
wiki_explanation = "API Fehler oder keine Antwort" # <= ZEILE 2285 (als separate Zeile)
alt_article = "" # <= ZEILE 2285 (als separate Zeile)
logging.error(f"Zeile {row_num_in_sheet}: API Fehler oder keine Antwort für Verifizierungs-Prompt.") # <= ZEILE 2286 (korrekt eingerückt)
# Füge Updates für S, T, U hinzu (basierend auf Spaltenbeschreibung: S=Konstistenz, T=Begründung, U=Vorschlag)
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Wiki Konsistenzprüfung"] + 1)}{row_num_in_sheet}', 'values': [[wiki_confirm]]}) # <= ZEILE 2288
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Wiki Inkonsistenz"] + 1)}{row_num_in_sheet}', 'values': [[wiki_explanation]]}) # T ist Begründung <= ZEILE 2289
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Wiki Artikel"] + 1)}{row_num_in_sheet}', 'values': [[alt_article]]}) # U ist Vorschlag <= ZEILE 2290
# Setze AX Timestamp, wenn dieser Schritt gemacht wurde
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Verif. Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # <= ZEILE 2293
# --- 4. ChatGPT Evaluationen (Branch W-Y, FSM Z-AA, MA AB-AD, Umsatz AG-AH) ---
# Dieser Block wird nur ausgeführt, wenn die GRUPPE "ChatGPT" ausgewählt ist
# ... (Rest der _process_single_row Methode, wie in Teil 4/6 von 18:55 Uhr) ...
if process_chatgpt:
# Branch Evaluation (W-Y) ist nötig, wenn: (_is_step_processing_needed für AO ODER Inputs (Wiki/Web) wurden aktualisiert)
chat_ts_ao_missing = not self._get_cell_value(row_data, "Timestamp letzte Prüfung").strip()
branch_eval_needed = self._is_step_processing_needed(row_data, "Timestamp letzte Prüfung", force_reeval, wiki_data_updated_in_this_run or website_data_updated_in_this_run)
if branch_eval_needed:
any_processing_done = True
logging.info(f"Zeile {row_num_in_sheet}: Starte Branchen Evaluation (Grund: {'Re-Eval' if force_reeval else f'AO fehlt oder Inputs aktualisiert ({wiki_data_updated_in_this_run or website_data_updated_in_this_run})'})...")
# Annahme: evaluate_branche_chatgpt existiert (global) und nutzt logging/retry
# Nutze die (ggf. neu extrahierten) final_wiki_data und final_website_summary
branch_result = evaluate_branche_chatgpt(
crm_branche, crm_beschreibung,
final_wiki_data.get('branche', 'k.A.'), # Nutze final_wiki_data
final_wiki_data.get('categories', 'k.A.'), # Nutze final_wiki_data
final_website_summary # Nutze final_website_summary
)
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('branch', 'Fehler')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenz Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('consistency', 'Fehler')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('justification', 'Fehler')]]})
# Setze AO Timestamp, wenn Branch Evaluation gemacht wurde
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Timestamp letzte Prüfung"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]})
# --- Weitere ChatGPT-Schätzungen und Konsistenzprüfungen ---
# Diese laufen JETZT mit, wenn die GRUPPE "ChatGPT" ausgewählt war UND Branch Eval nötig war.
# Im Refactoring werden dies granularere, wählbare Schritte mit eigenen Flags.
# FSM Evaluation (Z-AA)
# Annahme: evaluate_fsm_suitability existiert (global) und nutzt logging/retry
fsm_result = evaluate_fsm_suitability(company_name, {'wiki': final_wiki_data, 'web_summary': final_website_summary, 'crm_desc': crm_beschreibung})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Prüfung FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[fsm_result.get('suitability', 'Fehler')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung für FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[fsm_result.get('justification', 'Fehler')]]})
# Mitarbeiterzahl Schätzung (AB)
# Annahme: process_employee_estimation existiert (global) und nutzt logging/retry
# Benötigt Wiki Paragraph, CRM Employee als Input
estimated_emp_value_str = process_employee_estimation(company_name, final_wiki_data.get('first_paragraph', 'k.A.'), self._get_cell_value(row_data, "CRM Anzahl Mitarbeiter")) # Ergebnis wird in AB geschrieben
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schätzung Anzahl Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[estimated_emp_value_str]]}) # Annahme: gibt String zurück
# Mitarbeiter Konsistenzprüfung (AC, AD)
# Annahme: process_employee_consistency existiert (global)
# Braucht CRM, Wiki, und geschätzte MA (nehme den geschätzten Wert aus updates oder row_data, hier updates besser)
# Finden Sie den geschätzten Wert aus den Updates, falls vorhanden, sonst nehmen Sie den alten Wert aus row_data
estimated_emp_value_for_consistency = next((item['values'][0][0] for item in updates if item['range'].startswith(self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schätzung Anzahl Mitarbeiter"] + 1))), self._get_cell_value(row_data, "Chat Schätzung Anzahl Mitarbeiter"))
emp_consistency = process_employee_consistency(self._get_cell_value(row_data, "CRM Anzahl Mitarbeiter"), final_wiki_data.get('mitarbeiter', 'k.A.'), estimated_emp_value_for_consistency)
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenzprüfung Mitarbeiterzahl"] + 1)}{row_num_in_sheet}', 'values': [[emp_consistency.get('consistency', 'Fehler')]]})
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Mitarbeiterzahl"] + 1)}{row_num_in_sheet}', 'values': [[emp_consistency.get('justification', 'Fehler')]]})
# Umsatz Schätzung (AG)
# Annahme: evaluate_umsatz_chatgpt existiert (global)
# Benötigt Wiki Umsatz (aus extrahierten Daten) als Input
estimated_umsatz_value_str = evaluate_umsatz_chatgpt(company_name, final_wiki_data.get('umsatz', 'k.A.')) # Ergebnis wird in AG geschrieben
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schätzung Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[estimated_umsatz_value_str]]}) # Annahme: gibt String zurück
# Umsatz Konsistenzprüfung (AH)
# Annahme: evaluate_umsatz_chatgpt_consistency existiert (global)
# Braucht CRM, Wiki, und geschätzten Umsatz
estimated_umsatz_value_for_consistency = next((item['values'][0][0] for item in updates if item['range'].startswith(self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schätzung Umsatz"] + 1))), self._get_cell_value(row_data, "Chat Schätzung Umsatz"))
umsatz_consistency = evaluate_umsatz_chatgpt_consistency(self._get_cell_value(row_data, "CRM Umsatz"), final_wiki_data.get('umsatz', 'k.A.'), estimated_umsatz_value_for_consistency)
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[umsatz_consistency.get('justification', 'Fehler')]]})
# --- 5. ML Schätzung Servicetechniker (AU) ---
# Dieses sollte ein separater Prozess sein, der NACHDEM die Inputs (W, AV, AW) verfügbar sind, läuft.
# Also NICHT hier in _process_single_row.
# --- 6. Abschließende Updates ---
# Version wird gesetzt, wenn IRGENDEINE Verarbeitung in dieser Zeile stattgefunden hat
if any_processing_done:
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Version"] + 1)}{row_num_in_sheet}', 'values': [[Config.VERSION]]})
# --- 7. Batch Update für diese Zeile ---
if updates:
logging.info(f"Zeile {row_num_in_sheet}: Sende Batch-Update mit {len(updates)} Operationen für diese Zeile...")
success = self.sheet_handler.batch_update_cells(updates) # Nutze self.sheet_handler
if not success: logging.error(f"Zeile {row_num_in_sheet}: FEHLER beim Batch-Update.")
else:
if not any_processing_done:
logging.info(f"Zeile {row_num_in_sheet}: Keine Updates zum Schreiben (alle relevanten Schritte übersprungen).")
logging.info(f"--- Verarbeitung für Zeile {row_num_in_sheet} abgeschlossen ---")
# Kleine Pause nach der Verarbeitung jeder Zeile, um API-Limits zu respektieren
time.sleep(max(0.1, getattr(Config, 'RETRY_DELAY', 5) / 20))
# --- Private Helfer für Timestamp/Status Checks ---
# Diese werden von _process_single_row aufgerufen.
# Stellen Sie sicher, dass diese Methoden IN der Klasse DataProcessor sind.
def _get_cell_value(self, row_data, key): # Implementierung wurde oben kopiert, muss hier in die Klasse
"""Lokale Hilfsfunktion zum sicheren Zugriff auf Zellwerte innerhalb von Methoden, die row_data als Parameter erhalten."""
idx = COLUMN_MAP.get(key)
if idx is not None and len(row_data) > idx:
return row_data[idx] if row_data[idx] is not None else ''
return ""
def _is_step_processing_needed(self, row_data, step_key, force_reeval, related_inputs_updated=False): # Implementierung wurde oben kopiert, muss hier in die Klasse
"""
Prüft, ob ein spezifischer Verarbeitungsschritt für diese Zeile ausgeführt werden soll,
basierend auf Timestamp, force_reeval und ob Eingangsdaten aktualisiert wurden.
"""
if force_reeval: return True
if step_key is None: return related_inputs_updated # Ohne Timestamp, nur wenn Inputs neu
timestamp_col_index = COLUMN_MAP.get(step_key)
if timestamp_col_index is None: logging.error(f" -> Step Check Fehler: Timestamp Schlüssel '{step_key}' nicht in COLUMN_MAP gefunden."); return False
ts_value = row_data[timestamp_col_index] if len(row_data) > timestamp_col_index else ""
ts_is_set = bool(str(ts_value).strip())
needs_processing = not ts_is_set or related_inputs_updated
return needs_processing
# _is_wiki_search_extract_needed Helfer (spezifisch für AN & S='X (URL Copied)')
def _is_wiki_search_extract_needed(self, row_data, force_reeval): # related_inputs_updated hier nicht relevant
"""Prüft, ob Wikipedia Search/Extraction nötig ist (AN Timestamp oder S='X (URL Copied)' oder force_reeval)."""
# Wiki Search/Extraction ist nötig, wenn: force_reeval ODER AN fehlt ODER S='X (URL Copied)'
# Nutze private Helfermethode _get_cell_value
wiki_ts_an_missing = not self._get_cell_value(row_data, "Wikipedia Timestamp").strip()
status_s_indicates_reparse = self._get_cell_value(row_data, "Chat Wiki Konsistenzprüfung").strip().upper() == "X (URL COPIED)"
return force_reeval or wiki_ts_an_missing or status_s_indicates_reparse
# _is_wiki_verification_needed Helfer (spezifisch für AX)
def _is_wiki_verification_needed(self, row_data, force_reeval, wiki_data_updated_in_this_run): # Abhängig von wiki_data_updated_in_this_run
"""Prüft, ob Wikipedia Verifizierung nötig ist (AX Timestamp oder Wiki Daten aktualisiert)."""
# Wiki Verifizierung (S-U, AX) ist nötig, wenn: force_reeval ODER AX fehlt ODER Wiki Daten gerade aktualisiert wurden
return self._is_step_processing_needed(row_data, "Wiki Verif. Timestamp", force_reeval, wiki_data_updated_in_this_run)
# _is_branch_evaluation_needed Helfer (spezifisch für AO)
def _is_branch_evaluation_needed(self, row_data, force_reeval, inputs_updated_in_this_run): # Abhängig von wiki_data_updated_in_this_run ODER website_data_updated_in_this_run
"""Prüft, ob Branch Evaluation nötig ist (AO Timestamp oder Inputs (Wiki/Web) aktualisiert)."""
# Branch Evaluation ist nötig, wenn: force_reeval ODER AO fehlt ODER Inputs (Wiki/Web) wurden aktualisiert
return self._is_step_processing_needed(row_data, "Timestamp letzte Prüfung", force_reeval, inputs_updated_in_this_run)
# Fügen Sie hier weitere _is_xxx_needed Methoden für andere Schritte hinzu (FSM, MA, Umsatz Schätzung)
# Diese prüfen jeweils ihren spezifischen Trigger (eigenen Timestamp ODER Inputs).
# Z.B. FSM hat keinen eigenen TS, wird getriggert wenn Branch Eval inputs (Wiki/Web) aktualisiert ODER Branch Eval selbst neu gemacht wurde.
# --- Methode für den Re-Eval Modus ---
# Diese Methode gehört in die Klasse
def process_reevaluation_rows(self, row_limit=None, clear_flag=True,
process_wiki_steps=True, # <-- Flags als Parameter
process_chatgpt_steps=True, # <-- Flags als Parameter
process_website_steps=True): # <-- Flags als Parameter
"""
Verarbeitet nur Zeilen, die in Spalte A mit 'x' markiert sind.
Ruft _process_single_row für jede dieser Zeilen auf mit force_reeval=True.
Verarbeitet maximal row_limit Zeilen.
Löscht optional das 'x'-Flag nach erfolgreicher Verarbeitung.
Erlaubt die Auswahl spezifischer Verarbeitungsschritte.
Args:
row_limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None.
clear_flag (bool, optional): Flag 'x' nach erfolgreicher Verarbeitung löschen. Defaults to True.
process_wiki_steps (bool, optional): Soll die Gruppe Wiki-Schritte ausgeführt werden?. Defaults to True.
process_chatgpt_steps (bool, optional): Soll die Gruppe ChatGPT-Schritte ausgeführt werden?. Defaults to True.
process_website_steps (bool, optional): Soll die Gruppe Website-Schritte ausgeführt werden?. Defaults to True.
"""
logging.info(f"Starte Re-Evaluierungsmodus (Spalte A = 'x'). Max. Zeilen: {row_limit if row_limit is not None else 'Unbegrenzt'}")
selected_steps_log = []
if process_wiki_steps: selected_steps_log.append("Wiki")
if process_chatgpt_steps: selected_steps_log.append("ChatGPT")
if process_website_steps: selected_steps_log.append("Website")
logging.info(f"Ausgewählte Schritte für Re-Eval: {', '.join(selected_steps_log) if selected_steps_log else 'Keine ausgewählt!'} (force_reeval=True)")
if not self.sheet_handler.load_data(): return logging.error("Fehler beim Laden der Daten für Re-Evaluation.")
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = 5
if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten für Re-Evaluation gefunden.")
reeval_col_idx = COLUMN_MAP.get("ReEval Flag")
if reeval_col_idx is None: return logging.critical("FEHLER: 'ReEval Flag' Spaltenindex nicht in COLUMN_MAP gefunden.")
rows_to_process = []
for idx_in_list in range(header_rows, len(all_data)):
row_data = all_data[idx_in_list]
row_num_in_sheet = idx_in_list + 1
if len(row_data) > reeval_col_idx and str(row_data[reeval_col_idx]).strip().lower() == "x":
rows_to_process.append({'row_num': row_num_in_sheet, 'data': row_data})
found_count = len(rows_to_process)
logging.info(f"{found_count} Zeilen mit ReEval-Flag 'x' gefunden.")
if found_count == 0: return logging.info("Keine Zeilen zur Re-Evaluation markiert.")
processed_count = 0
updates_clear_flag = []
rows_actually_processed = []
for task in rows_to_process:
if limit is not None and processed_count >= limit:
logging.info(f"Zeilenlimit ({limit}) für Re-Evaluation erreicht. Breche weitere Verarbeitung ab.")
break
row_num = task['row_num']
row_data = task['data']
try:
# Rufe _process_single_row mit force_reeval=True und den ausgewählten Flags auf
self._process_single_row(
row_num_in_sheet = row_num,
row_data = row_data,
process_wiki = process_wiki_steps, # <<< ÜBERGIBT DIE STEUERUNG
process_chatgpt = process_chatgpt_steps, # <<< ÜBERGIBT DIE STEUERUNG
process_website = process_website_steps, # <<< ÜBERGIBT DIE STEUERUNG
force_reeval = True # <<< BLEIBT HIER TRUE FÜR RE-EVAL MODUS
)
processed_count += 1
rows_actually_processed.append(row_num)
if clear_flag:
flag_col_letter = self.sheet_handler._get_col_letter(reeval_col_idx + 1)
if flag_col_letter: updates_clear_flag.append({'range': f'{flag_col_letter}{row_num}', 'values': [['']]})
else: logging.error(f"Fehler: Konnte Spaltenbuchstaben für 'ReEval Flag' ({reeval_col_idx+1}) nicht ermitteln.")
except Exception as e_proc: logging.exception(f"FEHLER bei Re-Evaluation von Zeile {row_num}: {e_proc}")
if clear_flag and updates_clear_flag:
logging.info(f"Lösche ReEval-Flags für {len(updates_clear_flag)} erfolgreich verarbeitete Zeilen ({rows_actually_processed})...")
success = self.sheet_handler.batch_update_cells(updates_clear_flag)
if success: logging.info("ReEval-Flags erfolgreich gelöscht.")
else: logging.error("FEHLER beim Löschen der ReEval-Flags.")
logging.info(f"Re-Evaluierung abgeschlossen. {processed_count} Zeilen verarbeitet (Limit war: {limit}, Gefunden: {found_count}).")
# --- Methode für sequenzielle Verarbeitung (full_run) ---
# Diese Methode gehört in die Klasse
def process_sequential(self, start_sheet_row, num_to_process,
process_wiki=True, process_chatgpt=True, process_website=True):
"""
Verarbeitet eine feste Anzahl von Zeilen beginnend bei einer bestimmten Sheet-Zeile
sequenziell, eine nach der anderen, unter Verwendung von _process_single_row.
_process_single_row prüft dabei die Timestamps/Status (force_reeval=False).
Args:
start_sheet_row (int): Die 1-basierte Startzeilennummer im Sheet.
num_to_process (int): Die maximale Anzahl der zu verarbeitenden Zeilen.
process_wiki (bool, optional): Soll die Gruppe Wiki-Schritte ausgeführt werden?. Defaults to True.
process_chatgpt (bool, optional): Soll die Gruppe ChatGPT-Schritte ausgeführt werden?. Defaults to True.
process_website (bool, optional): Soll die Gruppe Website-Schritte ausgeführt werden?. Defaults to True.
"""
header_rows = 5 # Annahme
logging.info(f"Starte sequenzielle Verarbeitung von {num_to_process} Zeilen ab Sheet-Zeile {start_sheet_row}...")
groups_to_attempt_log = []
if process_website: groups_to_attempt_log.append("Website")
if process_wiki: groups_to_attempt_log.append("Wiki")
if process_chatgpt: groups_to_attempt_log.append("ChatGPT")
logging.info(f"Ausgewählte Schritte: {', '.join(groups_to_attempt_log) if groups_to_attempt_log else 'Keine ausgewählt!'} (Standard-Timestamp/Status-Logik)")
if not self.sheet_handler.load_data(): logging.error("Fehler beim Laden der Daten für sequenzielle Verarbeitung."); return
all_data = self.sheet_handler.get_all_data_with_headers()
total_sheet_rows = len(all_data)
if start_sheet_row > total_sheet_rows or start_sheet_row <= header_rows:
logging.warning(f"Start-Sheet-Zeile {start_sheet_row} liegt außerhalb des gültigen Datenbereichs ({header_rows+1} bis {total_sheet_rows}). Keine Verarbeitung.")
return
end_sheet_row_inclusive = min(start_sheet_row + num_to_process - 1, total_sheet_rows)
logging.info(f"Sequenzielle Verarbeitung geplant für Sheet-Zeilen {start_sheet_row} bis {end_sheet_row_inclusive}.")
if start_sheet_row > end_sheet_row_inclusive: logging.warning("Start nach Ende (berechnet nach Limit). Keine Verarbeitung."); return
processed_count = 0
for i in range(start_sheet_row, end_sheet_row_inclusive + 1):
row_num_in_sheet = i
row_data = all_data[i - 1]
try:
self._process_single_row(
row_num_in_sheet = row_num_in_sheet,
row_data = row_data,
process_wiki = process_wiki, # <<< ÜBERGIBT DIE STEUERUNG
process_chatgpt = process_chatgpt, # <<< ÜBERGIBT DIE STEUERUNG
process_website = process_website, # <<< ÜBERGIBT DIE STEUERUNG
force_reeval = False # <<< WICHTIG: Standard-Timestamp/Status-Logik
)
processed_count += 1
except Exception as e_proc: logging.exception(f"FEHLER bei sequenzieller Verarbeitung von Zeile {row_num_in_sheet}: {e_proc}")
logging.info(f"Sequenzielle Verarbeitung abgeschlossen. {processed_count} Zeilen bearbeitet im Bereich [{start_sheet_row}, {end_sheet_row_inclusive}].")
# --- Methode zum Prozessieren von Zeilen nach Kriterien (NEU) ---
# Diese Methode gehört in die Klasse
def process_rows_matching_criteria(self, criteria_func, limit=None,
process_wiki=True, process_chatgpt=True, process_website=True,
force_step_reeval=False):
"""
Sucht Zeilen im Sheet, die ein gegebenes Kriterium erfüllen (definiert durch criteria_func).
Verarbeitet eine begrenzte Anzahl dieser passenden Zeilen unter Verwendung von _process_single_row.
Args:
criteria_func (callable): Eine Funktion, die eine Zeile (list) nimmt und True zurückgibt, wenn das Kriterium erfüllt ist.
limit (int, optional): Maximale Anzahl passender Zeilen, die verarbeitet werden sollen. Defaults to None (alle passenden).
process_wiki (bool, optional): Soll die Gruppe Wiki-Schritte ausgeführt werden?. Defaults to True.
process_chatgpt (bool, optional): Soll die Gruppe ChatGPT-Schritte ausgeführt werden?. Defaults to True.
process_website (bool, optional): Soll die Gruppe Website-Schritte ausgeführt werden?. Defaults to True.
force_step_reeval (bool, optional): Bestimmt, ob _process_single_row mit force_reeval=True aufgerufen wird (ignoriert Timestamps für ausgewählte Schritte). Defaults to False.
"""
logging.info(f"Starte Verarbeitung von Zeilen nach Kriterien. Limit: {limit if limit is not None else 'Unbegrenzt'}")
logging.info(f"Verwendetes Kriterium: {criteria_func.__name__}")
groups_to_attempt_log = []
if process_website: groups_to_attempt_log.append("Website")
if process_wiki: groups_to_attempt_log.append("Wiki")
if process_chatgpt: groups_to_attempt_log.append("ChatGPT")
logging.info(f"Ausgewählte Schritte: {', '.join(groups_to_attempt_log) if groups_to_attempt_log else 'Keine ausgewählt!'}")
logging.info(f"force_reeval für Schritte: {force_step_reeval}")
if not self.sheet_handler.load_data(): logging.error("Fehler beim Laden der Daten für kriterienbasierte Verarbeitung."); return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = 5
if not all_data or len(all_data) <= header_rows: logging.warning("Keine Daten für kriterienbasierte Verarbeitung gefunden."); return
rows_to_process = []
logging.info("Suche nach Zeilen, die dem Kriterium entsprechen...")
for idx_in_list in range(header_rows, len(all_data)):
row_data = all_data[idx_in_list]
row_num_in_sheet = idx_in_list + 1
try:
if criteria_func(row_data): # Nutze die globale Kriterien-Funktion
rows_to_process.append({'row_num': row_num_in_sheet, 'data': row_data})
except Exception as e_crit: logging.error(f"FEHLER beim Prüfen des Kriteriums für Zeile {row_num_in_sheet}: {e_crit}");
found_count = len(rows_to_process)
logging.info(f"{found_count} Zeilen entsprechen dem Kriterium '{criteria_func.__name__}'.")
if found_count == 0: logging.info("Keine Zeilen gefunden, die dem Kriterium entsprechen."); return
processed_count = 0
for task in rows_to_process:
if limit is not None and processed_count >= limit:
logging.info(f"Limit ({limit}) für kriterienbasierte Verarbeitung erreicht. Breche weitere Verarbeitung ab.")
break
row_num = task['row_num']
row_data = task['data']
try:
self._process_single_row(
row_num_in_sheet = row_num,
row_data = row_data,
process_wiki = process_wiki, # <<< ÜBERGIBT DIE STEUERUNG
process_chatgpt = process_chatgpt, # <<< ÜBERGIBT DIE STEUERUNG
process_website = process_website, # <<< ÜBERGIBT DIE STEUERUNG
force_reeval = force_step_reeval # <<< Bestimmt, ob Timestamps ignoriert werden
)
processed_count += 1
except Exception as e_proc: logging.exception(f"FEHLER bei Verarbeitung einer Kriterium-Zeile ({row_num}): {e_proc}")
logging.info(f"Kriterienbasierte Verarbeitung abgeschlossen. {processed_count} von {found_count} gefundenen Zeilen bearbeitet (Limit war: {limit}).")
# --- Batch-Verarbeitung Methoden (Werden von run_batch_dispatcher aufgerufen) ---
# Diese Methoden führen eine spezifische Aufgabe für einen Batch aus, basierend auf einem Timestamp.
# Sie rufen NICHT _process_single_row auf.
def process_verification_batch(self, limit=None):
"""
Batch-Prozess NUR für Wikipedia-Verifizierung (Spalten S-U, AX).
Findet Startzeile ab erster Zelle mit leerem AX.
"""
logging.info(f"Starte Wikipedia-Verifizierungs-Batch. Limit: {limit if limit is not None else 'Unbegrenzt'}")
if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.")
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = 5
if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten gefunden.")
timestamp_col_key = "Wiki Verif. Timestamp"; timestamp_col_index = COLUMN_MAP.get(timestamp_col_key)
if timestamp_col_index is None: return logging.critical(f"FEHLER: Schlüssel '{timestamp_col_key}' nicht in COLUMN_MAP gefunden.")
ts_col_letter = self.sheet_handler._get_col_letter(timestamp_col_index + 1)
start_data_index = self.sheet_handler.get_start_row_index(check_column_key=timestamp_col_key, min_sheet_row=header_rows + 1)
if start_data_index == -1: return logging.error(f"FEHLER bei Startzeilensuche auf Spalte '{timestamp_col_key}'.")
if start_data_index >= len(self.sheet_handler.get_data()): return logging.info("Alle Zeilen mit Timestamp gefüllt. Nichts zu tun.")
start_sheet_row = start_data_index + header_rows + 1
total_sheet_rows = len(all_data)
end_sheet_row = total_sheet_rows # Default bis Ende
if limit is not None and limit >= 0:
end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows)
if limit == 0: return logging.info("Limit 0.")
if start_sheet_row > end_sheet_row: logging.warning("Start nach Ende (Limit)."); return
logging.info(f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} für Wiki Verifizierung (Batch).")
batch_size = Config.BATCH_SIZE
current_batch = []
current_row_numbers = []
processed_count = 0
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1
row = all_data[row_index_in_list]
# Vorbereitung für den Prompt (Daten holen)
company_name = self._get_cell_value(row, "CRM Name")
crm_desc = self._get_cell_value(row, "CRM Beschreibung")
wiki_url = self._get_cell_value(row, "Wiki URL")
wiki_paragraph = self._get_cell_value(row, "Wiki Absatz")
wiki_categories = self._get_cell_value(row, "Wiki Kategorien")
# Füge nur hinzu, wenn relevante Wiki-Daten da sind ODER URL existiert
if wiki_url != 'k.A.' or wiki_paragraph != 'k.A.' or wiki_categories != 'k.A.':
entry_text = (
f"Eintrag {i}:\n"
f" Firmenname: {company_name}\n"
f" CRM-Beschreibung: {crm_desc[:200]}...\n"
f" Wikipedia-URL: {wiki_url}\n"
f" Wiki-Absatz: {wiki_paragraph[:200]}...\n"
f" Wiki-Kategorien: {wiki_categories[:200]}...\n"
f"----\n"
)
current_batch.append(entry_text)
current_row_numbers.append(i)
processed_count += 1
if len(current_batch) >= batch_size or i == end_sheet_row:
if current_batch:
# Rufe _process_batch auf (globale Helferfunktion oder private Methode)
# Angenommen, _process_batch ist global definiert
try:
_process_batch(self.sheet_handler.sheet, current_batch, current_row_numbers)
# Setze den AX Timestamp für die bearbeiteten Zeilen, NUR wenn _process_batch nicht exception geworfen hat
wiki_ts_updates = []
current_wiki_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for row_num in current_row_numbers:
wiki_ts_updates.append({'range': f'{ts_col_letter}{row_num}', 'values': [[current_wiki_timestamp]]})
if wiki_ts_updates:
success_ts = self.sheet_handler.batch_update_cells(wiki_ts_updates)
if success_ts: logging.debug(f"Wiki Verif. Timestamp {ts_col_letter} für Batch {current_row_numbers[0]}-{current_row_numbers[-1]} gesetzt.")
else: logging.error(f"FEHLER beim Setzen des Wiki Verif. Timestamps {ts_col_letter} für Batch.")
except Exception as e_batch:
logging.error(f"FEHLER bei Verarbeitung von Batch {current_row_numbers[0]}-{current_row_numbers[-1]} in _process_batch: {e_batch}")
# Hier könnten Sie die Zeilen im Sheet markieren, die Fehler hatten
pass # Fahren Sie mit dem nächsten Batch fort
time.sleep(Config.RETRY_DELAY)
current_batch = []
current_row_numbers = []
logging.info(f"Wikipedia-Verifizierungs-Batch abgeschlossen. {processed_count} Zeilen in Batches verarbeitet (aus Bereich {start_sheet_row}-{end_sheet_row}).")
# process_website_batch Methode
def process_website_batch(self, limit=None):
"""
Batch-Prozess NUR für Website-Scraping (Rohtext AR, Timestamp AT).
Findet Startzeile ab erster Zelle mit leerem AT.
"""
logging.info(f"Starte Website-Scraping Batch. Limit: {limit if limit is not None else 'Unbegrenzt'}")
if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.")
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = 5
if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten gefunden.")
rohtext_col_key = "Website Rohtext"; rohtext_col_index = COLUMN_MAP.get(rohtext_col_key)
website_col_idx = COLUMN_MAP.get("CRM Website")
version_col_idx = COLUMN_MAP.get("Version")
timestamp_col_key = "Website Scrape Timestamp"; timestamp_col_index = COLUMN_MAP.get(timestamp_col_key)
if None in [rohtext_col_index, website_col_idx, version_col_idx, timestamp_col_index]: return logging.critical(f"FEHLER: Benötigte Indizes für process_website_batch fehlen.")
rohtext_col_letter = self.sheet_handler._get_col_letter(rohtext_col_index + 1)
version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1)
ts_col_letter = self.sheet_handler._get_col_letter(timestamp_col_index + 1)
start_data_index = self.sheet_handler.get_start_row_index(check_column_key=timestamp_col_key, min_sheet_row=header_rows + 1)
if start_data_index == -1: return logging.error(f"FEHLER bei Startzeilensuche auf Spalte '{timestamp_col_key}'.")
if start_data_index >= len(self.sheet_handler.get_data()): return logging.info("Alle Zeilen mit Timestamp gefüllt. Nichts zu tun.")
start_sheet_row = start_data_index + header_rows + 1
total_sheet_rows = len(all_data)
end_sheet_row = total_sheet_rows # Default bis Ende
if limit is not None and limit >= 0:
end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows)
if limit == 0: return logging.info("Limit 0.")
if start_sheet_row > end_sheet_row: logging.warning("Start nach Ende (Limit)."); return
logging.info(f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} für Website Scraping (Batch).")
# Worker-Funktion für Scraping (Kann global bleiben oder private statische Methode)
# Bleibt global, da sie keine self benötigt.
def scrape_raw_text_task(task_info):
row_num = task_info['row_num']; url = task_info['url']; raw_text = "k.A."; error = None
try: raw_text = get_website_raw(url) # Annahme: get_website_raw ist global mit Retry
except Exception as e: error = f"Scraping Fehler Zeile {row_num}: {e}"; logging.error(error) # Logge Fehler im Worker
return {"row_num": row_num, "raw_text": raw_text, "error": error}
tasks_for_processing_batch = []
all_sheet_updates = []
processed_count = 0 # Zählt Zeilen, für die Task erstellt wird
skipped_url_count = 0
processing_batch_size = Config.PROCESSING_BATCH_SIZE
max_scraping_workers = Config.MAX_SCRAPING_WORKERS
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1
row = all_data[row_index_in_list]
# URL Prüfung (immer nötig, auch wenn AT fehlt)
website_url = row[website_col_idx] if len(row) > website_col_idx else ""
if not website_url or website_url.strip().lower() == "k.A.":
skipped_url_count += 1
continue
# Kein AT Timestamp -> Task erstellen
tasks_for_processing_batch.append({"row_num": i, "url": website_url})
processed_count += 1
# Verarbeitungs-Batch ausführen
if len(tasks_for_processing_batch) >= processing_batch_size or i == end_sheet_row:
if tasks_for_processing_batch:
batch_start_row = tasks_for_processing_batch[0]['row_num']
batch_end_row = tasks_for_processing_batch[-1]['row_num']
batch_task_count = len(tasks_for_processing_batch)
logging.info(f"\n--- Starte Scraping-Batch ({batch_task_count} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---")
scraping_results = {} # {'row_num': raw_text}
batch_error_count = 0
logging.info(f" Scrape {batch_task_count} Websites parallel (max {max_scraping_workers} worker)...")
with concurrent.futures.ThreadPoolExecutor(max_workers=max_scraping_workers) as executor:
future_to_task = {executor.submit(scrape_raw_text_task, task): task for task in tasks_for_processing_batch}
for future in concurrent.futures.as_completed(future_to_task):
task = future_to_task[future]
try:
result = future.result()
scraping_results[result['row_num']] = result['raw_text']
if result['error']: batch_error_count += 1
except Exception as exc:
row_num = task['row_num']
err_msg = f"Generischer Fehler Scraping Task Zeile {row_num}: {exc}"
logging.error(err_msg)
scraping_results[row_num] = "k.A. (Fehler)"
batch_error_count += 1
logging.info(f" Scraping für Batch beendet. {len(scraping_results)} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).")
# Sheet Updates vorbereiten (AR und AT)
if scraping_results:
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
batch_sheet_updates = []
for row_num, raw_text_res in scraping_results.items():
batch_sheet_updates.extend([
{'range': f'{rohtext_col_letter}{row_num}', 'values': [[raw_text_res]]},
{'range': f'{ts_col_letter}{row_num}', 'values': [[current_timestamp]]} # Setze AT Timestamp
])
all_sheet_updates.extend(batch_sheet_updates)
# Sheet Updates senden für diesen Batch
if all_sheet_updates:
logging.info(f" Sende Sheet-Update für {len(all_sheet_updates)} Zellen für Batch {batch_start_row}-{batch_end_row}...")
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
if success: logging.info(f" Sheet-Update erfolgreich.")
else: logging.error(f" FEHLER beim Sheet-Update.")
all_sheet_updates = [] # Zurücksetzen nach Senden
# Pause nach jedem Batch
logging.debug(" Warte nach Batch...")
time.sleep(Config.RETRY_DELAY)
# Finaler Sheet Update Batch senden (falls Reste übrig)
if all_sheet_updates:
logging.info(f"Sende finalen Sheet-Update ({len(all_sheet_updates)} Zellen)...")
self.sheet_handler.batch_update_cells(all_sheet_updates)
logging.info(f"Website-Scraping Batch abgeschlossen. {processed_count} Tasks erstellt, {skipped_url_count} Zeilen ohne URL übersprungen.")
# process_summarization_batch Methode
def process_summarization_batch(self, limit=None):
"""
Batch-Prozess NUR für Website-Zusammenfassung (AS).
Findet Startzeile ab erster Zelle mit leerem AS, wo AR gefüllt ist.
"""
logging.info(f"Starte Website-Zusammenfassung Batch. Limit: {limit if limit is not None else 'Unbegrenzt'}")
if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.")
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = 5
if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten gefunden.")
rohtext_col_idx = COLUMN_MAP.get("Website Rohtext")
summary_col_idx = COLUMN_MAP.get("Website Zusammenfassung")
version_col_idx = COLUMN_MAP.get("Version")
if None in [rohtext_col_idx, summary_col_idx, version_col_idx]: return logging.critical(f"FEHLER: Benötigte Indizes fehlen.")
summary_col_letter = self.sheet_handler._get_col_letter(summary_col_idx + 1)
version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1)
# Finde die Startzeile: Erste Zelle mit leerem AS UND gefülltem AR
# Dies erfordert ein manuelles Scannen, da get_start_row_index nur eine Spalte prüft
start_sheet_row = header_rows + 1 # Starte nach Headern
logging.info(f"Suche Startzeile für Zusammenfassungs-Batch (leeres AS, gefülltes AR)...")
found_start_row = None
for i in range(header_rows, len(all_data)):
row = all_data[i]
row_num_in_sheet = i + 1
# Sicherstellen, dass Zeile lang genug ist
if len(row) <= max(rohtext_col_idx, summary_col_idx): continue
ar_value = str(row[rohtext_col_idx]).strip()
as_value = str(row[summary_col_idx]).strip()
# Kriterium: AS ist leer UND AR ist gefüllt (nicht k.A. Varianten)
ar_is_filled = bool(ar_value) and ar_value.lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]
as_is_empty = not bool(as_value)
if ar_is_filled and as_is_empty:
found_start_row = row_num_in_sheet
logging.info(f"Startzeile für Zusammenfassungs-Batch gefunden: {found_start_row} (Index {i}).")
break
if found_start_row is None:
logging.info("Keine Zeilen gefunden, die eine Zusammenfassung benötigen (leeres AS, gefülltes AR).")
return # Nichts zu tun
start_sheet_row = found_start_row
total_sheet_rows = len(all_data)
end_sheet_row = total_sheet_rows # Default bis Ende
if limit is not None and limit >= 0:
end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows)
if limit == 0: return logging.info("Limit 0.")
if start_sheet_row > end_sheet_row: logging.warning("Start nach Ende (Limit)."); return
logging.info(f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} für Website Zusammenfassung (Batch).")
tasks_for_openai_batch = []
all_sheet_updates = []
processed_count = 0 # Zählt Zeilen, für die Task erstellt wird
openai_batch_size = Config.OPENAI_BATCH_SIZE_LIMIT
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1
row = all_data[row_index_in_list]
# Erneute Prüfung (nur zur Sicherheit): Ist AS noch leer und AR gefüllt? (Daten könnten sich geändert haben)
if len(row) <= max(rohtext_col_idx, summary_col_idx): continue # Zeile zu kurz
ar_value = str(row[rohtext_col_idx]).strip()
as_value = str(row[summary_col_idx]).strip()
ar_is_filled = bool(ar_value) and ar_value.lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]
as_is_empty = not bool(as_value)
if not (ar_is_filled and as_is_empty):
# Diese Zeile wurde von get_start_row_index gefunden, aber das Kriterium passt nicht mehr (z.B. manuell bearbeitet)
logging.debug(f"Zeile {i}: Kriterium (leeres AS, gefülltes AR) passt nicht mehr, übersprungen.")
continue
# Task hinzufügen
tasks_for_openai_batch.append({'row_num': i, 'raw_text': ar_value}) # Füge den Rohtext hinzu
processed_count += 1
# OpenAI Batch verarbeiten, wenn voll oder letzte Zeile
if tasks_for_openai_batch and (len(tasks_for_openai_batch) >= openai_batch_size or i == end_sheet_row):
debug_print(f" Verarbeite OpenAI Batch für {len(tasks_for_openai_batch)} Aufgaben (Start: {tasks_for_openai_batch[0]['row_num']})...")
# summarize_batch_openai ist global (oder private helper)
try:
summaries_result = summarize_batch_openai(tasks_for_openai_batch)
# Sheet Updates für diesen OpenAI Batch vorbereiten
current_version = Config.VERSION
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Timestamp für AS? Oder AT nutzen?
# AT ist für Scraping. AS könnte eigenen Timestamp bekommen, oder AO/AP nutzen.
# Belassen wir es bei AS + AP Update.
for task in tasks_for_openai_batch:
row_num = task['row_num']
summary = summaries_result.get(row_num, "k.A. (Fehler Batch Zuordnung)")
batch_sheet_updates = [
{'range': f'{summary_col_letter}{row_num}', 'values': [[summary]]},
# Version AP wird in _process_single_row gesetzt. Batch Modi setzen AP nicht.
# Das ist eine Inkonsistenz. AP sollte von jedem Batch Modus gesetzt werden.
# {'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]} # Setze AP hier
]
all_sheet_updates.extend(batch_sheet_updates)
# Sheet Updates senden für diesen OpenAI Batch
if all_sheet_updates:
logging.info(f" Sende Sheet-Update für {len(tasks_for_openai_batch)} Zusammenfassungen ({len(all_sheet_updates)} Zellen)...")
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
if success: logging.info(f" Sheet-Update erfolgreich.")
else: logging.error(f" FEHLER beim Sheet-Update.")
all_sheet_updates = [] # Zurücksetzen nach Senden
except Exception as e_batch:
logging.error(f"FEHLER bei Verarbeitung von OpenAI Batch {tasks_for_openai_batch[0]['row_num']}-{tasks_for_openai_batch[-1]['row_num']}: {e_batch}")
# Fehler markieren? Oder einfach weitermachen? Pass.
tasks_for_openai_batch = [] # OpenAI Batch leeren
# Pause nach jedem OpenAI Batch
time.sleep(Config.RETRY_DELAY)
# Finaler Sheet Update Batch senden (falls Reste übrig)
if all_sheet_updates:
logging.info(f"Sende finalen Sheet-Update ({len(all_sheet_updates)} Zellen)...")
self.sheet_handler.batch_update_cells(all_sheet_updates)
logging.info(f"Website-Zusammenfassung Batch abgeschlossen. {processed_count} Tasks erstellt.")
# process_branch_batch Methode
def process_branch_batch(self, limit=None):
"""
Batch-Prozess NUR für Brancheneinschätzung (W-Y, AO).
Findet Startzeile ab erster Zelle mit leerem AO.
"""
logging.info(f"Starte Branchen-Einschätzung Batch. Limit: {limit if limit is not None else 'Unbegrenzt'}")
if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.")
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = 5
if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten gefunden.")
timestamp_col_key = "Timestamp letzte Prüfung"; timestamp_col_index = COLUMN_MAP.get(timestamp_col_key)
branche_crm_idx = COLUMN_MAP.get("CRM Branche"); beschreibung_idx = COLUMN_MAP.get("CRM Beschreibung")
branche_wiki_idx = COLUMN_MAP.get("Wiki Branche"); kategorien_wiki_idx = COLUMN_MAP.get("Wiki Kategorien")
summary_web_idx = COLUMN_MAP.get("Website Zusammenfassung"); version_col_idx = COLUMN_MAP.get("Version")
branch_w_idx = COLUMN_MAP.get("Chat Vorschlag Branche"); branch_x_idx = COLUMN_MAP.get("Chat Konsistenz Branche")
branch_y_idx = COLUMN_MAP.get("Chat Begründung Abweichung Branche")
required_indices = [timestamp_col_index, branche_crm_idx, beschreibung_idx, branche_wiki_idx, kategorien_wiki_idx, summary_web_idx, version_col_idx, branch_w_idx, branch_x_idx, branch_y_idx]
if None in required_indices: return logging.critical(f"FEHLER: Benötigte Indizes fehlen.")
ts_col_letter = self.sheet_handler._get_col_letter(timestamp_col_index + 1)
version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1)
branch_w_letter = self.sheet_handler._get_col_letter(branch_w_idx + 1)
branch_x_letter = self.sheet_handler._get_col_letter(branch_x_idx + 1)
branch_y_letter = self.sheet_handler._get_col_letter(branch_y_idx + 1)
# Finde die Startzeile
start_data_index = self.sheet_handler.get_start_row_index(check_column_key=timestamp_col_key, min_sheet_row=header_rows + 1)
if start_data_index == -1: return logging.error(f"FEHLER bei Startzeilensuche auf Spalte '{timestamp_col_key}'.")
if start_data_index >= len(self.sheet_handler.get_data()): return logging.info("Alle Zeilen mit Timestamp gefüllt. Nichts zu tun.")
start_sheet_row = start_data_index + header_rows + 1
total_sheet_rows = len(all_data)
end_sheet_row = total_sheet_rows # Default bis Ende
if limit is not None and limit >= 0:
end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows)
if limit == 0: return logging.info("Limit 0.")
if start_sheet_row > end_sheet_row: logging.warning("Start nach Ende (Limit)."); return
logging.info(f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} für Branchen-Einschätzung (Batch).")
MAX_BRANCH_WORKERS = Config.MAX_BRANCH_WORKERS
OPENAI_CONCURRENCY_LIMIT = Config.OPENAI_CONCURRENCY_LIMIT
openai_semaphore_branch = threading.Semaphore(OPENAI_CONCURRENCY_LIMIT) # Annahme: threading ist importiert
tasks_for_processing_batch = [] # Liste von Task-Daten für parallele Verarbeitung
processed_count = 0 # Zählt Zeilen, für die Task erstellt wird
if not ALLOWED_TARGET_BRANCHES: load_target_schema();
if not ALLOWED_TARGET_BRANCHES: return logging.critical("FEHLER: Ziel-Schema nicht geladen. Branch Batch nicht möglich.")
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1
row = all_data[row_index_in_list]
# Erneute Prüfung (nur zur Sicherheit): Ist AO noch leer?
if len(row) > timestamp_col_index and str(row[timestamp_col_index]).strip():
logging.debug(f"Zeile {i}: Timestamp {ts_col_letter} ist nicht mehr leer, übersprungen.")
continue
# Task sammeln (Nutze self._get_cell_value)
task_data = {
"row_num": i,
"crm_branche": self._get_cell_value(row, "CRM Branche"),
"beschreibung": self._get_cell_value(row, "CRM Beschreibung"),
"wiki_branche": self._get_cell_value(row, "Wiki Branche"),
"wiki_kategorien": self._get_cell_value(row, "Wiki Kategorien"),
"website_summary": self._get_cell_value(row, "Website Zusammenfassung")
}
tasks_for_processing_batch.append(task_data)
processed_count += 1
# Batch verarbeiten, wenn voll oder letzte Zeile
if len(tasks_for_processing_batch) >= Config.PROCESSING_BRANCH_BATCH_SIZE or i == end_sheet_row:
if tasks_for_processing_batch:
batch_start_row = tasks_for_processing_batch[0]['row_num']
batch_end_row = tasks_for_processing_batch[-1]['row_num']
batch_task_count = len(tasks_for_processing_batch)
logging.info(f"\n--- Starte Branch-Evaluation Batch ({batch_task_count} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---")
results_list = []; batch_error_count = 0
logging.info(f" Evaluiere {batch_task_count} Zeilen parallel (max {MAX_BRANCH_WORKERS} worker, {OPENAI_CONCURRENCY_LIMIT} OpenAI gleichzeitig)...")
# Worker Funktion für Branch Evaluation (muss hier oder global sein)
# Kann private Methode werden, die semaphore nutzt.
# Machen wir sie zu einer privaten Methode.
# Definiere _evaluate_branch_task_worker(self, task_data, semaphore)
# *** BEGINN PARALLELE VERARBEITUNG ***
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_BRANCH_WORKERS) as executor:
# Submit Aufgaben an den Executor
# Passing semaphore to each worker task
future_to_task = {executor.submit(self._evaluate_branch_task_worker, task, openai_semaphore_branch): task for task in tasks_for_processing_batch}
# Warte auf Ergebnisse und sammle sie
for future in concurrent.futures.as_completed(future_to_task):
task = future_to_task[future]
try:
result_data = future.result() # Ergebnis enthält {'row_num': ..., 'result': ..., 'error': ...}
results_list.append(result_data)
if result_data['error']: batch_error_count += 1
except Exception as exc:
# Dies fängt Fehler auf Executor-Ebene ab (sollte selten sein, da Worker Fehler loggt)
row_num = task['row_num']
err_msg = f"Generischer Fehler Branch Task Zeile {row_num}: {exc}"
logging.error(err_msg)
results_list.append({"row_num": row_num, "result": {"branch": "FEHLER", "consistency": "error_task", "justification": err_msg[:500]}, "error": err_msg})
batch_error_count += 1
# *** ENDE PARALLELE VERARBEITUNG ***
logging.info(f" Branch-Evaluation für Batch beendet. {len(results_list)} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).")
# Sheet Updates vorbereiten FÜR DIESEN BATCH
if results_list:
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
current_version = Config.VERSION
batch_sheet_updates = []
# Sortiere Ergebnisse nach Zeilennummer für geordnetes Schreiben (optional)
results_list.sort(key=lambda x: x['row_num'])
for res_data in results_list:
row_num = res_data['row_num']; result = res_data['result']
logging.debug(f" Zeile {row_num}: Ergebnis -> Branch='{result.get('branch')}', Consistency='{result.get('consistency')}', Justification='{result.get('justification', '')[:50]}...'")
batch_sheet_updates.extend([
{'range': f'{branch_w_letter}{row_num}', 'values': [[result.get("branch", "Fehler")]]},
{'range': f'{branch_x_letter}{row_num}', 'values': [[result.get("consistency", "Fehler")]]},
{'range': f'{branch_y_letter}{row_num}', 'values': [[result.get("justification", "Fehler")]]},
{'range': f'{ts_col_letter}{row_num}', 'values': [[current_timestamp]]}, # AO Timestamp setzen
# Version AP sollte auch von Batch Modi gesetzt werden.
{'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]} # Setze AP
])
# Sende Updates für DIESEN Batch SOFORT
if batch_sheet_updates:
logging.info(f" Sende Sheet-Update für {len(results_list)} Zeilen ({len(batch_sheet_updates)} Zellen) für Batch {batch_start_row}-{batch_end_row}...")
success = self.sheet_handler.batch_update_cells(batch_sheet_updates)
if success: logging.info(f" Sheet-Update erfolgreich.")
else: logging.error(f" FEHLER beim Sheet-Update.")
else: logging.debug(f" Keine Sheet-Updates für Batch {batch_start_row}-{batch_end_row} vorbereitet.")
tasks_for_processing_batch = [] # Batch leeren
logging.debug(f"--- Verarbeitungs-Batch {batch_start_row}-{batch_end_row} abgeschlossen ---")
# Kurze Pause NACHDEM ein Batch komplett verarbeitet und geschrieben wurde
logging.debug(" Warte nach Batch...")
time.sleep(Config.RETRY_DELAY)
logging.info(f"Branchen-Einschätzung Batch abgeschlossen. {processed_count} Tasks erstellt.")
# --- Private Worker Methode für Branch Batch Parallelisierung ---
# Diese Methode gehört in die Klasse DataProcessor und wird vom Branch Batch Executor aufgerufen
def _evaluate_branch_task_worker(self, task_data, semaphore):
"""Worker-Funktion für die parallele Branchenevaluation."""
row_num = task_data['row_num']
# evaluate_branche_chatgpt ist global und macht den OpenAI Call mit Retry
# Semaphor steuert die Anzahl gleichzeitiger OpenAI Calls
result = {"branch": "k.A. (Task Fehler)", "consistency": "error_task", "justification": "Fehler im Worker"}; error = None
try:
with semaphore: # Acquire the semaphore
# logging.debug(f" Task {row_num}: Semaphore erhalten.") # Zu laut
result = evaluate_branche_chatgpt(
task_data['crm_branche'], task_data['beschreibung'],
task_data['wiki_branche'], task_data['wiki_kategorien'],
task_data['website_summary']
)
# logging.debug(f" Task {row_num}: evaluate_branche_chatgpt beendet.") # Zu laut
except Exception as e:
error = f"Fehler bei Branchenevaluation Zeile {row_num}: {e}"; logging.error(error)
# Update result dictionary with error info if needed
result['justification'] = (result.get('justification', '') + f" Fehler: {error}")[:500]
result['consistency'] = 'error_task'
return {"row_num": row_num, "result": result, "error": error}
# --- Dienstprogramm Methoden (Werden von run_user_interface aufgerufen) ---
# Diese Methoden führen eine spezifische Aufgabe aus und arbeiten oft über das gesamte Sheet
# oder eine gefilterte Menge.
# process_serp_website_lookup Methode (früher process_serp_website_lookup_for_empty)
def process_serp_website_lookup(self, limit=None): # <<< Methode in DataProcessor
"""
Sucht fehlende Websites (Spalte D ist leer oder "k.A.") via SERP API
(Google Search) und trägt gefundene URLs in Spalte D ein.
Args:
limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None.
"""
logging.info(f"Starte Modus: SERP API Website Lookup für leere Zellen in Spalte D. Limit: {limit if limit is not None else 'Unbegrenzt'}")
if not self.sheet_handler.load_data(): return logging.error("Fehler beim Laden der Daten.")
data_rows = self.sheet_handler.get_data()
header_rows = 5
rows_processed_count = 0
updates = []
try: # Annahme: COLUMN_MAP ist global
website_col_idx = COLUMN_MAP["CRM Website"]; name_col_idx = COLUMN_MAP["CRM Name"]
website_col_letter = self.sheet_handler._get_col_letter(website_col_idx + 1)
# Optional: Timestamp AY für SerpAPI Wiki Suche, um hier nicht Website Suche immer wieder zu machen, wenn Wiki Suche für die Zeile schon fehlschlug.
# Das wird aber komplex. Belassen wir es simpel.
except KeyError as e: logging.critical(f"FEHLER: Benötigte Spalte '{e}' fehlt."); return
except Exception as e: logging.critical(f"FEHLER beim Holen der Spaltenbuchstaben: {e}"); return
for i, row in enumerate(data_rows): # <= for-Schleife beginnt hier
row_num_in_sheet = i + header_rows + 1
if limit is not None and rows_processed_count >= limit:
logging.info(f"Limit ({limit}) für Website Lookup erreicht.")
break
# Sicherstellen, dass die Zeile lang genug ist, um auf die benötigten Spalten zuzugreifen
max_needed_idx = max(website_col_idx, name_col_idx)
if len(row) <= max_needed_idx:
logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz).")
continue # continue gehört unter das if
current_website = row[website_col_idx] if len(row) > website_col_idx else ""
if not current_website or str(current_website).strip().lower() == "k.a.":
company_name = row[name_col_idx] if len(row) > name_col_idx else ""
if not company_name or str(company_name).strip() == "":
logging.warning(f"Zeile {row_num_in_sheet}: Übersprungen (kein Firmenname).")
continue # continue gehört unter das if
logging.info(f"Zeile {row_num_in_sheet}: Suche Website für '{company_name}'...")
new_website = serp_website_lookup(company_name) # Globale Funktion mit Retry
rows_processed_count += 1
if new_website != "k.A.":
updates.append({'range': f'{website_col_letter}{row_num_in_sheet}', 'values': [[new_website]]})
logging.info(f"Zeile {row_num_in_sheet}: Neue Website '{new_website}' gefunden.")
else:
logging.info(f"Zeile {row_num_in_sheet}: Keine Website gefunden.")
time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.3) # Pause nach jedem SERP-Aufruf
# <= Hier endet die for-Schleife. Die folgenden Blöcke müssen auf dieser Ebene (derselben wie for) eingerückt sein.
if updates: # <= DIESER BLOCK muss auf derselben Ebene wie die for-Schleife beginnen
logging.info(f"Sende Batch-Update für {len(updates)} Zellen ({rows_processed_count} Zeilen geprüft)...")
success = self.sheet_handler.batch_update_cells(updates)
if success:
logging.info(f"Batch-Update erfolgreich.")
else: # <= Dieses else gehört zum if success:
logging.error(f"FEHLER beim Batch-Update.")
else: # <= DIESER BLOCK gehört zum if updates:
logging.info("Keine fehlenden Websites gefunden oder keine Updates nötig.")
logging.info(f"Modus 'website_lookup' abgeschlossen. {rows_processed_count} Zeilen geprüft.") # <= Diese Zeile gehört zur Methode, auf derselben Ebene wie das if updates:
# process_find_wiki_serp Methode
def process_find_wiki_serp(self, limit=None, min_employees=500, min_umsatz=200): # <<< Methode in DataProcessor
"""
Sucht fehlende Wikipedia-URLs (Spalte M = k.A.) für Unternehmen mit
(Umsatz CRM > min_umsatz MIO € ODER Mitarbeiter CRM > min_employees)
über SerpAPI und trägt gefundene URLs in Spalte M ein. Setzt ReEval-Flag (A)
und löscht abhängige Wiki-Spalten (N-V, AN, AO, AP, AX).
Merkt sich in Spalte AY, wann die Suche durchgeführt wurde.
Args:
limit (int, optional): Maximale Anzahl zu prüfender Zeilen. Defaults to None.
min_employees (int, optional): Mindestanzahl Mitarbeiter (Spalte K) als Teilfilter. Defaults to 500.
min_umsatz (int, optional): Mindestumsatz in MIO € (Spalte J) als Teilfilter. Defaults to 200.
"""
logging.info(f"Starte Modus 'find_wiki_serp': Suche fehlende Wiki-URLs für Firmen mit (Umsatz CRM > {min_umsatz} MIO € ODER Mitarbeiter CRM > {min_employees})...")
if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.")
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = 5; if not all_data or len(all_data) <= header_rows: logging.warning("Keine Daten gefunden."); return
data_rows = all_data[header_rows:]
col_indices = {} # Annahme: COLUMN_MAP ist global
required_keys = [ "ReEval Flag", "CRM Anzahl Mitarbeiter", "CRM Umsatz", "Wiki URL", "CRM Name", "CRM Website", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Chat Wiki Konsistenzprüfung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", "Begründung bei Abweichung", "Wikipedia Timestamp", "Timestamp letzte Prüfung", "Version", "Wiki Verif. Timestamp", "SerpAPI Wiki Search Timestamp" ]
all_keys_found = True; for key in required_keys: idx = COLUMN_MAP.get(key); col_indices[key] = idx; if idx is None: logging.critical(f"FEHLER: Schlüssel '{key}' fehlt! Modus abgebrochen."); all_keys_found = False
if not all_keys_found: return
col_letters = {key: self.sheet_handler._get_col_letter(idx + 1) for key, idx in col_indices.items()}
all_sheet_updates = []; processed_rows_count = 0; found_urls_count = 0; skipped_timestamp_ay_count = 0; skipped_size_count = 0; skipped_m_filled_count = 0
now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for idx, row in enumerate(data_rows):
row_num_in_sheet = idx + header_rows + 1
if limit is not None and processed_rows_count >= limit: logging.info(f"Limit ({limit}) erreicht."); break
max_needed_idx = max(col_indices.values()); if len(row) <= max_needed_idx: logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz)."); continue
ts_ay_val = row[col_indices["SerpAPI Wiki Search Timestamp"]]; if ts_ay_val and ts_ay_val.strip(): skipped_timestamp_ay_count += 1; continue
m_value = row[col_indices["Wiki URL"]]; if m_value and str(m_value).strip().lower() not in ["k.a.", "kein artikel gefunden"]: skipped_m_filled_count += 1; continue
umsatz_val_str = row[col_indices["CRM Umsatz"]]; ma_val_str = row[col_indices["CRM Anzahl Mitarbeiter"]]
umsatz_val_mio = get_numeric_filter_value(umsatz_val_str, is_umsatz=True) # Globale Funktion
ma_val_num = get_numeric_filter_value(ma_val_str, is_umsatz=False) # Globale Funktion
if not (umsatz_val_mio > min_umsatz or ma_val_num > min_employees):
logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Größe nicht ausreichend. Umsatz (Mio): {umsatz_val_mio:.2f}, MA: {ma_val_num}). Schwellen: Umsatz > {min_umsatz} Mio, MA > {min_employees}.")
skipped_size_count += 1; continue
company_name = row[col_indices["CRM Name"]]; if not company_name or str(company_name).strip() == "": logging.warning(f"Zeile {row_num_in_sheet}: Übersprungen, kein Firmenname."); ay_col_letter = col_letters["SerpAPI Wiki Search Timestamp"]; all_sheet_updates.append({'range': f'{ay_col_letter}{row_num_in_sheet}', 'values': [[now_timestamp_str]]}); continue
logging.info(f"Zeile {row_num_in_sheet}: Suche Wiki-URL für '{company_name}' (Umsatz (Mio): {umsatz_val_mio:.2f}, MA: {ma_val_num})...")
processed_rows_count += 1
website_url = row[col_indices["CRM Website"]] if col_indices["CRM Website"] is not None and len(row) > col_indices["CRM Website"] else None
wiki_url_found = serp_wikipedia_lookup(company_name, website=website_url) # Globale Funktion mit Retry
ay_col_letter = col_letters["SerpAPI Wiki Search Timestamp"]; all_sheet_updates.append({'range': f'{ay_col_letter}{row_num_in_sheet}', 'values': [[now_timestamp_str]]})
if wiki_url_found and wiki_url_found.strip() and wiki_url_found != "k.A.":
logging.info(f" -> URL gefunden: {wiki_url_found}. Bereite Update vor.")
found_urls_count += 1; m_l = col_letters["Wiki URL"]; a_l = col_letters["ReEval Flag"]; n_idx = col_indices["Wiki Absatz"]; v_idx = col_indices["Begründung bei Abweichung"]; n_l=self.sheet_handler._get_col_letter(n_idx+1); v_l=self.sheet_handler._get_col_letter(v_idx+1); an_l = col_letters["Wikipedia Timestamp"]; ao_l = col_indices["Timestamp letzte Prüfung"]; ap_l = col_letters["Version"]; ax_l = col_letters["Wiki Verif. Timestamp"]
# Korrektur AO_l war Index, muss Buchstabe sein
ao_idx = COLUMN_MAP.get("Timestamp letzte Prüfung"); ao_l=self.sheet_handler._get_col_letter(ao_idx+1);
all_sheet_updates.extend([
{'range': f'{m_l}{row_num_in_sheet}', 'values': [[wiki_url_found]]}, {'range': f'{a_l}{row_num_in_sheet}', 'values': [['x']]},
{'range': f'{n_l}{row_num_in_sheet}:{v_l}{row_num_in_sheet}', 'values': [[''] * (v_idx - n_idx + 1)]},
{'range': f'{an_l}{row_num_in_sheet}', 'values': [['']]}, {'range': f'{ao_l}{row_num_in_sheet}', 'values': [['']]},
{'range': f'{ap_l}{row_num_in_sheet}', 'values': [['']]}, {'range': f'{ax_l}{row_num_in_sheet}', 'values': [['']]}
])
else: logging.info(f" -> Keine Wiki-URL via SerpAPI gefunden.")
time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.3)
if all_sheet_updates:
logging.info(f"Sende Batch-Update für {len(all_sheet_updates)} Zellen ({processed_rows_count} Zeilen geprüft)...")
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
if success: logging.info(f"Sheet-Update erfolgreich.")
else: logging.error(f"FEHLER beim Batch-Update.")
else: logging.info("Keine Updates nötig.")
logging.info(f"Modus 'find_wiki_serp' abgeschlossen. {processed_rows_count} Tasks erstellt, {found_urls_count} URLs gefunden, {skipped_timestamp_ay_count} AY gesetzt, {skipped_size_count} Größe, {skipped_m_filled_count} M gefüllt.")
# process_wiki_updates_from_chatgpt Methode
def process_wiki_updates_from_chatgpt(self, row_limit=None): # <<< Methode in DataProcessor
"""
Identifiziert Zeilen (S nicht OK/Updated/Copied/Invalid), prüft ob U eine *valide* und *andere* Wiki-URL ist.
- Wenn ja: Kopiert U->M, markiert S='X (URL Copied)', U='URL übernommen', löscht TS/Version, setzt ReEval-Flag A.
- Wenn nein (U keine URL, U==M, oder U ungültig): LÖSCHT den Inhalt von U und markiert S als 'X (Invalid Suggestion)'.
Verarbeitet maximal row_limit Zeilen.
"""
logging.info(f"Starte Modus: Wiki-Updates (URL-Validierung & Löschen ungültiger Vorschläge). Limit: {limit if limit is not None else 'Unbegrenzt'}")
if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.")
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = 5; if not all_data or len(all_data) <= header_rows: logging.warning("Keine Daten gefunden."); return
data_rows = all_data[header_rows:]
required_keys = [ "Chat Wiki Konsistenzprüfung", "Chat Vorschlag Wiki Artikel", "Wiki URL", "Wikipedia Timestamp", "Wiki Verif. Timestamp", "Timestamp letzte Prüfung", "Version", "ReEval Flag", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Begründung bei Abweichung" ]
col_indices = {} # Annahme: COLUMN_MAP ist global
all_keys_found = True; for key in required_keys: idx = COLUMN_MAP.get(key); col_indices[key] = idx; if idx is None: logging.critical(f"FEHLER: Schlüssel '{key}' fehlt! Modus abgebrochen."); all_keys_found = False
if not all_keys_found: return
all_sheet_updates = []; processed_rows_count = 0; updated_url_count = 0; cleared_suggestion_count = 0
for idx, row in enumerate(data_rows):
row_num_in_sheet = idx + header_rows + 1
if limit is not None and processed_rows_count >= limit: logging.info(f"Limit ({limit}) erreicht."); break
max_needed_idx = max(col_indices.values()); if len(row) <= max_needed_idx: logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz)."); continue
# Nutze private Helfermethode _get_cell_value
konsistenz_s = self._get_cell_value(row, "Chat Wiki Konsistenzprüfung").strip()
vorschlag_u = self._get_cell_value(row, "Chat Vorschlag Wiki Artikel").strip()
url_m = self._get_cell_value(row, "Wiki URL").strip()
konsistenz_s_upper = konsistenz_s.upper()
is_candidate_for_check = bool(konsistenz_s_upper) and konsistenz_s_upper not in ["OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)", "?"]
if is_candidate_for_check or (konsistenz_s_upper == "?" and not vorschlag_u):
logging.debug(f"Zeile {row_num_in_sheet}: Kandidat für Wiki-Update-Prüfung (Status S = '{konsistenz_s}'). Vorschlag U = '{vorschlag_u}'")
processed_rows_count += 1
is_update_candidate = False; new_url = ""
condition2_u_is_wiki_url = vorschlag_u.lower().startswith(("http://", "https://")) and "wikipedia.org/wiki/" in vorschlag_u.lower()
if condition2_u_is_wiki_url:
new_url = vorschlag_u
condition3_u_differs_m = simple_normalize_url(new_url) != simple_normalize_url(url_m) # Globale Funktion
if condition3_u_differs_m:
logging.debug(f" -> Prüfe Validität der neuen URL: {new_url}...")
try: condition4_u_is_valid = is_valid_wikipedia_article_url(new_url); # Globale Funktion mit Retry
except Exception as e_valid: logging.error(f" -> Fehler bei Validierung der URL '{new_url}': {e_valid}. Behandle als ungültig."); condition4_u_is_valid = False
if condition4_u_is_valid: is_update_candidate = True; logging.debug(f" -> URL '{new_url}' ist ein valider Artikel.")
else: logging.debug(f" -> URL '{new_url}' ist KEIN valider Artikel laut API Check.")
else: logging.debug(f" -> Vorschlag U ist identisch mit URL M.")
else: logging.debug(f" -> Vorschlag U ist keine Wikipedia URL ('{vorschlag_u}').")
if is_update_candidate:
logging.info(f"Zeile {row_num_in_sheet}: Update-Kandidat VALIDIERUNG ERFOLGREICH. Setze ReEval-Flag 'x' und bereite Updates vor für URL: {new_url}")
updated_url_count += 1
m_l=self.sheet_handler._get_col_letter(col_indices["Wiki URL"]+1); s_l=self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"]+1); u_l=self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"]+1)
a_l=self.sheet_handler._get_col_letter(col_indices["ReEval Flag"]+1)
n_idx = col_indices["Wiki Absatz"]; v_idx = col_indices["Begründung bei Abweichung"]; n_l=self.sheet_handler._get_col_letter(n_idx+1); v_l=self.sheet_handler._get_col_letter(v_idx+1)
an_l=self.sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"]+1); ax_l=self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"]+1); ao_l=self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Prüfung"]+1); ap_l=self.sheet_handler._get_col_letter(col_indices["Version"]+1)
all_sheet_updates.extend([
{'range': f'{m_l}{row_num_in_sheet}', 'values': [[new_url]]}, {'range': f'{s_l}{row_num_in_sheet}', 'values': [["X (URL Copied)"]]}, {'range': f'{u_l}{row_num_in_sheet}', 'values': [["URL übernommen"]]},
{'range': f'{n_l}{row_num_in_sheet}:{v_l}{row_num_in_sheet}', 'values': [[''] * (v_idx - n_idx + 1)]},
{'range': f'{an_l}{row_num_in_sheet}', 'values': [['']]}, {'range': f'{ax_l}{row_num_in_sheet}', 'values': [['']]},
{'range': f'{ao_l}{row_num_in_sheet}', 'values': [['']]}, {'range': f'{ap_l}{row_num_in_sheet}', 'values': [['']]},
{'range': f'{a_l}{row_num_in_sheet}', 'values': [["x"]]},
])
else:
logging.info(f"Zeile {row_num_in_sheet}: Vorschlag U ('{vorschlag_u}') ist ungültig/identisch. Lösche U und setze Status S.")
cleared_suggestion_count += 1
s_l=self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"]+1); u_l=self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"]+1)
all_sheet_updates.extend([
{'range': f'{s_l}{row_num_in_sheet}', 'values': [["X (Invalid Suggestion)"]]}, # Neuer Status in S
{'range': f'{u_l}{row_num_in_sheet}', 'values': [[""]]} # Vorschlag U löschen
])
# else: Status war OK, X(Updated), X(Copied), X(Invalid Suggestion) oder leer -> Kein Kandidat
if all_sheet_updates:
logging.info(f"Sende Batch-Update für {processed_rows_count} geprüfte Zeilen ({len(all_sheet_updates)} Zellen)...")
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
if success: logging.info(f"Sheet-Update für Wiki-Updates erfolgreich.")
else: logging.error("FEHLER beim Sheet-Update für Wiki-Updates.")
else: logging.info("Keine Zeilen gefunden, die eine Wiki-URL-Korrektur oder Vorschlagsbereinigung benötigen.")
logging.info(f"Wiki-Updates abgeschlossen. {processed_rows_count} Zeilen geprüft. {updated_url_count} URLs kopiert & für ReEval markiert, {cleared_suggestion_count} ungültige Vorschläge gelöscht/markiert.")
# --- Private Helfer für Timestamp/Status Checks ---
# Diese werden von _process_single_row aufgerufen.
# Stellen Sie sicher, dass diese Methoden IN der Klasse DataProcessor sind.
def _get_cell_value(self, row_data, key):
"""Lokale Hilfsfunktion zum sicheren Zugriff auf Zellwerte innerhalb von Methoden, die row_data als Parameter erhalten."""
idx = COLUMN_MAP.get(key) # Annahme: COLUMN_MAP ist global
if idx is not None and len(row_data) > idx:
return row_data[idx] if row_data[idx] is not None else ''
return ""
def _is_step_processing_needed(self, row_data, step_key, force_reeval, related_inputs_updated=False):
"""
Prüft, ob ein spezifischer Verarbeitungsschritt für diese Zeile ausgeführt werden soll,
basierend auf Timestamp, force_reeval und ob Eingangsdaten aktualisiert wurden.
"""
if force_reeval: return True
if step_key is None: return related_inputs_updated # Ohne Timestamp, nur wenn Inputs neu
timestamp_col_index = COLUMN_MAP.get(step_key)
if timestamp_col_index is None: logging.error(f" -> Step Check Fehler: Timestamp Schlüssel '{step_key}' nicht in COLUMN_MAP gefunden."); return False
ts_value = row_data[timestamp_col_index] if len(row_data) > timestamp_col_index else ""
ts_is_set = bool(str(ts_value).strip())
needs_processing = not ts_is_set or related_inputs_updated
return needs_processing
# _is_wiki_search_extract_needed Helfer (spezifisch für AN & S='X (URL Copied)')
def _is_wiki_search_extract_needed(self, row_data, force_reeval): # related_inputs_updated hier nicht relevant
"""Prüft, ob Wikipedia Search/Extraction nötig ist (AN Timestamp oder S='X (URL Copied)' oder force_reeval)."""
# Wiki Search/Extraction ist nötig, wenn: force_reeval ODER AN fehlt ODER S='X (URL Copied)'
# Nutze private Helfermethode _get_cell_value
wiki_ts_an_missing = not self._get_cell_value(row_data, "Wikipedia Timestamp").strip()
status_s_indicates_reparse = self._get_cell_value(row_data, "Chat Wiki Konsistenzprüfung").strip().upper() == "X (URL COPIED)"
return force_reeval or wiki_ts_an_missing or status_s_indicates_reparse
# _is_wiki_verification_needed Helfer (spezifisch für AX)
def _is_wiki_verification_needed(self, row_data, force_reeval, wiki_data_updated_in_this_run): # Abhängig von wiki_data_updated_in_this_run
"""Prüft, ob Wikipedia Verifizierung nötig ist (AX Timestamp oder Wiki Daten aktualisiert)."""
# Wiki Verifizierung (S-U, AX) ist nötig, wenn: force_reeval ODER AX fehlt ODER Wiki Daten gerade aktualisiert wurden
return self._is_step_processing_needed(row_data, "Wiki Verif. Timestamp", force_reeval, wiki_data_updated_in_this_run)
# _is_branch_evaluation_needed Helfer (spezifisch für AO)
def _is_branch_evaluation_needed(self, row_data, force_reeval, inputs_updated_in_this_run): # Abhängig von wiki_data_updated_in_this_run ODER website_data_updated_in_this_run
"""Prüft, ob Branch Evaluation nötig ist (AO Timestamp oder Inputs (Wiki/Web) aktualisiert)."""
# Branch Evaluation ist nötig, wenn: force_reeval ODER AO fehlt ODER Inputs (Wiki/Web) wurden aktualisiert
return self._is_step_processing_needed(row_data, "Timestamp letzte Prüfung", force_reeval, inputs_updated_in_this_run)
# Fügen Sie hier weitere _is_xxx_needed Methoden für andere Schritte hinzu (FSM, MA, Umsatz Schätzung)
# Diese prüfen jeweils ihren spezifischen Trigger (eigenen Timestamp ODER Inputs).
# Z.B. FSM hat keinen eigenen TS, wird getriggert wenn Branch Eval inputs (Wiki/Web) aktualisiert ODER Branch Eval selbst neu gemacht wurde.
# Implementierung könnte so aussehen:
# def _is_fsm_evaluation_needed(self, row_data, force_reeval, inputs_updated_in_this_run):
# # FSM hat keinen eigenen Timestamp, AO wird für Branch Eval verwendet
# # Wir triggern FSM, wenn Branch Eval triggern würde ODER Inputs aktualisiert wurden
# branch_eval_trigger = self._is_step_processing_needed(row_data, "Timestamp letzte Prüfung", force_reeval, inputs_updated_in_this_run)
# return branch_eval_trigger # FSM wird getriggert, wenn der Branch Step getriggert wird. (Vereinfachung)
# --- Methode für den Re-Eval Modus ---
# 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 die Gruppe Wiki-Schritte ausgeführt werden?. Defaults to True.
process_chatgpt_steps (bool, optional): Soll die Gruppe ChatGPT-Schritte ausgeführt werden?. Defaults to True.
process_website_steps (bool, optional): Soll die Gruppe Website-Schritte ausgeführt werden?. Defaults to True.
"""
logging.info(f"Starte Re-Evaluierungsmodus (Spalte A = 'x'). Max. Zeilen: {row_limit if row_limit is not None else 'Unbegrenzt'}")
selected_steps_log = []
if process_wiki_steps: selected_steps_log.append("Wiki")
if process_chatgpt_steps: selected_steps_log.append("ChatGPT")
if process_website_steps: selected_steps_log.append("Website")
logging.info(f"Ausgewählte Schritte für Re-Eval: {', '.join(selected_steps_log) if selected_steps_log else 'Keine ausgewählt!'} (force_reeval=True)")
if not self.sheet_handler.load_data(): return logging.error("Fehler beim Laden der Daten für Re-Evaluation.")
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = 5
if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten für Re-Evaluation gefunden.")
reeval_col_idx = COLUMN_MAP.get("ReEval Flag") # Annahme: COLUMN_MAP ist global
if reeval_col_idx is None: return logging.critical("FEHLER: 'ReEval Flag' Spaltenindex nicht in COLUMN_MAP gefunden.")
rows_to_process = []
for idx_in_list in range(header_rows, len(all_data)):
row_data = all_data[idx_in_list]
row_num_in_sheet = idx_in_list + 1
# Sicherstellen, dass die Zeile lang genug ist für Spalte A
if len(row_data) > reeval_col_idx and str(row_data[reeval_col_idx]).strip().lower() == "x":
rows_to_process.append({'row_num': row_num_in_sheet, 'data': row_data})
found_count = len(rows_to_process)
logging.info(f"{found_count} Zeilen mit ReEval-Flag 'x' gefunden.")
if found_count == 0: return logging.info("Keine Zeilen zur Re-Evaluation markiert.")
processed_count = 0
updates_clear_flag = []
rows_actually_processed = []
for task in rows_to_process:
if limit is not None and processed_count >= limit:
logging.info(f"Zeilenlimit ({limit}) für Re-Evaluation erreicht. Breche weitere Verarbeitung ab.")
break
row_num = task['row_num']
row_data = task['data']
try:
# Rufe _process_single_row mit force_reeval=True und den ausgewählten Flags auf
self._process_single_row(
row_num_in_sheet = row_num,
row_data = row_data,
process_wiki = process_wiki_steps, # <<< ÜBERGIBT DIE STEUERUNG
process_chatgpt = process_chatgpt_steps, # <<< ÜBERGIBT DIE STEUERUNG
process_website = process_website_steps, # <<< ÜBERGIBT DIE STEUERUNG
force_reeval = True # <<< BLEIBT HIER TRUE FÜR RE-EVAL MODUS
)
processed_count += 1
rows_actually_processed.append(row_num)
if clear_flag:
flag_col_letter = self.sheet_handler._get_col_letter(reeval_col_idx + 1)
if flag_col_letter: updates_clear_flag.append({'range': f'{flag_col_letter}{row_num}', 'values': [['']]})
else: logging.error(f"Fehler: Konnte Spaltenbuchstaben für 'ReEval Flag' ({reeval_col_idx+1}) nicht ermitteln.")
except Exception as e_proc: logging.exception(f"FEHLER bei Re-Evaluation von Zeile {row_num}: {e_proc}")
if clear_flag and updates_clear_flag:
logging.info(f"Lösche ReEval-Flags für {len(updates_clear_flag)} erfolgreich verarbeitete Zeilen ({rows_actually_processed})...")
success = self.sheet_handler.batch_update_cells(updates_clear_flag)
if success: logging.info("ReEval-Flags erfolgreich gelöscht.")
else: logging.error("FEHLER beim Löschen der ReEval-Flags.")
logging.info(f"Re-Evaluierung abgeschlossen. {processed_count} Zeilen verarbeitet (Limit war: {limit}, Gefunden: {found_count}).")
# --- Methode für sequenzielle Verarbeitung (full_run) ---
# Diese Methode gehört in die Klasse DataProcessor
def process_sequential(self, start_sheet_row, num_to_process,
process_wiki=True, process_chatgpt=True, process_website=True):
"""
Verarbeitet eine feste Anzahl von Zeilen beginnend bei einer bestimmten Sheet-Zeile
sequenziell, eine nach der anderen, unter Verwendung von _process_single_row.
_process_single_row prüft dabei die Timestamps/Status (force_reeval=False).
Args:
start_sheet_row (int): Die 1-basierte Startzeilennummer im Sheet.
num_to_process (int): Die maximale Anzahl der zu verarbeitenden Zeilen.
process_wiki (bool, optional): Soll die Gruppe Wiki-Schritte ausgeführt werden?. Defaults to True.
process_chatgpt (bool, optional): Soll die Gruppe ChatGPT-Schritte ausgeführt werden?. Defaults to True.
process_website (bool, optional): Soll die Gruppe Website-Schritte ausgeführt werden?. Defaults to True.
"""
header_rows = 5 # Annahme
logging.info(f"Starte sequenzielle Verarbeitung von {num_to_process} Zeilen ab Sheet-Zeile {start_sheet_row}...")
groups_to_attempt_log = []
if process_website: groups_to_attempt_log.append("Website")
if process_wiki: groups_to_attempt_log.append("Wiki")
if process_chatgpt: groups_to_attempt_log.append("ChatGPT")
logging.info(f"Ausgewählte Schritte: {', '.join(groups_to_attempt_log) if groups_to_attempt_log else 'Keine ausgewählt!'} (Standard-Timestamp/Status-Logik)")
if not self.sheet_handler.load_data(): logging.error("Fehler beim Laden der Daten für sequenzielle Verarbeitung."); return
all_data = self.sheet_handler.get_all_data_with_headers()
total_sheet_rows = len(all_data)
if start_sheet_row > total_sheet_rows or start_sheet_row <= header_rows:
logging.warning(f"Start-Sheet-Zeile {start_sheet_row} liegt außerhalb des gültigen Datenbereichs ({header_rows+1} bis {total_sheet_rows}). Keine Verarbeitung.")
return
end_sheet_row_inclusive = min(start_sheet_row + num_to_process - 1, total_sheet_rows)
logging.info(f"Sequenzielle Verarbeitung geplant für Sheet-Zeilen {start_sheet_row} bis {end_sheet_row_inclusive}.")
if start_sheet_row > end_sheet_row_inclusive: logging.warning("Start nach Ende (berechnet nach Limit). Keine Verarbeitung."); return
processed_count = 0
for i in range(start_sheet_row, end_sheet_row_inclusive + 1):
row_num_in_sheet = i
row_data = all_data[i - 1]
try:
self._process_single_row(
row_num_in_sheet = row_num_in_sheet,
row_data = row_data,
process_wiki = process_wiki, # <<< ÜBERGIBT DIE STEUERUNG
process_chatgpt = process_chatgpt, # <<< ÜBERGIBT DIE STEUERUNG
process_website = process_website, # <<< ÜBERGIBT DIE STEUERUNG
force_reeval = False # <<< WICHTIG: Standard-Timestamp/Status-Logik
)
processed_count += 1
except Exception as e_proc: logging.exception(f"FEHLER bei sequenzieller Verarbeitung von Zeile {row_num_in_sheet}: {e_proc}")
logging.info(f"Sequenzielle Verarbeitung abgeschlossen. {processed_count} Zeilen bearbeitet im Bereich [{start_sheet_row}, {end_sheet_row_inclusive}].")
# --- Methode zum Prozessieren von Zeilen nach Kriterien (NEU) ---
# Diese Methode gehört in die Klasse DataProcessor
def process_rows_matching_criteria(self, criteria_func, limit=None,
process_wiki=True, process_chatgpt=True, process_website=True,
force_step_reeval=False):
"""
Sucht Zeilen im Sheet, die ein gegebenes Kriterium erfüllen (definiert durch criteria_func).
Verarbeitet eine begrenzte Anzahl dieser passenden Zeilen unter Verwendung von _process_single_row.
Args:
criteria_func (callable): Eine Funktion, die eine Zeile (list) nimmt und True zurückgibt, wenn das Kriterium erfüllt ist.
limit (int, optional): Maximale Anzahl passender Zeilen, die verarbeitet werden sollen. Defaults to None (alle passenden).
process_wiki (bool, optional): Soll die Gruppe Wiki-Schritte ausgeführt werden?. Defaults to True.
process_chatgpt (bool, optional): Soll die Gruppe ChatGPT-Schritte ausgeführt werden?. Defaults to True.
process_website (bool, optional): Soll die Gruppe Website-Schritte ausgeführt werden?. Defaults to True.
force_step_reeval (bool, optional): Bestimmt, ob _process_single_row mit force_reeval=True aufgerufen wird (ignoriert Timestamps für ausgewählte Schritte). Defaults to False.
"""
logging.info(f"Starte Verarbeitung von Zeilen nach Kriterien. Limit: {limit if limit is not None else 'Unbegrenzt'}")
logging.info(f"Verwendetes Kriterium: {criteria_func.__name__}")
groups_to_attempt_log = []
if process_website: groups_to_attempt_log.append("Website")
if process_wiki: groups_to_attempt_log.append("Wiki")
if process_chatgpt: groups_to_attempt_log.append("ChatGPT")
logging.info(f"Ausgewählte Schritte: {', '.join(groups_to_attempt_log) if groups_to_attempt_log else 'Keine ausgewählt!'}")
logging.info(f"force_reeval für Schritte: {force_step_reeval}")
if not self.sheet_handler.load_data(): logging.error("Fehler beim Laden der Daten für kriterienbasierte Verarbeitung."); return
all_data = self.sheet_handler.get_all_data_with_headers(); header_rows = 5
if not all_data or len(all_data) <= header_rows: logging.warning("Keine Daten für kriterienbasierte Verarbeitung gefunden."); return
rows_to_process = []
logging.info("Suche nach Zeilen, die dem Kriterium entsprechen...")
for idx_in_list in range(header_rows, len(all_data)):
row_data = all_data[idx_in_list]
row_num_in_sheet = idx_in_list + 1
try:
if criteria_func(row_data): # Nutze die globale Kriterien-Funktion
rows_to_process.append({'row_num': row_num_in_sheet, 'data': row_data})
except Exception as e_crit: logging.error(f"FEHLER beim Prüfen des Kriteriums für Zeile {row_num_in_sheet}: {e_crit}");
found_count = len(rows_to_process)
logging.info(f"{found_count} Zeilen entsprechen dem Kriterium '{criteria_func.__name__}'.")
if found_count == 0: logging.info("Keine Zeilen gefunden, die dem Kriterium entsprechen."); return
processed_count = 0
for task in rows_to_process:
if limit is not None and processed_count >= limit:
logging.info(f"Limit ({limit}) für kriterienbasierte Verarbeitung erreicht. Breche weitere Verarbeitung ab.")
break
row_num = task['row_num']; row_data = task['data']
try:
self._process_single_row(
row_num_in_sheet = row_num,
row_data = row_data,
process_wiki = process_wiki,
process_chatgpt = process_chatgpt,
process_website = process_website,
force_reeval = force_step_reeval
)
processed_count += 1
except Exception as e_proc: logging.exception(f"FEHLER bei Verarbeitung einer Kriterium-Zeile ({row_num}): {e_proc}")
logging.info(f"Kriterienbasierte Verarbeitung abgeschlossen. {processed_count} von {found_count} gefundenen Zeilen bearbeitet (Limit war: {limit}).")
# --- Batch-Verarbeitung Methoden (Werden von run_batch_dispatcher aufgerufen) ---
# Diese Methoden führen eine spezifische Aufgabe für einen Batch aus, basierend auf einem Timestamp.
# Sie rufen NICHT _process_single_row auf.
def process_verification_batch(self, limit=None):
"""
Batch-Prozess NUR für Wikipedia-Verifizierung (Spalten S-U, AX).
Findet Startzeile ab erster Zelle mit leerem AX.
"""
logging.info(f"Starte Wikipedia-Verifizierungs-Batch. Limit: {limit if limit is not None else 'Unbegrenzt'}")
if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.")
all_data = self.sheet_handler.get_all_data_with_headers(); header_rows = 5
if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten gefunden.")
timestamp_col_key = "Wiki Verif. Timestamp"; timestamp_col_index = COLUMN_MAP.get(timestamp_col_key); if timestamp_col_index is None: return logging.critical(f"FEHLER: Schlüssel '{timestamp_col_key}' fehlt.")
ts_col_letter = self.sheet_handler._get_col_letter(timestamp_col_index + 1)
start_data_index = self.sheet_handler.get_start_row_index(check_column_key=timestamp_col_key, min_sheet_row=header_rows + 1); if start_data_index == -1: return logging.error(f"FEHLER bei Startzeilensuche auf Spalte '{timestamp_col_key}'."); if start_data_index >= len(self.sheet_handler.get_data()): logging.info("Alle Zeilen mit Timestamp gefüllt. Nichts zu tun."); return
start_sheet_row = start_data_index + header_rows + 1; total_sheet_rows = len(all_data); end_sheet_row = total_sheet_rows
if limit is not None and limit >= 0: end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows); if limit == 0: logging.info("Limit 0."); return
if start_sheet_row > end_sheet_row: logging.warning("Start nach Ende (Limit)."); return
logging.info(f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} für Wiki Verifizierung (Batch).")
batch_size = Config.BATCH_SIZE; current_batch = []; current_row_numbers = []; processed_count = 0
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1; row = all_data[row_index_in_list]
company_name = self._get_cell_value(row, "CRM Name"); crm_desc = self._get_cell_value(row, "CRM Beschreibung")
wiki_url = self._get_cell_value(row, "Wiki URL"); wiki_paragraph = self._get_cell_value(row, "Wiki Absatz")
wiki_categories = self._get_cell_value(row, "Wiki Kategorien")
if wiki_url != 'k.A.' or wiki_paragraph != 'k.A.' or wiki_categories != 'k.A.':
entry_text = ( f"Eintrag {i}:\n" f" Firmenname: {company_name}\n" f" CRM-Beschreibung: {crm_desc[:200]}...\n" f" Wikipedia-URL: {wiki_url}\n" f" Wiki-Absatz: {wiki_paragraph[:200]}...\n" f" Wiki-Kategorien: {wiki_categories[:200]}...\n" f"----\n" )
current_batch.append(entry_text); current_row_numbers.append(i); processed_count += 1
if len(current_batch) >= batch_size or i == end_sheet_row:
if current_batch:
try: _process_batch(self.sheet_handler.sheet, current_batch, current_row_numbers); # Globale Helferfunktion
wiki_ts_updates = []; current_wiki_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S");
for row_num in current_row_numbers: wiki_ts_updates.append({'range': f'{ts_col_letter}{row_num}', 'values': [[current_wiki_timestamp]]})
if wiki_ts_updates: success_ts = self.sheet_handler.batch_update_cells(wiki_ts_updates); if success_ts: logging.debug(f"Wiki Verif. Timestamp {ts_col_letter} für Batch {current_row_numbers[0]}-{current_row_numbers[-1]} gesetzt."); else: logging.error(f"FEHLER beim Setzen des Wiki Verif. Timestamps {ts_col_letter} für Batch.");
except Exception as e_batch: logging.error(f"FEHLER bei Verarbeitung von Batch {current_row_numbers[0]}-{current_row_numbers[-1]} in _process_batch: {e_batch}"); pass
time.sleep(Config.RETRY_DELAY)
current_batch = []; current_row_numbers = []
logging.info(f"Wikipedia-Verifizierungs-Batch abgeschlossen. {processed_count} Zeilen in Batches verarbeitet.")
# process_website_batch Methode
def process_website_batch(self, limit=None):
"""
Batch-Prozess NUR für Website-Scraping (Rohtext AR, Timestamp AT).
Findet Startzeile ab erster Zelle mit leerem AT.
"""
logging.info(f"Starte Website-Scraping Batch. Limit: {limit if limit is not None else 'Unbegrenzt'}")
if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.")
all_data = self.sheet_handler.get_all_data_with_headers(); header_rows = 5
if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten gefunden.")
rohtext_col_key = "Website Rohtext"; rohtext_col_index = COLUMN_MAP.get(rohtext_col_key)
website_col_idx = COLUMN_MAP.get("CRM Website"); version_col_idx = COLUMN_MAP.get("Version")
timestamp_col_key = "Website Scrape Timestamp"; timestamp_col_index = COLUMN_MAP.get(timestamp_col_key)
if None in [rohtext_col_index, website_col_idx, version_col_idx, timestamp_col_index]: return logging.critical(f"FEHLER: Benötigte Indizes fehlen.");
rohtext_col_letter = self.sheet_handler._get_col_letter(rohtext_col_index + 1); version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1); ts_col_letter = self.sheet_handler._get_col_letter(timestamp_col_index + 1)
start_data_index = self.sheet_handler.get_start_row_index(check_column_key=timestamp_col_key, min_sheet_row=header_rows + 1); if start_data_index == -1: return logging.error(f"FEHLER bei Startzeilensuche."); if start_data_index >= len(self.sheet_handler.get_data()): logging.info("Alle Zeilen mit Timestamp gefüllt. Nichts zu tun."); return
start_sheet_row = start_data_index + header_rows + 1; total_sheet_rows = len(all_data); end_sheet_row = total_sheet_rows
if limit is not None and limit >= 0: end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows); if limit == 0: logging.info("Limit 0."); return
if start_sheet_row > end_sheet_row: logging.warning("Start nach Ende (Limit)."); return
logging.info(f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} für Website Scraping (Batch).")
# Worker-Funktion für Scraping (Globale Helferfunktion)
def scrape_raw_text_task(task_info): # Needs access to get_website_raw (global)
row_num = task_info['row_num']; url = task_info['url']; raw_text = "k.A."; error = None
try: raw_text = get_website_raw(url); # Annahme: get_website_raw ist global mit Retry
except Exception as e: error = f"Scraping Fehler Zeile {row_num}: {e}"; logging.error(error);
return {"row_num": row_num, "raw_text": raw_text, "error": error}
tasks_for_processing_batch = []; all_sheet_updates = []; processed_count = 0; skipped_url_count = 0
processing_batch_size = Config.PROCESSING_BATCH_SIZE; max_scraping_workers = Config.MAX_SCRAPING_WORKERS;
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1; row = all_data[row_index_in_list]
website_url = row[website_col_idx] if len(row) > website_col_idx else ""; if not website_url or website_url.strip().lower() == "k.A.": skipped_url_count += 1; continue
tasks_for_processing_batch.append({"row_num": i, "url": website_url}); processed_count += 1
if len(tasks_for_processing_batch) >= processing_batch_size or i == end_sheet_row:
if tasks_for_processing_batch:
batch_start_row = tasks_for_processing_batch[0]['row_num']; batch_end_row = tasks_for_processing_batch[-1]['row_num']; batch_task_count = len(tasks_for_processing_batch)
logging.info(f"\n--- Starte Scraping-Batch ({batch_task_count} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---")
scraping_results = {}; batch_error_count = 0; logging.info(f" Scrape {batch_task_count} Websites parallel (max {max_scraping_workers} worker)...")
with concurrent.futures.ThreadPoolExecutor(max_workers=max_scraping_workers) as executor:
future_to_task = {executor.submit(scrape_raw_text_task, task): task for task in tasks_for_processing_batch}
for future in concurrent.futures.as_completed(future_to_task):
task = future_to_task[future]; try: result = future.result(); scraping_results[result['row_num']] = result['raw_text']; if result['error']: batch_error_count += 1;
except Exception as exc: row_num = task['row_num']; err_msg = f"Generischer Fehler Scraping Task Zeile {row_num}: {exc}"; logging.error(err_msg); scraping_results[row_num] = "k.A. (Fehler)"; batch_error_count += 1;
logging.info(f" Scraping für Batch beendet. {len(scraping_results)} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).")
if scraping_results:
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S"); batch_sheet_updates = [];
for row_num, raw_text_res in scraping_results.items():
batch_sheet_updates.extend([ {'range': f'{rohtext_col_letter}{row_num}', 'values': [[raw_text_res]]}, {'range': f'{ts_col_letter}{row_num}', 'values': [[current_timestamp]]} ])
all_sheet_updates.extend(batch_sheet_updates);
if all_sheet_updates: logging.info(f" Sende Sheet-Update für {len(all_sheet_updates)} Zellen für Batch {batch_start_row}-{batch_end_row}..."); success = self.sheet_handler.batch_update_cells(all_sheet_updates); if success: logging.info(f" Sheet-Update erfolgreich."); else: logging.error(f" FEHLER beim Sheet-Update."); all_sheet_updates = [];
logging.debug(" Warte nach Batch..."); time.sleep(Config.RETRY_DELAY);
tasks_for_processing_batch = [];
if all_sheet_updates: logging.info(f"Sende finalen Sheet-Update ({len(all_sheet_updates)} Zellen)..."); self.sheet_handler.batch_update_cells(all_sheet_updates);
logging.info(f"Website-Scraping Batch abgeschlossen. {processed_count} Tasks erstellt, {skipped_url_count} Zeilen ohne URL übersprungen.")
# process_summarization_batch Methode
# Kopieren Sie die Logik aus Ihrer globalen process_website_summarization_batch Funktion hierher und passen Sie sie an self an.
# Sie braucht Zugriff auf summarize_batch_openai (global oder private helper).
def process_summarization_batch(self, limit=None):
"""
Batch-Prozess NUR für Website-Zusammenfassung (AS).
Findet Startzeile ab erster Zelle mit leerem AS, wo AR gefüllt ist.
"""
logging.info(f"Starte Website-Zusammenfassung Batch. Limit: {limit if limit is not None else 'Unbegrenzt'}")
if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.")
all_data = self.sheet_handler.get_all_data_with_headers(); header_rows = 5
if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten gefunden.")
rohtext_col_idx = COLUMN_MAP.get("Website Rohtext"); summary_col_idx = COLUMN_MAP.get("Website Zusammenfassung"); version_col_idx = COLUMN_MAP.get("Version");
if None in [rohtext_col_idx, summary_col_idx, version_col_idx]: return logging.critical(f"FEHLER: Benötigte Indizes fehlen.");
summary_col_letter = self.sheet_handler._get_col_letter(summary_col_idx + 1); version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1);
start_sheet_row = header_rows + 1; logging.info(f"Suche Startzeile für Zusammenfassungs-Batch (leeres AS, gefülltes AR)..."); found_start_row = None
for i in range(header_rows, len(all_data)):
row = all_data[i]; row_num_in_sheet = i + 1;
if len(row) <= max(rohtext_col_idx, summary_col_idx): continue;
ar_value = str(row[rohtext_col_idx]).strip(); as_value = str(row[summary_col_idx]).strip();
ar_is_filled = bool(ar_value) and ar_value.lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]; as_is_empty = not bool(as_value);
if ar_is_filled and as_is_empty: found_start_row = row_num_in_sheet; logging.info(f"Startzeile gefunden: {found_start_row}."); break;
if found_start_row is None: logging.info("Keine Zeilen gefunden, die Zusammenfassung benötigen."); return;
start_sheet_row = found_start_row; total_sheet_rows = len(all_data); end_sheet_row = total_sheet_rows;
if limit is not None and limit >= 0: end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows); if limit == 0: logging.info("Limit 0."); return;
if start_sheet_row > end_sheet_row: logging.warning("Start nach Ende (Limit)."); return;
logging.info(f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} für Website Zusammenfassung (Batch).")
tasks_for_openai_batch = []; all_sheet_updates = []; processed_count = 0; openai_batch_size = Config.OPENAI_BATCH_SIZE_LIMIT;
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1; row = all_data[row_index_in_list];
if len(row) <= max(rohtext_col_idx, summary_col_idx): continue;
ar_value = str(row[rohtext_col_idx]).strip(); as_value = str(row[summary_col_idx]).strip();
ar_is_filled = bool(ar_value) and ar_value.lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]; as_is_empty = not bool(as_value);
if not (ar_is_filled and as_is_empty): logging.debug(f"Zeile {i}: Kriterium passt nicht mehr, übersprungen."); continue;
tasks_for_openai_batch.append({'row_num': i, 'raw_text': ar_value}); processed_count += 1;
if tasks_for_openai_batch and (len(tasks_for_openai_batch) >= openai_batch_size or i == end_sheet_row):
debug_print(f" Verarbeite OpenAI Batch für {len(tasks_for_openai_batch)} Aufgaben (Start: {tasks_for_openai_batch[0]['row_num']})...")
try: summaries_result = summarize_batch_openai(tasks_for_openai_batch); # Globale Funktion mit Retry
current_version = Config.VERSION;
for task in tasks_for_openai_batch:
row_num = task['row_num']; summary = summaries_result.get(row_num, "k.A. (Fehler Batch Zuordnung)");
batch_sheet_updates = [ {'range': f'{summary_col_letter}{row_num}', 'values': [[summary]]}, # {'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]} # AP setzen
]; all_sheet_updates.extend(batch_sheet_updates);
if all_sheet_updates: logging.info(f" Sende Sheet-Update für {len(tasks_for_openai_batch)} Zusammenfassungen ({len(all_sheet_updates)} Zellen)..."); success = self.sheet_handler.batch_update_cells(all_sheet_updates); if success: logging.info(f" Sheet-Update erfolgreich."); else: logging.error(f" FEHLER beim Sheet-Update."); all_sheet_updates = [];
except Exception as e_batch: logging.error(f"FEHLER bei Verarbeitung von OpenAI Batch {tasks_for_openai_batch[0]['row_num']}-{tasks_for_openai_batch[-1]['row_num']}: {e_batch}"); pass;
tasks_for_openai_batch = []; time.sleep(Config.RETRY_DELAY);
if all_sheet_updates: logging.info(f"Sende finalen Sheet-Update ({len(all_sheet_updates)} Zellen)..."); self.sheet_handler.batch_update_cells(all_sheet_updates);
logging.info(f"Website-Zusammenfassung Batch abgeschlossen. {processed_count} Tasks erstellt.")
# process_branch_batch Methode
# Kopieren Sie die Logik aus Ihrer globalen process_branch_batch Funktion hierher und passen Sie sie an self an.
# Sie braucht Zugriff auf evaluate_branche_chatgpt (global) und openai_semaphore_branch (global?).
# Das Semaphor sollte eher eine Instanzvariable sein oder an den Worker übergeben werden.
# Machen wir das Semaphor global und übergeben es.
def process_branch_batch(self, limit=None):
"""
Batch-Prozess NUR für Branchen-Einschätzung (W-Y, AO).
Findet Startzeile ab erster Zelle mit leerem AO.
"""
logging.info(f"Starte Branchen-Einschätzung Batch. Limit: {limit if limit is not None else 'Unbegrenzt'}")
if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.")
all_data = self.sheet_handler.get_all_data_with_headers(); header_rows = 5
if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten gefunden.")
timestamp_col_key = "Timestamp letzte Prüfung"; timestamp_col_index = COLUMN_MAP.get(timestamp_col_key); if timestamp_col_index is None: return logging.critical(f"FEHLER: Schlüssel '{timestamp_col_key}' fehlt.")
branche_crm_idx = COLUMN_MAP.get("CRM Branche"); beschreibung_idx = COLUMN_MAP.get("CRM Beschreibung"); branche_wiki_idx = COLUMN_MAP.get("Wiki Branche"); kategorien_wiki_idx = COLUMN_MAP.get("Wiki Kategorien"); summary_web_idx = COLUMN_MAP.get("Website Zusammenfassung"); version_col_idx = COLUMN_MAP.get("Version");
branch_w_idx = COLUMN_MAP.get("Chat Vorschlag Branche"); branch_x_idx = COLUMN_MAP.get("Chat Konsistenz Branche"); branch_y_idx = COLUMN_MAP.get("Chat Begründung Abweichung Branche");
required_indices = [timestamp_col_index, branche_crm_idx, beschreibung_idx, branche_wiki_idx, kategorien_wiki_idx, summary_web_idx, version_col_idx, branch_w_idx, branch_x_idx, branch_y_idx];
if None in required_indices: return logging.critical(f"FEHLER: Benötigte Indizes fehlen.");
ts_col_letter = self.sheet_handler._get_col_letter(timestamp_col_index + 1); version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1);
branch_w_letter = self.sheet_handler._get_col_letter(branch_w_idx + 1); branch_x_letter = self.sheet_handler._get_col_letter(branch_x_idx + 1); branch_y_letter = self.sheet_handler._get_col_letter(branch_y_idx + 1);
start_data_index = self.sheet_handler.get_start_row_index(check_column_key=timestamp_col_key, min_sheet_row=header_rows + 1); if start_data_index == -1: return logging.error(f"FEHLER bei Startzeilensuche."); if start_data_index >= len(self.sheet_handler.get_data()): logging.info("Alle Zeilen mit Timestamp gefüllt. Nichts zu tun."); return;
start_sheet_row = start_data_index + header_rows + 1; total_sheet_rows = len(all_data); end_sheet_row = total_sheet_rows;
if limit is not None and limit >= 0: end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows); if limit == 0: logging.info("Limit 0."); return;
if start_sheet_row > end_sheet_row: logging.warning("Start nach Ende (Limit)."); return;
logging.info(f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} für Branchen-Einschätzung (Batch).")
MAX_BRANCH_WORKERS = Config.MAX_BRANCH_WORKERS; OPENAI_CONCURRENCY_LIMIT = Config.OPENAI_CONCURRENCY_LIMIT;
# Semaphor als globale Variable oder Instanz Variable der Klasse?
# Machen wir es global für Einfachheit in diesem Übergang.
# openai_semaphore_branch = threading.Semaphore(OPENAI_CONCURRENCY_LIMIT) # Annahme: threading ist importiert und Semaphor global
tasks_for_processing_batch = []; processed_count = 0;
if not ALLOWED_TARGET_BRANCHES: load_target_schema();
if not ALLOWED_TARGET_BRANCHES: return logging.critical("FEHLER: Ziel-Schema nicht geladen. Branch Batch nicht möglich.")
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1; row = all_data[row_index_in_list];
if len(row) <= timestamp_col_index or str(row[timestamp_col_index]).strip(): logging.debug(f"Zeile {i}: Timestamp {ts_col_letter} nicht leer, übersprungen."); continue;
task_data = { "row_num": i, "crm_branche": self._get_cell_value(row, "CRM Branche"), "beschreibung": self._get_cell_value(row, "CRM Beschreibung"), "wiki_branche": self._get_cell_value(row, "Wiki Branche"), "wiki_kategorien": self._get_cell_value(row, "Wiki Kategorien"), "website_summary": self._get_cell_value(row, "Website Zusammenfassung") };
tasks_for_processing_batch.append(task_data); processed_count += 1;
if len(tasks_for_processing_batch) >= Config.PROCESSING_BRANCH_BATCH_SIZE or i == end_sheet_row:
if tasks_for_processing_batch:
batch_start_row = tasks_for_processing_batch[0]['row_num']; batch_end_row = tasks_for_processing_batch[-1]['row_num']; batch_task_count = len(tasks_for_processing_batch);
logging.info(f"\n--- Starte Branch-Evaluation Batch ({batch_task_count} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---")
results_list = []; batch_error_count = 0; logging.info(f" Evaluiere {batch_task_count} Zeilen parallel (max {MAX_BRANCH_WORKERS} worker, {OPENAI_CONCURRENCY_LIMIT} OpenAI gleichzeitig)...")
# Worker Funktion für Branch Evaluation (muss hier oder global sein)
# Machen wir sie global wie _process_batch, da sie Semaphor nutzt.
# Definiere _evaluate_branch_task_worker(task_data, semaphore)
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_BRANCH_WORKERS) as executor:
# Submit Aufgaben an den Executor
# Annahme: openai_semaphore_branch ist global initialisiert
future_to_task = {executor.submit(_evaluate_branch_task_worker, task, openai_semaphore_branch): task for task in tasks_for_processing_batch} # Annahme: _evaluate_branch_task_worker ist global
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['error']: batch_error_count += 1;
except Exception as exc: row_num = task['row_num']; err_msg = f"Generischer Fehler Branch Task Zeile {row_num}: {exc}"; logging.error(err_msg); results_list.append({"row_num": row_num, "result": {"branch": "FEHLER", "consistency": "error_task", "justification": err_msg[:500]}, "error": err_msg}); batch_error_count += 1;
logging.info(f" Branch-Evaluation für Batch beendet. {len(results_list)} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).")
if results_list:
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S"); current_version = Config.VERSION; 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']; logging.debug(f" Zeile {row_num}: Ergebnis -> Branch='{result.get('branch')}', Consistency='{result.get('consistency')}', Justification='{result.get('justification', '')[:50]}...'");
batch_sheet_updates.extend([
{'range': f'{branch_w_letter}{row_num}', 'values': [[result.get("branch", "Fehler")]]}, {'range': f'{branch_x_letter}{row_num}', 'values': [[result.get("consistency", "Fehler")]]}, {'range': f'{branch_y_letter}{row_num}', 'values': [[result.get("justification", "Fehler")]]}, {'range': f'{ts_col_letter}{row_num}', 'values': [[current_timestamp]]}, {'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}
]);
if batch_sheet_updates: logging.info(f" Sende 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: logging.info(f" Sheet-Update erfolgreich."); else: logging.error(f" FEHLER beim Sheet-Update."); all_sheet_updates = [];
else: logging.debug(f" Keine Sheet-Updates vorbereitet.")
tasks_for_processing_batch = []; logging.debug(f"--- Verarbeitungs-Batch {batch_start_row}-{batch_end_row} abgeschlossen ---"); logging.debug(" Warte nach Batch..."); time.sleep(Config.RETRY_DELAY);
if all_sheet_updates: logging.info(f"Sende finalen Sheet-Update ({len(all_sheet_updates)} Zellen)..."); self.sheet_handler.batch_update_cells(all_sheet_updates);
logging.info(f"Branchen-Einschätzung Batch abgeschlossen. {processed_count} Tasks erstellt.")
# --- Dienstprogramm Methoden (Werden von run_user_interface aufgerufen) ---
# Diese Methoden führen eine spezifische Aufgabe aus und arbeiten oft über das gesamte Sheet
# oder eine gefilterte Menge.
# process_serp_website_lookup Methode (früher process_serp_website_lookup_for_empty)
def process_serp_website_lookup(self, limit=None): # <<< Methode in DataProcessor
"""
Sucht fehlende Websites (Spalte D ist leer oder "k.A.") via SERP API
(Google Search) und trägt gefundene URLs in Spalte D ein.
Args:
limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None.
"""
logging.info(f"Starte Modus: SERP API Website Lookup für leere Zellen in Spalte D. Limit: {limit if limit is not None else 'Unbegrenzt'}")
if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.")
data_rows = self.sheet_handler.get_data(); header_rows = 5;
rows_processed_count = 0; updates = [];
try: website_col_idx = COLUMN_MAP["CRM Website"]; name_col_idx = COLUMN_MAP["CRM Name"]; website_col_letter = self.sheet_handler._get_col_letter(website_col_idx + 1);
except KeyError as e: logging.critical(f"FEHLER: Benötigte Spalte '{e}' fehlt."); return;
except Exception as e: logging.critical(f"FEHLER beim Holen der Spaltenbuchstaben: {e}"); return;
for i, row in enumerate(data_rows):
row_num_in_sheet = i + header_rows + 1;
if limit is not None and rows_processed_count >= limit: logging.info(f"Limit ({limit}) erreicht."); break;
max_needed_idx = max(website_col_idx, name_col_idx); if len(row) <= max_needed_idx: logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz)."); continue;
current_website = row[website_col_idx] if len(row) > website_col_idx else "";
if not current_website or str(current_website).strip().lower() == "k.a.":
company_name = row[name_col_idx] if len(row) > name_col_idx else ""; if not company_name or str(company_name).strip() == "": logging.warning(f"Zeile {row_num_in_sheet}: Übersprungen (kein Firmenname)."); continue;
logging.info(f"Zeile {row_num_in_sheet}: Suche Website für '{company_name}'...");
new_website = serp_website_lookup(company_name); # Globale Funktion mit Retry
rows_processed_count += 1;
if new_website != "k.A.": updates.append({'range': f'{website_col_letter}{row_num_in_sheet}', 'values': [[new_website]]}); logging.info(f"Zeile {row_num_in_sheet}: Neue Website '{new_website}' gefunden.");
else: logging.info(f"Zeile {row_num_in_sheet}: Keine Website gefunden.");
time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.3);
if updates: logging.info(f"Sende Batch-Update für {len(updates)} Zellen ({rows_processed_count} Zeilen geprüft)..."); success = self.sheet_handler.batch_update_cells(updates); if success: logging.info(f"Batch-Update erfolgreich."); else: logging.error(f"FEHLER beim Batch-Update.");
else: logging.info("Keine fehlenden Websites gefunden oder keine Updates nötig.");
logging.info(f"Modus 'website_lookup' abgeschlossen. {rows_processed_count} Zeilen geprüft.")
# process_find_wiki_serp Methode
def process_find_wiki_serp(self, limit=None, min_employees=500, min_umsatz=200): # <<< Methode in DataProcessor
"""
Sucht fehlende Wikipedia-URLs (Spalte M = k.A.) für Unternehmen mit
(Umsatz CRM > min_umsatz MIO € ODER Mitarbeiter CRM > min_employees)
über SerpAPI und trägt gefundene URLs in Spalte M ein. Setzt ReEval-Flag (A)
und löscht abhängige Wiki-Spalten (N-V, AN, AO, AP, AX).
Merkt sich in Spalte AY, wann die Suche durchgeführt wurde.
Args:
limit (int, optional): Maximale Anzahl zu prüfender Zeilen. Defaults to None.
min_employees (int, optional): Mindestanzahl Mitarbeiter (Spalte K) als Teilfilter. Defaults to 500.
min_umsatz (int, optional): Mindestumsatz in MIO € (Spalte J) als Teilfilter. Defaults to 200.
"""
logging.info(f"Starte Modus 'find_wiki_serp': Suche fehlende Wiki-URLs für Firmen mit (Umsatz CRM > {min_umsatz} MIO € ODER Mitarbeiter CRM > {min_employees})...")
if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.")
all_data = self.sheet_handler.get_all_data_with_headers(); header_rows = 5; if not all_data or len(all_data) <= header_rows: logging.warning("Keine Daten gefunden."); return
data_rows = all_data[header_rows:];
col_indices = {}; required_keys = [ "ReEval Flag", "CRM Anzahl Mitarbeiter", "CRM Umsatz", "Wiki URL", "CRM Name", "CRM Website", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Chat Wiki Konsistenzprüfung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", "Begründung bei Abweichung", "Wikipedia Timestamp", "Timestamp letzte Prüfung", "Version", "Wiki Verif. Timestamp", "SerpAPI Wiki Search Timestamp" ];
all_keys_found = True; for key in required_keys: idx = COLUMN_MAP.get(key); col_indices[key] = idx; if idx is None: logging.critical(f"FEHLER: Schlüssel '{key}' fehlt! Modus abgebrochen."); all_keys_found = False;
if not all_keys_found: return;
col_letters = {key: self.sheet_handler._get_col_letter(idx + 1) for key, idx in col_indices.items()};
all_sheet_updates = []; processed_rows_count = 0; found_urls_count = 0; skipped_timestamp_ay_count = 0; skipped_size_count = 0; skipped_m_filled_count = 0;
now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S");
for idx, row in enumerate(data_rows):
row_num_in_sheet = idx + header_rows + 1;
if limit is not None and processed_rows_count >= limit: logging.info(f"Limit ({limit}) erreicht."); break;
max_needed_idx = max(col_indices.values()); if len(row) <= max_needed_idx: logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz)."); continue;
ts_ay_val = row[col_indices["SerpAPI Wiki Search Timestamp"]]; if ts_ay_val and ts_ay_val.strip(): skipped_timestamp_ay_count += 1; continue;
m_value = row[col_indices["Wiki URL"]]; if m_value and str(m_value).strip().lower() not in ["k.a.", "kein artikel gefunden"]: skipped_m_filled_count += 1; continue;
umsatz_val_str = row[col_indices["CRM Umsatz"]]; ma_val_str = row[col_indices["CRM Anzahl Mitarbeiter"]];
umsatz_val_mio = get_numeric_filter_value(umsatz_val_str, is_umsatz=True); # Globale Funktion
ma_val_num = get_numeric_filter_value(ma_val_str, is_umsatz=False); # Globale Funktion
if not (umsatz_val_mio > min_umsatz or ma_val_num > min_employees):
logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Größe nicht ausreichend. Umsatz (Mio): {umsatz_val_mio:.2f}, MA: {ma_val_num}). Schwellen: Umsatz > {min_umsatz} Mio, MA > {min_employees}.");
skipped_size_count += 1; continue;
company_name = row[col_indices["CRM Name"]]; if not company_name or str(company_name).strip() == "": logging.warning(f"Zeile {row_num_in_sheet}: Übersprungen, kein Firmenname."); ay_col_letter = col_letters["SerpAPI Wiki Search Timestamp"]; all_sheet_updates.append({'range': f'{ay_col_letter}{row_num_in_sheet}', 'values': [[now_timestamp_str]]}); continue;
logging.info(f"Zeile {row_num_in_sheet}: Suche Wiki-URL für '{company_name}' (Umsatz (Mio): {umsatz_val_mio:.2f}, MA: {ma_val_num})...");
processed_rows_count += 1;
website_url = row[col_indices["CRM Website"]] if col_indices["CRM Website"] is not None and len(row) > col_indices["CRM Website"] else None;
wiki_url_found = serp_wikipedia_lookup(company_name, website=website_url); # Globale Funktion mit Retry
ay_col_letter = col_letters["SerpAPI Wiki Search Timestamp"]; all_sheet_updates.append({'range': f'{ay_col_letter}{row_num_in_sheet}', 'values': [[now_timestamp_str]]});
if wiki_url_found and wiki_url_found.strip() and wiki_url_found != "k.A.":
logging.info(f" -> URL gefunden: {wiki_url_found}. Bereite Update vor.");
found_urls_count += 1; m_l = col_letters["Wiki URL"]; a_l = col_letters["ReEval Flag"]; n_idx = col_indices["Wiki Absatz"]; v_idx = col_indices["Begründung bei Abweichung"]; n_l=self.sheet_handler._get_col_letter(n_idx+1); v_l=self.sheet_handler._get_col_letter(v_idx+1); an_l = col_indices["Wikipedia Timestamp"]; ao_l = col_indices["Timestamp letzte Prüfung"]; ap_l = col_letters["Version"]; ax_l = col_letters["Wiki Verif. Timestamp"];
ao_idx = COLUMN_MAP.get("Timestamp letzte Prüfung"); ao_l=self.sheet_handler._get_col_letter(ao_idx+1); # Korrektur AO_l war Index, muss Buchstabe sein
all_sheet_updates.extend([
{'range': f'{m_l}{row_num_in_sheet}', 'values': [[wiki_url_found]]}, {'range': f'{a_l}{row_num_in_sheet}', 'values': [['x']]},
{'range': f'{n_l}{row_num_in_sheet}:{v_l}{row_num_in_sheet}', 'values': [[''] * (v_idx - n_idx + 1)]},
{'range': f'{an_l}{row_num_in_sheet}', 'values': [['']]}, {'range': f'{ao_l}{row_num_in_sheet}', 'values': [['']]},
{'range': f'{ap_l}{row_num_in_sheet}', 'values': [['']]}, {'range': f'{ax_l}{row_num_in_sheet}', 'values': [['']]}
]);
else: logging.info(f" -> Keine Wiki-URL via SerpAPI gefunden.");
time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.3);
if all_sheet_updates: logging.info(f"Sende Batch-Update für {len(all_sheet_updates)} Zellen ({processed_rows_count} Zeilen geprüft)..."); success = self.sheet_handler.batch_update_cells(all_sheet_updates); if success: logging.info(f"Sheet-Update erfolgreich."); else: logging.error(f"FEHLER beim Batch-Update.");
else: logging.info("Keine Updates nötig.");
logging.info(f"Modus 'find_wiki_serp' abgeschlossen. {processed_rows_count} Tasks erstellt, {found_urls_count} URLs gefunden, {skipped_timestamp_ay_count} AY gesetzt, {skipped_size_count} Größe, {skipped_m_filled_count} M gefüllt.")
# process_wiki_updates_from_chatgpt Methode
def process_wiki_updates_from_chatgpt(self, row_limit=None): # <<< Methode in DataProcessor
"""
Identifiziert Zeilen (S nicht OK/Updated/Copied/Invalid), prüft ob U eine *valide* und *andere* Wiki-URL ist.
- Wenn ja: Kopiert U->M, markiert S='X (URL Copied)', U='URL übernommen', löscht TS/Version, setzt ReEval-Flag A.
- Wenn nein (U keine URL, U==M, oder U ungültig): LÖSCHT den Inhalt von U und markiert S als 'X (Invalid Suggestion)'.
Verarbeitet maximal row_limit Zeilen.
"""
logging.info(f"Starte Modus: Wiki-Updates (URL-Validierung & Löschen ungültiger Vorschläge). Limit: {limit if limit is not None else 'Unbegrenzt'}")
if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.")
all_data = self.sheet_handler.get_all_data_with_headers(); header_rows = 5
if not all_data or len(all_data) <= header_rows: logging.warning("Keine Daten gefunden."); return
data_rows = all_data[header_rows:]
required_keys = [ "Chat Wiki Konsistenzprüfung", "Chat Vorschlag Wiki Artikel", "Wiki URL", "Wikipedia Timestamp", "Wiki Verif. Timestamp", "Timestamp letzte Prüfung", "Version", "ReEval Flag", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Begründung bei Abweichung" ];
col_indices = {}; all_keys_found = True; for key in required_keys: idx = COLUMN_MAP.get(key); col_indices[key] = idx; if idx is None: logging.critical(f"FEHLER: Schlüssel '{key}' fehlt! Modus abgebrochen."); all_keys_found = False;
if not all_keys_found: return;
all_sheet_updates = []; processed_rows_count = 0; updated_url_count = 0; cleared_suggestion_count = 0;
for idx, row in enumerate(data_rows):
row_num_in_sheet = idx + header_rows + 1;
if limit is not None and processed_rows_count >= limit: logging.info(f"Limit ({limit}) erreicht."); break;
max_needed_idx = max(col_indices.values()); if len(row) <= max_needed_idx: logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz)."); continue;
konsistenz_s = self._get_cell_value(row, "Chat Wiki Konsistenzprüfung").strip();
vorschlag_u = self._get_cell_value(row, "Chat Vorschlag Wiki Artikel").strip();
url_m = self._get_cell_value(row, "Wiki URL").strip();
konsistenz_s_upper = konsistenz_s.upper();
is_candidate_for_check = bool(konsistenz_s_upper) and konsistenz_s_upper not in ["OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)", "?"];
if is_candidate_for_check or (konsistenz_s_upper == "?" and not vorschlag_u):
logging.debug(f"Zeile {row_num_in_sheet}: Kandidat für Wiki-Update-Prüfung (Status S = '{konsistenz_s}'). Vorschlag U = '{vorschlag_u}'");
processed_rows_count += 1; is_update_candidate = False; new_url = "";
condition2_u_is_wiki_url = vorschlag_u.lower().startswith(("http://", "https://")) and "wikipedia.org/wiki/" in vorschlag_u.lower();
if condition2_u_is_wiki_url:
new_url = vorschlag_u;
condition3_u_differs_m = simple_normalize_url(new_url) != simple_normalize_url(url_m); # Global Function
if condition3_u_differs_m:
logging.debug(f" -> Prüfe Validität der neuen URL: {new_url}...");
try: condition4_u_is_valid = is_valid_wikipedia_article_url(new_url); # Global Function with Retry
except Exception as e_valid: logging.error(f" -> Fehler bei Validierung der URL '{new_url}': {e_valid}. Behandle als ungültig."); condition4_u_is_valid = False;
if condition4_u_is_valid: is_update_candidate = True; logging.debug(f" -> URL '{new_url}' ist ein valider Artikel.");
else: logging.debug(f" -> URL '{new_url}' ist KEIN valider Artikel laut API Check.");
else: logging.debug(f" -> Vorschlag U ist identisch mit URL M.");
else: logging.debug(f" -> Vorschlag U ist keine Wikipedia URL ('{vorschlag_u}').");
if is_update_candidate:
logging.info(f"Zeile {row_num_in_sheet}: Update-Kandidat VALIDIERUNG ERFOLGREICH. Setze ReEval-Flag 'x' und bereite Updates vor für URL: {new_url}");
updated_url_count += 1;
m_l=self.sheet_handler._get_col_letter(col_indices["Wiki URL"]+1); s_l=self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"]+1); u_l=self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"]+1);
a_l=self.sheet_handler._get_col_letter(col_indices["ReEval Flag"]+1);
n_idx = col_indices["Wiki Absatz"]; v_idx = col_indices["Begründung bei Abweichung"]; n_l=self.sheet_handler._get_col_letter(n_idx+1); v_l=self.sheet_handler._get_col_letter(v_idx+1);
an_l=self.sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"]+1); ax_l=self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"]+1); ao_l=self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Prüfung"]+1); ap_l=self.sheet_handler._get_col_letter(col_indices["Version"]+1);
all_sheet_updates.extend([
{'range': f'{m_l}{row_num_in_sheet}', 'values': [[new_url]]}, {'range': f'{s_l}{row_num_in_sheet}', 'values': [["X (URL Copied)"]]}, {'range': f'{u_l}{row_num_in_sheet}', 'values': [["URL übernommen"]]},
{'range': f'{n_l}{row_num_in_sheet}:{v_l}{row_num_in_sheet}', 'values': [[''] * (v_idx - n_idx + 1)]},
{'range': f'{an_l}{row_num_in_sheet}', 'values': [['']]}, {'range': f'{ax_l}{row_num_in_sheet}', 'values': [['']]},
{'range': f'{ao_l}{row_num_in_sheet}', 'values': [['']]}, {'range': f'{ap_l}{row_num_in_sheet}', 'values': [['']]},
{'range': f'{a_l}{row_num_in_sheet}', 'values': [["x"]]},
]);
else: # <<< Continue from here
logging.info(f"Zeile {row_num_in_sheet}: Vorschlag U ('{vorschlag_u}') ist ungültig/identisch. Lösche U und setze Status S.");
cleared_suggestion_count += 1;
s_l=self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"]+1); u_l=self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"]+1);
all_sheet_updates.extend([
{'range': f'{s_l}{row_num_in_sheet}', 'values': [["X (Invalid Suggestion)"]]},
{'range': f'{u_l}{row_num_in_sheet}', 'values': [[""]]}
]);
if all_sheet_updates:
logging.info(f"Sende Batch-Update für {processed_rows_count} geprüfte Zeilen ({len(all_sheet_updates)} Zellen)...");
success = self.sheet_handler.batch_update_cells(all_sheet_updates);
if success: logging.info(f"Sheet-Update für Wiki-Updates erfolgreich.");
else: logging.error("FEHLER beim Sheet-Update für Wiki-Updates.");
else: logging.info("Keine Zeilen gefunden, die Wiki-Updates benötigen.");
logging.info(f"Wiki-Updates 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.");
# process_website_details Methode (früher process_website_details_for_marked_rows)
def process_website_details(self, limit=None): # <<< Methode in DataProcessor
"""
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:
limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None.
"""
logging.info(f"Starte Modus (EXPERIMENTELL): Website Detail Extraction für Zeilen mit 'x' in Spalte A. Limit: {limit if limit is not None else 'Unbegrenzt'}");
if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.");
data_rows = self.sheet_handler.get_data(); header_rows = 5;
rows_processed_count = 0; updates = [];
try: reeval_col_idx = COLUMN_MAP["ReEval Flag"]; website_col_idx = COLUMN_MAP["CRM Website"];
# Versuche zuerst die dedizierte Spalte 'Website Details'
details_col_idx = COLUMN_MAP.get("Website Details", None);
if details_col_idx is None:
# Fallback auf 'Website Rohtext' (AR) wenn 'Website Details' nicht in COLUMN_MAP
details_col_idx = COLUMN_MAP.get("Website Rohtext");
if details_col_idx is None: logging.critical("FEHLER: Weder 'Website Details' noch 'Website Rohtext' Spaltenindex fehlt."); return;
logging.warning("Keine Spalte 'Website Details' in COLUMN_MAP, nutze 'Website Rohtext' (AR) als Fallback.");
details_col_letter = self.sheet_handler._get_col_letter(details_col_idx + 1);
except KeyError as e: logging.critical(f"FEHLER: Benötigte Spalte '{e}' fehlt."); return;
except Exception as e: logging.critical(f"FEHLER beim Holen der Spaltenbuchstaben: {e}"); return;
for i, row in enumerate(data_rows):
row_num_in_sheet = i + header_rows + 1;
if limit is not None and rows_processed_count >= limit: logging.info(f"Limit ({limit}) erreicht."); break;
if len(row) <= reeval_col_idx or str(row[reeval_col_idx]).strip().lower() != "x": continue; # Prüfen, ob Zeile mit 'x' markiert ist
website_url = row[website_col_idx] if len(row) > website_col_idx else "";
if not website_url or str(website_url).strip().lower() == "k.a.": logging.warning(f"Zeile {row_num_in_sheet}: Keine gültige Website URL, überspringe."); continue;
logging.info(f"Zeile {row_num_in_sheet}: Extrahiere Website Details von {website_url}...");
rows_processed_count += 1;
try: details = scrape_website_details(website_url); # Annahme: scrape_website_details ist global
except NameError: logging.critical("FEHLER: Funktion 'scrape_website_details' nicht definiert!"); details = "FEHLER: Funktion nicht definiert";
except Exception as e_detail: logging.exception(f"Fehler bei scrape_website_details für {website_url}: {e_detail}"); details = f"FEHLER: {e_detail}";
updates.append({'range': f'{details_col_letter}{row_num_in_sheet}', 'values': [[str(details)]]});
time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.2);
if updates: logging.info(f"Sende Batch-Update für {len(updates)} Zellen ({rows_processed_count} Zeilen geprüft)..."); success = self.sheet_handler.batch_update_cells(updates); if success: logging.info(f"Batch-Update erfolgreich."); else: logging.error(f"FEHLER beim Batch-Update.");
else: logging.info("Keine 'x' Zeilen gefunden für Detail-Extraktion.");
logging.info(f"Modus 'website_details' abgeschlossen. {rows_processed_count} Zeilen geprüft.")
# process_contact_research Methode
def process_contact_research(self, limit=None): # <<< Methode in DataProcessor
"""Sucht LinkedIn Kontakte und trägt sie in 'Contacts' Sheet ein."""
logging.info(f"Starte Contact Research (LinkedIn). Limit: {limit if limit is not None else 'Unbegrenzt'}");
# DataProcessor benötigt sheet_handler.sheet.spreadsheet Zugriff
if not self.sheet_handler or not hasattr(self.sheet_handler, 'sheet') or not hasattr(self.sheet_handler.sheet, 'spreadsheet'):
logging.critical("FEHLER: Sheet Handler oder Spreadsheet nicht verfügbar für Contact Research.");
return;
main_sheet = self.sheet_handler.sheet;
if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.");
all_data = self.sheet_handler.get_all_data_with_headers(); header_rows = 5;
# Finde Startzeile basierend auf Timestamp in Spalte AM (Index 38)
timestamp_col_index = COLUMN_MAP.get("Contact Search Timestamp");
if timestamp_col_index is None: logging.critical("FEHLER: 'Contact Search Timestamp' Spaltenindex fehlt."); return;
start_sheet_row = -1;
# Starte Suche nach leerem Timestamp nach Headern (Zeile 6, Index 5)
for i in range(header_rows, len(all_data)): # Iterate 0-based
row_index_in_list = i; row = all_data[row_index_in_list]; row_num_in_sheet = i + 1; # 1-based
if len(row) <= timestamp_col_index or not row[timestamp_col_index].strip():
start_sheet_row = row_num_in_sheet; break;
if start_sheet_row == -1: logging.info("Keine Zeile ohne Contact Search Timestamp gefunden."); return;
logging.info(f"Contact Research startet ab Zeile {start_sheet_row}.");
# Kontakte-Blatt öffnen oder erstellen
try: contacts_sheet = self.sheet_handler.sheet.spreadsheet.worksheet("Contacts"); logging.info("Blatt 'Contacts' gefunden.");
except gspread.exceptions.WorksheetNotFound:
logging.info("Blatt 'Contacts' nicht gefunden, erstelle neu...");
contacts_sheet = self.sheet_handler.sheet.spreadsheet.add_worksheet(title="Contacts", rows="1000", cols="12");
header = ["Firmenname", "CRM Kurzform", "Website", "Geschlecht", "Vorname", "Nachname", "Position", "Suchbegriffskategorie", "E-Mail-Adresse", "LinkedIn-Link", "Timestamp"];
try: contacts_sheet.update(values=[header], range_name="A1:K1"); logging.info("Neues Blatt 'Contacts' erstellt und Header eingetragen.");
except Exception as e: logging.error(f"FEHLER beim Schreiben des Headers ins 'Contacts' Blatt: {e}"); # Kann hier weitergehen?
# Positionen, nach denen gesucht wird
positions_to_search = ["Serviceleiter", "Leiter Kundendienst", "IT-Leiter", "Leiter IT", "Geschäftsführer", "Vorstand", "Disponent", "Einsatzleiter"]; # Annahme
processed_count = 0;
# Gehe Zeilen im Hauptblatt durch (ab Startzeile)
for i in range(start_sheet_row, len(all_data) + 1):
row_index_in_list = i - 1; row = all_data[row_index_in_list]; row_num_in_sheet = i; # 1-based
if limit is not None and processed_count >= limit: logging.info(f"Limit ({limit}) erreicht."); break;
# Benötigte Spaltenindizes für Lesezugriff (CRM Name, Kurzform, Website)
name_idx = COLUMN_MAP.get("CRM Name"); kurzform_idx = COLUMN_MAP.get("CRM Kurzform"); website_idx = COLUMN_MAP.get("CRM Website");
if None in [name_idx, kurzform_idx, website_idx]: logging.error("FEHLER: Benötigte CRM-Spalten für Contact Research fehlen."); break;
# Sicherstellen, dass Zeile lang genug ist
max_crm_idx = max(name_idx, kurzform_idx, website_idx);
if len(row) <= max_crm_idx: logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz)."); continue;
company_name = row[name_idx]; crm_kurzform = row[kurzform_idx]; website = row[website_idx];
if not all([company_name, crm_kurzform, website]) or any(str(v).strip().lower() == "k.a." for v in [company_name, crm_kurzform, website]):
logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (fehlende CRM Daten: Name, Kurzform oder Website).");
# Optional: Setze AM Timestamp zu "k.A. (Missing Data)"? Oder leer lassen?
# Lassen wir leer, website_lookup/find_wiki_serp könnten Daten ergänzen.
continue;
logging.info(f"Zeile {row_num_in_sheet}: Suche Kontakte für '{crm_kurzform}'...");
processed_count += 1; # Zähle als verarbeitet, wenn die Suche für diese Firma gestartet wird
all_found_contacts = []; contact_counts = {pos: 0 for pos in ["Serviceleiter", "IT-Leiter", "Geschäftsführer", "Disponent"]};
for position in positions_to_search:
# search_linkedin_contacts ist global mit Retry
found_contacts = search_linkedin_contacts(company_name, website, position, crm_kurzform, num_results=5); # Suche max. 5 Kontakte pro Position
# Zählung für das Hauptblatt (vereinfachte Kategorien)
if "serviceleiter" in position.lower() or "kundendienst" in position.lower() or "einsatzleiter" in position.lower(): contact_counts["Serviceleiter"] += len(found_contacts);
elif "it-leiter" in position.lower() or "leiter it" in position.lower(): contact_counts["IT-Leiter"] += len(found_contacts);
elif "geschäftsführer" in position.lower() or "vorstand" in position.lower(): contact_counts["Geschäftsführer"] += len(found_contacts);
elif "disponent" in position.lower(): contact_counts["Disponent"] += len(found_contacts);
# Füge gefundene Kontakte zur Liste hinzu (mit Suchkategorie)
for contact in found_contacts: contact["Suchbegriffskategorie"] = position; all_found_contacts.append(contact);
time.sleep(1.5); # Kleine Pause zwischen SerpAPI-Aufrufen
# Verarbeite gefundene Kontakte und schreibe ins Contacts-Sheet
rows_to_append = []; timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S");
unique_contacts = {c.get('LinkedInURL'): c for c in all_found_contacts if c.get('LinkedInURL')}.values(); # Deduplizieren & nur mit URL
for contact in unique_contacts:
firstname = contact.get("Vorname", ""); lastname = contact.get("Nachname", "");
gender_value = get_gender(firstname); # Global Function with Retry
email = get_email_address(firstname, lastname, website); # Global Function
contact_row = [ contact.get("Firmenname", ""), contact.get("CRM Kurzform", ""), contact.get("Website", ""), gender_value, firstname, lastname, contact.get("Position", ""), contact.get("Suchbegriffskategorie", ""), email, contact.get("LinkedInURL", ""), timestamp ];
rows_to_append.append(contact_row);
if rows_to_append:
try: contacts_sheet.append_rows(rows_to_append, value_input_option='USER_ENTERED'); logging.info(f"Zeile {row_num_in_sheet}: {len(rows_to_append)} neue Kontakte zum 'Contacts'-Blatt hinzugefügt.");
except Exception as e: logging.error(f"Zeile {row_num_in_sheet}: Fehler beim Hinzufügen von Kontakten zum Sheet: {e}"); pass; # Fehler loggen, aber weitermachen
# Aktualisiere Trefferzahlen und Timestamp im Hauptblatt (Batch Update)
# Benötigte Spaltenindizes für Schreibzugriff (AI-AM)
ai_idx = COLUMN_MAP.get("Linked Serviceleiter gefunden"); aj_idx = COLUMN_MAP.get("Linked It-Leiter gefunden"); ak_idx = COLUMN_MAP.get("Linked Management gefunden"); al_idx = COLUMN_MAP.get("Linked Disponent gefunden"); am_idx = COLUMN_MAP.get("Contact Search Timestamp");
if None in [ai_idx, aj_idx, ak_idx, al_idx, am_idx]: logging.error("FEHLER: Benötigte Linked/Contact TS Spalten fehlen."); continue; # Kann nicht updaten
main_sheet_updates = [];
main_sheet_updates.append({'range': f'AI{row_num_in_sheet}', 'values': [[str(contact_counts.get("Serviceleiter", ""))]]});
main_sheet_updates.append({'range': f'AJ{row_num_in_sheet}', 'values': [[str(contact_counts.get("IT-Leiter", ""))]]});
main_sheet_updates.append({'range': f'AK{row_num_in_sheet}', 'values': [[str(contact_counts.get("Geschäftsführer", ""))]]});
main_sheet_updates.append({'range': f'AL{row_num_in_sheet}', 'values': [[str(contact_counts.get("Disponent", ""))]]});
main_sheet_updates.append({'range': f'AM{row_num_in_sheet}', 'values': [[timestamp]]});
if main_sheet_updates:
success = self.sheet_handler.batch_update_cells(main_sheet_updates); # Nutze self.sheet_handler
if success: logging.debug(f"Zeile {row_num_in_sheet}: Kontaktzahlen/AM im Hauptblatt aktualisiert.");
else: logging.error(f"Zeile {row_num_in_sheet}: FEHLER beim Batch-Update Kontaktzahlen/AM.");
# Pause nach Verarbeitung einer Firma
time.sleep(Config.RETRY_DELAY);
logging.info(f"Contact Research abgeschlossen. {processed_count} Firmen geprüft.")
# --- Methoden zur Datenvorbereitung und Modelltraining für ML ---
# Diese Methoden gehören in die Klasse DataProcessor
# prepare_data_for_modeling 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.
- Konsolidiert Umsatz/Mitarbeiter (Wiki > CRM Priorität).
- Filert 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.
"""
logging.info("Starte Datenvorbereitung für Modellierung...")
# Nutze den self.sheet_handler der Klasse
if not self.sheet_handler or not self.sheet_handler.sheet_values:
logging.error("Fehler: Sheet Handler nicht initialisiert oder keine Daten geladen für prepare_data_for_modeling.")
if not self.sheet_handler.load_data():
logging.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
# Prüfe auf ausreichende Zeilenzahl (Header + mindestens eine Datenzeile)
min_required_rows = 6 # 5 Header + 1 Datenzeile
if not all_data or len(all_data) < min_required_rows:
logging.error(f"Fehler: Nicht genügend Datenzeilen ({len(all_data)}) im Sheet gefunden für Modellierung (mindestens {min_required_rows} benötigt).")
return None
try:
headers = all_data[0]
# Stelle sicher, dass die Header-Zeile auch die erwartete Mindestlänge hat,
# um die Spaltenindizes aus COLUMN_MAP zu finden
if len(headers) <= max(COLUMN_MAP.values()):
logging.critical(f"FEHLER: Header-Zeile ({len(headers)} Spalten) ist kürzer als der höchste Index in COLUMN_MAP ({max(COLUMN_MAP.values())}). COLUMN_MAP passt nicht zum Sheet.")
return None
except IndexError:
logging.critical("FEHLER: Sheet scheint leer zu sein oder hat keine erste Zeile, keine Header gefunden.")
return None
except ValueError as e:
logging.critical(f"FEHLER: Ungültiger Wert in COLUMN_MAP. Kann max Index nicht ermitteln: {e}")
return None
except Exception as e:
logging.critical(f"FEHLER beim Zugriff auf Header oder Prüfen der Spaltenlänge: {e}")
return None
data_rows = all_data[5:] # Annahme: Die ersten 5 Zeilen sind Header
# Erstelle DataFrame
df = pd.DataFrame(data_rows, columns=headers)
logging.info(f"Initialen DataFrame für Modellierung erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.")
# --- Spaltenauswahl und Umbenennung ---
# Definiere die notwendigen Spalten und ihre gewünschten Namen im DataFrame
col_keys = {
"name": "CRM Name", # Zur Identifikation, wird später entfernt
"branche": "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 = [key for key in col_keys.values() if key not in COLUMN_MAP]
if missing_keys:
logging.critical(f"FEHLER: Folgende benötigte Spalten-Schlüssel fehlen in COLUMN_MAP für prepare_data_for_modeling: {missing_keys}.")
return None
# Erstelle das Mapping von Header-Namen zu internen Schlüsseln
header_to_internal_key = {headers[COLUMN_MAP[key]]: internal_key for internal_key, key in col_keys.items()}
# Wähle nur die benötigten Spalten im DataFrame aus
# Verwende die tatsächlichen Header-Namen aus dem Sheet für die Auswahl
cols_to_select_by_header = [headers[COLUMN_MAP[key]] for key in col_keys.values()]
try:
df_subset = df[cols_to_select_by_header].copy() # Kopie erstellen
# Benenne die Spalten um
df_subset.rename(columns=header_to_internal_key, inplace=True)
except KeyError as e:
# Dieser Fehler sollte eigentlich durch die obige Prüfung abgefangen werden,
# aber zur Sicherheit ein weiterer Check
logging.critical(f"FEHLER beim Auswählen/Umbenennen der Spalten (KeyError: {e}). Verfügbare Header im DF: {list(df.columns)}")
return None
except Exception as e:
logging.critical(f"Unerwarteter FEHLER beim Auswählen/Umbenennen der Spalten: {e}")
return None
logging.info(f"Benötigte Spalten für Modellierung ausgewählt und umbenannt: {list(df_subset.columns)}")
# --- Features konsolidieren (Umsatz, Mitarbeiter) ---
# Annahme: extract_numeric_value existiert (global)
# Wir brauchen hier eine Funktion, die NaN zurückgibt für ungültige Werte, nicht "k.A."
# Passen Sie extract_numeric_value an oder erstellen Sie eine neue.
# Die get_valid_numeric Funktion aus Ihrer alten prepare_data_for_modeling Version macht genau das.
def get_valid_numeric(value_str):
"""Hilfsfunktion zur sicheren Konvertierung mit Fehlerbehandlung."""
if value_str is None or pd.isna(value_str) or str(value_str).strip() == '': return np.nan
raw_value_str = str(value_str)
try:
# Kopieren Sie hier die Logik von extract_numeric_value, die NaN zurückgibt
# anstatt "k.A." bei Fehlern oder 0/negativen Werten.
processed_value = clean_text(raw_value_str) # Annahme: clean_text existiert
if processed_value == "k.A.": return np.nan
processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|über|unter|mehr als|weniger als|bis zu)\s+', '', processed_value)
processed_value = re.sub(r'[€$£¥]', '', processed_value).strip()
processed_value = re.split(r'\s*(-||bis)\s*', processed_value, 1)[0].strip()
processed_value_no_thousands = processed_value.replace('.', '').replace("'", "")
processed_value_final = processed_value_no_thousands.replace(',', '.')
match = re.search(r'([\d.]+)', processed_value_final)
if not match: return np.nan
num_str = match.group(1)
if not num_str or num_str == '.': return np.nan
num = float(num_str)
original_lower = raw_value_str.lower()
multiplier = 1.0
if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower): multiplier = 1000000000.0
elif re.search(r'\bmio\s*\b|\bmillionen\s*\b|\bmill\.\s*\b', original_lower): multiplier = 1000000.0
elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): multiplier = 1000.0
num = num * multiplier
return num if num > 0 else np.nan # Nur positive Werte zählen
except (ValueError, TypeError) as e: logging.debug(f"Konntze Wert '{str(value_str)[:50]}...' nicht als gültige Zahl parsen: {e}"); return np.nan
except Exception as e: logging.warning(f"Unerwarteter Fehler in get_valid_numeric für Wert '{str(value_str)[:50]}...': {e}"); return np.nan
cols_to_process = {
'Umsatz': ('umsatz_wiki', 'umsatz_crm', 'Finaler_Umsatz'),
'Mitarbeiter': ('ma_wiki', 'ma_crm', 'Finaler_Mitarbeiter')
}
for base_name, (wiki_col, crm_col, final_col) in cols_to_process.items():
logging.info(f"Verarbeite und konsolidiere '{base_name}' (Priorität: Wiki > CRM)...")
# Sicherstellen, dass Spalten existieren (get_valid_numeric behandelt None)
wiki_series = df_subset[wiki_col].apply(get_valid_numeric) if wiki_col in df_subset.columns else pd.Series(np.nan, index=df_subset.index)
crm_series = df_subset[crm_col].apply(get_valid_numeric) if crm_col in df_subset.columns else pd.Series(np.nan, index=df_subset.index)
# np.where wählt den ersten Wert, wenn er nicht NaN ist, sonst den zweiten
df_subset[final_col] = np.where(
wiki_series.notna(),
wiki_series,
crm_series
)
logging.info(f" -> {df_subset[final_col].notna().sum()} gültige '{final_col}' Werte erstellt (von {len(df_subset)} Zeilen).")
# --- Zielvariable vorbereiten (Technikerzahl) ---
techniker_col = "techniker" # Interne Spaltenname nach Umbenennung
logging.info(f"Verarbeite Zielvariable '{techniker_col}'...")
if techniker_col not in df_subset.columns: logging.critical(f"FEHLER: Zielvariable '{techniker_col}' fehlt."); return None
df_subset['Anzahl_Servicetechniker_Numeric'] = pd.to_numeric(df_subset[techniker_col], errors='coerce')
# Filtere Zeilen: Behalte nur die mit gültiger, positiver Technikerzahl (> 0)
initial_rows = len(df_subset)
df_filtered = df_subset[
df_subset['Anzahl_Servicetechniker_Numeric'].notna() &
(df_subset['Anzahl_Servicetechniker_Numeric'] > 0)
].copy()
filtered_rows = len(df_filtered); removed_rows = initial_rows - filtered_rows;
if removed_rows > 0: logging.info(f"{removed_rows} Zeilen entfernt (fehlende/ungültige Technikerzahl).")
logging.info(f"Verbleibende Zeilen für Modellierung (mit gültiger Technikerzahl > 0): {filtered_rows}")
if filtered_rows == 0: logging.error("FEHLER: Keine Zeilen mit gültiger Technikerzahl (>0) übrig!"); return None
# --- Techniker-Buckets erstellen ---
# Bins und Labels wie definiert ([-1, 0, 19, 49, 99, 249, 499, float('inf')])
bins = [-1, 0, 19, 49, 99, 249, 499, float('inf')]
labels = ['Bucket_1_(0)', 'Bucket_2_(<20)', 'Bucket_3_(<50)', 'Bucket_4_(<100)', 'Bucket_5_(<250)', 'Bucket_6_(<500)', 'Bucket_7_(>499)']
df_filtered['Techniker_Bucket'] = pd.cut( df_filtered['Anzahl_Servicetechniker_Numeric'], bins=bins, labels=labels, right=True, include_lowest=True )
logging.info("Techniker-Buckets erstellt.")
logging.info(f"Verteilung der Techniker-Buckets im Trainingsdatensatz:\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True).round(3)}")
if df_filtered['Techniker_Bucket'].isna().any(): logging.warning("WARNUNG: NaNs in Techniker-Buckets erstellt. Entferne diese Zeilen."); df_filtered.dropna(subset=['Techniker_Bucket'], inplace=True); logging.info(f"Nach Entfernung von NaN Buckets: {len(df_filtered)} Zeilen verbleiben."); if len(df_filtered) == 0: logging.error("FEHLER: Keine Zeilen übrig nach Entfernung von NaN Buckets."); return None;
# --- Kategoriale Features vorbereiten (Branche) ---
branche_col = "branche" # Interne Spaltenname
logging.info(f"Verarbeite kategoriales Feature '{branche_col}' für One-Hot Encoding...")
if branche_col not in df_filtered.columns: logging.critical(f"FEHLER: Spalte '{branche_col}' fehlt für One-Hot Encoding."); return None;
df_filtered[branche_col] = df_filtered[branche_col].astype(str).fillna('Unbekannt').str.strip()
df_encoded = pd.get_dummies(df_filtered, columns=[branche_col], prefix='Branche', dummy_na=False)
logging.info(f"One-Hot Encoding für '{branche_col}' durchgeführt. Neue Spaltenanzahl: {len(df_encoded.columns)}")
# --- Finale Auswahl der Features für das Modell ---
feature_columns = [col for col in df_encoded.columns if col.startswith('Branche_')]
feature_columns.extend(['Finaler_Umsatz', 'Finaler_Mitarbeiter'])
if not all(col in df_encoded.columns for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter']): logging.critical("FEHLER: Konsolidierte numerische Spalten fehlen."); return None;
target_column = 'Techniker_Bucket'; original_data_cols = ['name', 'Anzahl_Servicetechniker_Numeric'];
if not all(col in df_encoded.columns for col in original_data_cols): logging.critical(f"FEHLER: Originaldaten-Spalten {original_data_cols} fehlen."); return None;
df_model_ready = df_encoded[original_data_cols + feature_columns + [target_column]].copy()
for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter', 'Anzahl_Servicetechniker_Numeric']:
if col in df_model_ready.columns: df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce')
df_model_ready = df_model_ready.reset_index(drop=True)
logging.info("Datenvorbereitung für Modellierung abgeschlossen."); logging.info(f"Finaler DataFrame hat {len(df_model_ready)} Zeilen und {len(df_model_ready.columns)} Spalten.");
logging.info(f"Anzahl Feature-Spalten: {len(feature_columns)}"); logging.info(f"Ziel-Spalte: {target_column}");
nan_counts = df_model_ready[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].isna().sum(); logging.info(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}");
rows_with_nan = df_model_ready[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].isna().any(axis=1).sum(); logging.info(f"Anzahl Zeilen mit mindestens einem fehlenden numerischen Feature: {rows_with_nan}");
return df_model_ready
# train_technician_model Methode
def train_technician_model(self, model_out, imputer_out, patterns_out):
"""
Trainiert Decision Tree Modell zur Schätzung der Servicetechnikerzahl.
"""
logging.info("Starte Modus: train_technician_model");
prepared_df = self.prepare_data_for_modeling(); # Nutze self
if prepared_df is not None and not prepared_df.empty:
logging.info("Aufteilen der Daten für das Modelltraining...");
try:
X = prepared_df.drop(columns=['Techniker_Bucket', 'name', 'Anzahl_Servicetechniker_Numeric']); # Spaltennamen nach Umbenennung
y = prepared_df['Techniker_Bucket'];
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y);
logging.info(f"Train/Test Split: {len(X_train)} Train, {len(X_test)} Test samples.");
except KeyError as e: logging.error(f"FEHLER beim Train/Test Split: Spalte nicht gefunden - {e}."); return;
except Exception as e: logging.error(f"FEHLER beim Train/Test Split: {e}"); return;
logging.info("Imputation fehlender numerischer Werte (Median)...");
numeric_features = ['Finaler_Umsatz', 'Finaler_Mitarbeiter'];
try:
imputer = SimpleImputer(strategy='median');
features_to_impute = [nf for nf in numeric_features if nf in X_train.columns];
if features_to_impute:
X_train[features_to_impute] = imputer.fit_transform(X_train[features_to_impute]);
X_test[features_to_impute] = imputer.transform(X_test[features_to_impute]); # Wichtig: transform, nicht fit_transform!
imputer_filename = imputer_out;
with open(imputer_filename, 'wb') as f_imp: pickle.dump(imputer, f_imp);
logging.info(f"Imputer erfolgreich trainiert und gespeichert: '{imputer_filename}'.");
else: logging.warning("Keine numerischen Features gefunden, die imputiert werden müssen.");
except Exception as e: logging.error(f"FEHLER bei der Imputation: {e}"); return;
logging.info("Starte Decision Tree Training mit GridSearchCV...");
param_grid = { 'criterion': ['gini', 'entropy'], 'max_depth': [6, 8, 10, 12, 15], 'min_samples_split': [20, 40, 60], 'min_samples_leaf': [10, 20, 30], 'ccp_alpha': [0.0, 0.001, 0.005] };
dtree = DecisionTreeClassifier(random_state=42, class_weight='balanced');
grid_search = GridSearchCV(estimator=dtree, param_grid=param_grid, cv=5, scoring='f1_weighted', n_jobs=-1, verbose=1);
if X_train.isna().sum().sum() > 0: logging.error(f"FEHLER: NaNs nach Imputation in X_train gefunden. {X_train.columns[X_train.isna().any()].tolist()}. Training abgebrochen."); return;
try:
grid_search.fit(X_train, y_train);
best_estimator = grid_search.best_estimator_; logging.info(f"GridSearchCV abgeschlossen."); logging.info(f"Beste Parameter: {grid_search.best_params_}"); logging.info(f"Bester F1-Score (gewichtet, CV): {grid_search.best_score_:.4f}");
model_filename = model_out; with open(model_filename, 'wb') as f_mod: pickle.dump(best_estimator, f_mod); logging.info(f"Bestes Modell gespeichert: '{model_filename}'.");
except Exception as e_train: logging.exception(f"FEHLER während des Trainings: {e_train}"); return;
logging.info("Evaluiere Modell auf dem Test-Set...");
try:
X_test_processed = X_test.reindex(columns=X_train.columns, fill_value=0); # Sicherstellen, dass X_test gleiche Spalten hat
y_pred = best_estimator.predict(X_test_processed);
test_accuracy = accuracy_score(y_test, y_pred); class_labels = [str(cls) for cls in best_estimator.classes_];
report = classification_report(y_test, y_pred, zero_division=0, labels=best_estimator.classes_, target_names=class_labels);
conf_matrix = confusion_matrix(y_test, y_pred, labels=best_estimator.classes_); conf_matrix_df = pd.DataFrame(conf_matrix, index=class_labels, columns=class_labels);
logging.info(f"\n--- Evaluation Test-Set ---\nGenauigkeit: {test_accuracy:.4f}\nClassification Report:\n{report}\nConfusion Matrix:\n{conf_matrix_df}");
print(f"\nModell Genauigkeit (Test): {test_accuracy:.4f}");
except Exception as e_eval: logging.exception(f"FEHLER bei der Evaluation des Test-Sets: {e_eval}");
logging.info("Extrahiere Baumregeln...");
try: feature_names = list(X_train.columns);
rules_text = export_text(best_estimator, feature_names=feature_names, show_weights=True, spacing=3);
patterns_filename = patterns_out; with open(patterns_filename, 'w', encoding='utf-8') as f_rules: f_rules.write(rules_text); logging.info(f"Regeln als Text gespeichert: '{patterns_filename}'.");
except Exception as e_export: logging.error(f"Fehler beim Exportieren der Regeln: {e_export}");
else: logging.warning("Datenvorbereitung für Modelltraining fehlgeschlagen oder ergab keine Daten.");
# train_technician_model_rag_light Methode (NEU - Platzhalter)
# Diese Methode würde die Schätzung mit dem trainierten Modell und Regeln durchführen.
# Sie gehört hierher, wird aber erst später implementiert.
# def train_technician_model_rag_light(self, ...):
# pass # Implementierung später
# --- Batch Dispatcher Methode (Werden von run_user_interface aufgerufen) ---
# Diese Methode wählt den passenden Batch-Prozess aus und ruft die entsprechende Batch-Methode auf.
# Sie findet die Startzeile für die Batch-Methoden.
def run_batch_dispatcher(self, mode, limit=None):
"""
Wählt den passenden Batch-Prozess basierend auf dem Modus und ruft die entsprechende Methode auf.
Ermittelt die Startzeile dynamisch.
Args:
mode (str): Der Name des Batch-Modus (z.B. 'wiki_batch', 'website_scrape_batch').
limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None.
"""
logging.info(f"Starte DataProcessor Batch Dispatcher im Modus '{mode}' mit limit={limit if limit is not None else 'Unbegrenzt'}.")
header_rows = 5 # Annahme, könnte auch dynamisch vom handler kommen
# Startspalte für jeden Batch-Modus (basierend auf Timestamp/Status für Neuverarbeitung)
start_col_key = None
if mode == "wiki_batch": start_col_key = "Wiki Verif. Timestamp" # AX
elif mode == "website_scrape_batch": start_col_key = "Website Scrape Timestamp" # AT
elif mode == "summarize_batch": # Summarize Batch braucht leeres AS und gefülltes AR
# Die Startzeilensuche für Summarize Batch ist komplexer und in process_summarization_batch implementiert.
# Dieser Dispatcher kann sie hier nicht generisch finden. process_summarization_batch muss das selbst tun.
pass # Keine generische Startspalte hier
elif mode == "branch_batch": start_col_key = "Timestamp letzte Prüfung" # AO
elif mode == "combined":
# Combined mode ruft die einzelnen Batch-Methoden nacheinander auf
logging.info("Combined mode: Calling batches sequentially.")
self.run_batch_dispatcher(mode="wiki_batch", limit=limit) # Prüft AX
self.run_batch_dispatcher(mode="website_scrape_batch", limit=limit) # Prüft AT
self.run_batch_dispatcher(mode="summarize_batch", limit=limit) # Sucht Startzeile intern
self.run_batch_dispatcher(mode="branch_batch", limit=limit) # Prüft AO
logging.info("Combined mode completed.")
return # Wichtig: Nach Combined beenden
# Logik für einzelne Batch-Modi (wiki, website, summarize, branch)
# Für Summarize Batch (mode == 'summarize_batch'), ruft die Methode intern die Startzeilensuche auf.
# Für die anderen (wiki, website, branch), nutzen wir get_start_row_index hier.
if mode in ["wiki_batch", "website_scrape_batch", "branch_batch"]:
if start_col_key is None:
logging.critical(f"FEHLER: Keine Startspalte für Batch-Modus '{mode}' definiert.")
return
logging.info(f"Dispatcher: Ermittle Startzeile basierend auf Spalte '{start_col_key}'...")
start_data_index = self.sheet_handler.get_start_row_index(check_column_key=start_col_key, min_sheet_row=header_rows + 1)
if start_data_index == -1: return logging.error(f"FEHLER: Startspalte '{start_col_key}' prüfen!")
# get_start_row_index gibt den Index in den Daten (ohne Header) zurück.
# Wenn alle Zeilen gefüllt sind, gibt es die Anzahl der Datenzeilen zurück.
# Wenn dieser Index >= Anzahl der Datenzeilen ist, gibt es nichts zu tun.
if start_data_index >= len(self.sheet_handler.get_data()):
logging.info(f"Alle Zeilen in Spalte '{start_col_key}' sind gefüllt. Nichts zu tun für Modus '{mode}'.")
return
# Diese Startzeile (0-basiert in Daten) wird nicht direkt an die Batch-Methoden übergeben,
# da diese die Startzeile (1-basiert im Sheet) benötigen, um über die GESAMTE Liste zu iterieren.
# Wir berechnen hier nur zur Info die Start-Sheet-Zeile.
start_sheet_row_info = start_data_index + header_rows + 1
logging.info(f"Erste Zeile mit leerem Timestamp in Spalte '{start_col_key}' ist Sheet-Zeile {start_sheet_row_info}.")
# Die Batch-Methoden (process_verification_batch etc.) müssen ihre eigene Startzeilensuche durchführen,
# oder wir übergeben die Daten ab dieser Zeile.
# Aktuell machen die Batch-Methoden ihre eigene get_start_row_index Suche.
# Das ist redundant, aber funktioniert. Behalten wir das vorerst bei.
# Im Refactoring kann man die Batch-Methoden so ändern, dass sie ab einem übergebenen Index iterieren.
# Aufruf der spezifischen Batch-Methoden
try:
# Diese Methoden müssen in der DataProcessor Klasse implementiert sein
if mode == "wiki_batch": self.process_verification_batch(limit=limit)
elif mode == "website_scrape_batch": self.process_website_batch(limit=limit)
elif mode == "summarize_batch": self.process_summarization_batch(limit=limit) # Sucht Startzeile intern
elif mode == "branch_batch": self.process_branch_batch(limit=limit)
# Combined wird oben separat behandelt
except Exception as e:
logging.exception(f"FEHLER in DataProcessor Batch Dispatcher im Modus '{mode}': {e}")
logging.info(f"DataProcessor Batch Dispatcher für Modus '{mode}' abgeschlossen.")
# --- Neue Funktion: Benutzerinterface & Modus Dispatcher ---
# Diese Funktion ist die neue Steuerzentrale, die das Menü anzeigt und die Aufrufe delegiert.
# Sie ersetzt den grossen if/elif Block in der alten main Funktion.
# Annahme: DataProcessor Klasse ist definiert und instanziert
# Annahme: Globale Kriterien-Funktionen (criteria_xxx) sind definiert
# Annahme: Globale Dienstprogramm-Funktionen (alignment_demo) sind definiert
# Annahme: logging ist konfiguriert
def run_user_interface(data_processor, cli_mode=None, cli_limit=None, cli_start_row=None, cli_steps=None, cli_min_umsatz=None, cli_min_employees=None):
"""
Implementiert das interaktive Menü zur Modusauswahl oder verarbeitet CLI-Argumente.
Ruft die entsprechenden Methoden der DataProcessor-Instanz auf.
"""
mode_info = None
row_limit = cli_limit
start_row = cli_start_row # Optional für sequenzielle Verarbeitung
# CLI Steps für Re-Eval werden hier verarbeitet
steps_list = [step.strip().lower() for step in (cli_steps.split(',') if cli_steps else [])]
# Definition der Hauptmodi (Numerisch -> Name -> Beschreibung)
# Definieren Sie dieses Dictionary hier oder global
MAIN_MODES = {
1: {"name": "sequential", "description": "Sequenzielle Zeilenverarbeitung", "requires_limit": True, "requires_start_row": True, "is_single_row_processing_mode": True},
2: {"name": "reeval", "description": "Re-evaluate markierte Zeilen (Spalte A='x')", "requires_limit": True, "is_single_row_processing_mode": True},
3: {"name": "criteria", "description": "Prozessiere Zeilen, die Kriterien erfüllen", "requires_limit": True, "is_single_row_processing_mode": True},
4: {"name": "batch", "description": "Batch-Verarbeitung (Schritt-optimiert)", "requires_limit": True, "is_single_row_processing_mode": False},
5: {"name": "dienstprogramme", "description": "Einzelne Dienstprogramme / Suchen", "requires_limit": False, "is_single_row_processing_mode": False},
}
# --- Modus Auswahl (CLI hat Priorität über Interaktiv) ---
if cli_mode:
# Finde Modus Info basierend auf CLI Name
found_mode = [info for num, info in MAIN_MODES.items() if info["name"] == cli_mode]
if found_mode:
mode_info = found_mode[0]
logging.info(f"Hauptmodus (aus Kommandozeile): {mode_info['name']}")
else:
logging.error(f"Ungültiger Hauptmodus '{cli_mode}' via Kommandozeile. Gültige Modi: {', '.join([info['name'] for info in MAIN_MODES.values()])}")
return # Skript beenden bei ungültigem CLI Modus
else:
# Interaktiver Modus - Stufe 1 Menü
print("\n--- Hauptmodus wählen ---")
for num, info in MAIN_MODES.items():
print(f" {num}: {info['description']}")
while mode_info is None:
try:
choice = input("Geben Sie die Zahl des Hauptmodus ein: ").strip()
mode_num = int(choice)
if mode_num in MAIN_MODES:
mode_info = MAIN_MODES[mode_num]
logging.info(f"Hauptmodus (interaktiv gewählt): {mode_info['name']}")
else:
print("Ungültige Zahl. Bitte versuchen Sie es erneut.")
except ValueError: print("Ungültige Eingabe. Bitte geben Sie eine Zahl ein.")
except Exception as e: logging.error(f"Fehler bei Hauptmodus-Eingabe: {e}"); return # Skript beenden
# --- Abfrage weiterer Parameter basierend auf Hauptmodus ---
flags_for_steps = None # Wird für Zeilenverarbeitungsmodi gesetzt
selected_batch_mode = None # Wird für Batch-Modus gesetzt
selected_dienstprogramm = None # Wird für Dienstprogramme gesetzt
criteria_func = None # Wird für Kriterien-Modus gesetzt
force_step_reeval = False # Bestimmt force_reeval in _process_single_row für Criteria Mode
# --- Ebene 2/3: Spezifische Aktion / Schritte wählen / Bereich & Kriterien ---
# Fall: Zeilenverarbeitung (Sequentiell, Re-Eval, Kriterien)
if mode_info["is_single_row_processing_mode"]:
# --- Schrittauswahl für Zeilenverarbeitung ---
# Wenn Schritte nicht über CLI (--steps) gesetzt wurden, frage interaktiv.
if not cli_steps:
logging.info("\n--- Schritte für Zeilenverarbeitung auswählen ---")
print("\nWelche Verarbeitungsschritte sollen für die Zeilen ausgeführt werden?")
STEP_OPTIONS = {
1: {"name": "initial_search", "description": "Initial-Suchen (SerpAPI Website/Wiki, LinkedIn Kontakte)", "steps": ['initial_search']}, # Schrittnamen müssen mit denen in _process_single_row übereinstimmen (zukünftig)
2: {"name": "core_extraction", "description": "Kern-Extraktion (Wiki Daten, Web Scraping, Web Summary)", "steps": ['website', 'wiki']}, # Namen der Gruppen-Flags
3: {"name": "ki_enrichment", "description": "KI/Logik Anreicherung (Wiki Verify, Branch, FSM, MA Schätzung, Umsatz Schätzung, Konsistenz)", "steps": ['wiki_verify', 'chatgpt']}, # Namen der Gruppen-Flags
4: {"name": "all_enrichment", "description": "Alle oben genannten Anreicherungs-Schritte (1+2+3)", "steps": ['initial_search', 'website', 'wiki', 'wiki_verify', 'chatgpt']}, # Alle relevanten Gruppen-Flags
# Optional detailliertere Schritte hier einfügen ('wiki_extract', 'wiki_verify', 'branch_eval', 'fsm_eval', 'ma_est', 'umsatz_est', 'ma_cons', 'umsatz_cons', 'website_scrape', 'website_summary', 'website_lookup', 'linkedin_contacts')
}
selected_step_option = None
while selected_step_option is None:
try:
for num, info in STEP_OPTIONS.items(): print(f" {num}: {info['description']}")
step_choice = input("Geben Sie die Zahl der Schrittgruppe ein: ").strip(); step_num = int(step_choice);
if step_num in STEP_OPTIONS: selected_step_option = STEP_OPTIONS[step_num]; steps_list = selected_step_option["steps"]; logging.info(f"Schrittgruppe gewählt: {selected_step_option['name']}");
else: print("Ungültige Zahl für Schritte.");
except ValueError: print("Ungültige Eingabe.");
except Exception as e: logging.error(f"Fehler bei Schritt-Eingabe: {e}"); return;
# Mappen der Schritt-String-Liste auf die boolschen Flags für _process_single_row
# Diese Flags müssen mit den Parameternamen von _process_single_row übereinstimmen (process_wiki, process_chatgpt, process_website)
# Im Refactoring werden dies detailliertere Flags in einem Dictionary sein.
# Vorerst: Mappe auf die 3 groben Flags
process_wiki_flag = 'wiki' in steps_list or 'wiki_verify' in steps_list or 'wiki_extraction' in steps_list # Wenn irgendein Wiki-Schritt gewählt
process_chatgpt_flag = 'chatgpt' in steps_list or 'branch_eval' in steps_list or 'fsm_eval' in steps_list or 'ma_est' in steps_list or 'umsatz_est' in steps_list or 'ma_cons' in steps_list or 'umsatz_cons' in steps_list # Wenn irgendein ChatGPT/KI Schritt gewählt
process_website_flag = 'web' in steps_list or 'website_scrape' in steps_list or 'website_summary' in steps_list # Wenn irgendein Website-Schritt gewählt
# Setzen der flags_for_steps für den Aufruf von _process_single_row (mit den 3 groben Flags)
flags_for_steps = {
'process_wiki': process_wiki_flag,
'process_chatgpt': process_chatgpt_flag,
'process_website': process_website_flag
}
# Logge die gemappten Flags
logging.info(f"Gemappte _process_single_row Flags: wiki={process_wiki_flag}, chatgpt={process_chatgpt_flag}, website={process_website_flag}")
# --- Kriterien Auswahl für 'criteria' Modus ---
if mode_info["name"] == "criteria":
logging.info("\n--- Kriterium für Zeilenauswahl wählen ---")
print("\nWelches Kriterium soll für die Zeilenauswahl angewendet werden?")
CRITERIA_OPTIONS = {
1: {"name": "m_filled_an_empty", "description": "Wiki URL (M) gefüllt UND Wiki Timestamp (AN) leer", "func": criteria_m_filled_an_empty}, # Globale Funktion
2: {"name": "ao_empty", "description": "Timestamp letzte Prüfung (AO) leer", "func": criteria_ao_empty}, # Globale Funktion
3: {"name": "ar_empty", "description": "Website Rohtext (AR) leer", "func": criteria_ar_empty}, # Globale Funktion
4: {"name": "ax_empty", "description": "Wiki Verif. Timestamp (AX) leer", "func": criteria_ax_empty}, # Globale Funktion
# Fügen Sie hier weitere Kriterien hinzu
5: {"name": "size_meets_threshold", "description": f"Umsatz CRM > {cli_min_umsatz} MIO € ODER Mitarbeiter CRM > {cli_min_employees}", "func": lambda row_data: criteria_size_meets_threshold(row_data, cli_min_employees, cli_min_umsatz)}, # Kriterium mit Parametern
}
selected_criteria_option = None
while selected_criteria_option is None:
try:
for num, info in CRITERIA_OPTIONS.items(): print(f" {num}: {info['description']}")
criteria_choice = input("Geben Sie die Zahl des Kriteriums ein: ").strip(); criteria_num = int(criteria_choice);
if criteria_num in CRITERIA_OPTIONS: selected_criteria_option = CRITERIA_OPTIONS[criteria_num]; criteria_func = selected_criteria_option["func"]; logging.info(f"Kriterium gewählt: {selected_criteria_option['name']}");
else: print("Ungültige Zahl für Kriterium.");
except ValueError: print("Ungültige Eingabe.");
except Exception as e: logging.error(f"Fehler bei Kriterien-Eingabe: {e}"); return;
# Für Criteria Modus: Abfragen, ob force_reeval für Schritte angewendet werden soll
reeval_criteria_input = input("Force re-evaluate (Timestamp/Status ignorieren) für diese Schritte bei passenden Zeilen? (j/N): ").strip().lower()
force_step_reeval = (reeval_criteria_input == 'j')
logging.info(f"Force re-evaluate für Kriterien-Modus Schritte: {force_step_reeval}")
# Fall: Batch-Verarbeitung (Ebene 1 = 4)
elif mode_info["name"] == "batch":
# Hier das Menü für die Auswahl des Batch-Modus anzeigen
logging.info("\n--- Batch-Modus auswählen ---")
print("\nWelchen Batch-Modus möchten Sie ausführen?")
BATCH_MODES = {
1: {"name": "wiki_batch", "description": "Wikipedia-Verifizierung (AX)"},
2: {"name": "website_scrape_batch", "description": "Website-Scraping Rohtext (AT)"},
3: {"name": "summarize_batch", "description": "Website-Zusammenfassung (AS)"},
4: {"name": "branch_batch", "description": "Branchen-Einstufung (AO)"},
# 5: {"name": "combined", "description": "Alle Batch-Modi nacheinander"}, # Optional
}
selected_batch_mode_info = None
while selected_batch_mode_info is None:
try:
for num, info in BATCH_MODES.items(): print(f" {num}: {info['description']}");
batch_choice = input("Geben Sie die Zahl des Batch-Modus ein: ").strip(); batch_num = int(batch_choice);
if batch_num in BATCH_MODES: selected_batch_mode_info = BATCH_MODES[batch_num]; selected_batch_mode = selected_batch_mode_info["name"]; logging.info(f"Batch-Modus gewählt: {selected_batch_mode}");
else: print("Ungültige Zahl für Batch-Modus.");
except ValueError: print("Ungültige Eingabe.");
except Exception as e: logging.error(f"Fehler bei Batch-Modus-Eingabe: {e}"); return;
# Fall: Dienstprogramme (Ebene 1 = 5)
elif mode_info["name"] == "dienstprogramme":
# Hier das Menü für die Auswahl des Dienstprogramms anzeigen
logging.info("\n--- Dienstprogramm auswählen ---")
print("\nWelches Dienstprogramm möchten Sie ausführen?")
DIENSTPROGRAMM_MODES = {
1: {"name": "find_wiki_serp", "description": "Finde fehlende Wiki-URLs via SerpAPI"},
2: {"name": "website_lookup", "description": "Finde fehlende Website-URLs via SerpAPI"},
3: {"name": "contacts", "description": "Suche LinkedIn Kontakte via SerpAPI"},
4: {"name": "update_wiki_suggestions", "description": "Übernehme Wiki-Vorschläge aus U nach M"},
5: {"name": "train_technician_model", "description": "Trainiere ML Technikermodell"},
6: {"name": "alignment", "description": "Schreibe Header (A1:AY5)"},
# 7: {"name": "website_details", "description": "EXPERIMENTELL: Extrahiere Website-Details für 'x' Zeilen"}, # Ggf. ausblenden
8: {"name": "wiki_reextract", "description": "Wiki Re-Extraction (M gefüllt, AN leer) - Übergang"}, # Übergangsmodus
}
selected_dienstprogramm_info = None
while selected_dienstprogramm_info is None:
try:
for num, info in DIENSTPROGRAMM_MODES.items(): print(f" {num}: {info['description']}");
dp_choice = input("Geben Sie die Zahl des Dienstprogramms ein: ").strip(); dp_num = int(dp_choice);
if dp_num in DIENSTPROGRAMM_MODES: selected_dienstprogramm_info = DIENSTPROGRAMM_MODES[dp_num]; selected_dienstprogramm = selected_dienstprogramm_info["name"]; logging.info(f"Dienstprogramm gewählt: {selected_dienstprogramm}");
else: print("Ungültige Zahl für Dienstprogramm.");
except ValueError: print("Ungültige Eingabe.");
except Exception as e: logging.error(f"Fehler bei Dienstprogramm-Eingabe: {e}"); return;
# --- Abfrage Limit & Startzeile (Falls noch nicht gesetzt und benötigt) ---
# Limit ist bereits als CLI arg oder interaktiv abgefragt, wenn mode_info["requires_limit"] True ist.
# Startzeile wird nur für Sequenziell benötigt.
if mode_info["name"] == "sequential" and start_row is None:
try:
start_row_input = input(f"Startzeile (1-basiert) für sequenzielle Verarbeitung? (Enter=automatisch ermitteln): ").strip();
if start_row_input:
start_row_val = int(start_row_input);
if start_row_val >= 1: start_row = start_row_val;
else: logging.warning("Ungültige Startzeile ignoriert (<=0)."); start_row = None;
else: logging.info("Startzeile wird automatisch ermittelt.");
logging.info(f"Startzeile für sequenzielle Verarbeitung: {start_row if start_row is not None else 'Automatisch'}");
except ValueError: logging.warning("Ungültige Startzeilen-Eingabe ignoriert."); start_row = None;
except Exception as e: logging.error(f"Fehler bei Startzeilen-Eingabe: {e}"); start_row = None;
# --- Modus Ausführung basierend auf Auswahl ---
try:
# Fall: Zeilenverarbeitung (Sequenziell, Re-Eval, Kriterien)
if mode_info["is_single_row_processing_mode"]:
if flags_for_steps is None or not any(flags_for_steps.values()):
logging.warning("Keine Verarbeitungsschritte für Zeilenverarbeitung ausgewählt oder Fehler bei Auswahl. Nichts zu tun."); return;
if mode_info["name"] == "sequential":
# Sequenzielle Verarbeitung ruft process_sequential Methode auf
# start_row ist die 1-basierte Sheet-Zeile, wird so an process_sequential übergeben.
# process_sequential kümmert sich um die Konvertierung zu Daten-Index und Iteration.
# Wenn start_row None ist, wird es in process_sequential automatisch ermittelt.
data_processor.process_sequential(
start_sheet_row = start_row, # Kann None sein
num_to_process = row_limit, # Kann None sein
process_wiki = flags_for_steps.get('process_wiki', False), # <<< ÜBERGIBT DIE STEUERUNG
process_chatgpt = flags_for_steps.get('process_chatgpt', False), # <<< ÜBERGIBT DIE STEUERUNG
process_website = flags_for_steps.get('process_website', False) # <<< ÜBERGIBT DIE STEUERUNG
);
elif mode_info["name"] == "reeval":
# Re-Evaluate ruft process_reevaluation_rows Methode auf
# row_limit und flags_for_steps sind bereits gesetzt
data_processor.process_reevaluation_rows(
row_limit = row_limit,
clear_flag = True, # Standardmäßig Flag 'x' löschen
process_wiki_steps = flags_for_steps.get('process_wiki', False), # <<< ÜBERGIBT DIE STEUERUNG
process_chatgpt_steps = flags_for_steps.get('process_chatgpt', False), # <<< ÜBERGIBT DIE STEUERUNG
process_website_steps = flags_for_steps.get('process_website', False) # <<< ÜBERGIBT DIE STEUERUNG
);
elif mode_info["name"] == "criteria":
# Kriterienbasierte Verarbeitung ruft process_rows_matching_criteria Methode auf
# limit, flags_for_steps, criteria_func, force_step_reeval sind bereits gesetzt
if criteria_func is None: logging.error("FEHLER: Kriterien-Funktion nicht gesetzt. Abbruch."); return;
data_processor.process_rows_matching_criteria(
criteria_func = criteria_func,
limit = row_limit,
process_wiki = flags_for_steps.get('process_wiki', False),
process_chatgpt = flags_for_steps.get('process_chatgpt', False),
process_website = flags_for_steps.get('process_website', False),
force_step_reeval = force_step_reeval
);
# Fall: Batch-Verarbeitung (Ebene 1 = 4)
elif mode_info["name"] == "batch":
if selected_batch_mode is None: logging.warning("Kein Batch-Modus ausgewählt oder Fehler bei Auswahl. Nichts zu tun."); return;
# Batch Dispatcher Methode aufrufen
data_processor.run_batch_dispatcher(
mode = selected_batch_mode, # Gewählten Batch-Modus Namen übergeben
limit = row_limit # Limit übergeben
);
# Fall: Dienstprogramme (Ebene 1 = 5)
elif mode_info["name"] == "dienstprogramme":
if selected_dienstprogramm is None: logging.warning("Kein Dienstprogramm ausgewählt oder Fehler bei Auswahl. Nichts zu tun."); return;
# Aufruf der spezifischen Dienstprogramm-Methode
if selected_dienstprogramm == "find_wiki_serp":
# Parameter werden über CLI args (args.min_umsatz, args.min_employees) oder Defaults genommen
data_processor.process_find_wiki_serp(row_limit=row_limit, min_employees=cli_min_employees, min_umsatz=cli_min_umsatz); # <<< CLI args hier übergeben
elif selected_dienstprogramm == "website_lookup":
data_processor.process_serp_website_lookup(limit=row_limit);
elif selected_dienstprogramm == "contacts":
data_processor.process_contact_research(limit=row_limit); # limit parameter hinzufügen
elif selected_dienstprogramm == "update_wiki_suggestions":
data_processor.process_wiki_updates_from_chatgpt(row_limit=row_limit);
elif selected_dienstprogramm == "train_technician_model":
# Parameter werden über CLI args genommen
data_processor.train_technician_model(model_out=args.model_out, imputer_out=args.imputer_out, patterns_out=args.patterns_out); # <<< CLI args hier übergeben
elif selected_dienstprogramm == "alignment":
# alignment_demo ist global und braucht sheet_handler.sheet
alignment_demo(data_processor.sheet_handler.sheet);
elif selected_dienstprogramm == "website_details":
data_processor.process_website_details(limit=row_limit); # limit parameter hinzufügen
elif selected_dienstprogramm == "wiki_reextract":
# Dies ist der Übergangsmodus, der die temporäre globale Funktion nutzt
# Annahme: process_wiki_reextract_missing_an ist global
process_wiki_reextract_missing_an(data_processor.sheet_handler, data_processor, limit=row_limit);
except KeyboardInterrupt: logging.warning("Skript durch Benutzer unterbrochen (KeyboardInterrupt)."); print("\n! Skript wurde manuell beendet.");
except Exception as e: logging.critical(f"FATAL: Unerwarteter Fehler während der Ausführung von Modus '{mode_info.get('name', 'Unbekannt')}': {e}"); logging.exception("Traceback des kritischen Fehlers:");
# ==================== MAIN EXECUTION BLOCK ====================
# Diese Funktion ist der eigentliche Startpunkt des Skripts, wenn die Datei ausgeführt wird.
# main Funktion
def main():
# WICHTIG: Global LOG_FILE wird benötigt, aber erst nach Arg-Parsing gesetzt.
global LOG_FILE
# --- Initial Logging Setup (Konfiguration von Level und Format) ---
import logging
log_level = logging.DEBUG
log_format = '%(asctime)s - %(levelname)-8s - %(name)-15s - %(message)s'
logging.basicConfig(level=log_level, format=log_format, handlers=[])
console_handler = logging.StreamHandler(); console_handler.setLevel(log_level); console_handler.setFormatter(logging.Formatter(log_format));
logging.getLogger('').addHandler(console_handler);
# --- Initialisierung (Argument Parser etc.) ---
# Version hier (sollte mit Config.VERSION übereinstimmen)
current_script_version = "v1.6.7" # <<< ANPASSEN, wenn Config.VERSION geändert wird
parser = argparse.ArgumentParser(description=f"Firmen-Datenanreicherungs-Skript {current_script_version}");
# Liste der gültigen Hauptmodi (Namen) für CLI
valid_main_modes = ["sequential", "reeval", "criteria", "batch", "dienstprogramme"];
parser.add_argument("--mode", type=str, help=f"Hauptbetriebsmodus ({', '.join(valid_main_modes)})");
parser.add_argument("--limit", type=int, help="Maximale Anzahl zu verarbeitender Zeilen", default=None);
parser.add_argument("--start_row", type=int, help="Startzeile im Sheet (1-basiert) für sequenzielle Modi", default=None);
# NEUES ARGUMENT für den Re-Eval Modus zur Auswahl der Schritte
# Standard ist "wiki,chat,web", um das bisherige Verhalten zu imitieren
# Mögliche Werte für die Schritte: 'wiki', 'chat', 'web' (entsprechend den Parametern in _process_single_row)
# Im Refactoring werden dies detailliertere Namen sein.
parser.add_argument("--steps", type=str, help="Komma-getrennte Liste der Schritte im 'reeval' Modus (z.B. 'wiki,chat,web'). Mögliche Schritte: wiki, chat, web.", default="wiki,chat,web");
# Argumente für find_wiki_serp (falls über CLI gesteuert)
parser.add_argument("--min_umsatz", type=int, help="Mindestumsatz in Mio € für find_wiki_serp", default=200);
parser.add_argument("--min_employees", type=int, help="Mindestmitarbeiterzahl für find_wiki_serp", default=500);
# Argumente für train_technician_model
parser.add_argument("--model_out", type=str, default=MODEL_FILE, help=f"Pfad für Modell (.pkl)");
parser.add_argument("--imputer_out", type=str, default=IMPUTER_FILE, help=f"Pfad für Imputer (.pkl)");
parser.add_argument("--patterns_out", type=str, default=PATTERNS_FILE_TXT, help=f"Pfad für Regeln (.txt)");
# TODO: Fügen Sie hier weitere CLI-Argumente hinzu, falls andere Modi Parameter benötigen (z.B. für Kriterien-Modus spezifische Parameter)
args = parser.parse_args();
# Lade API Keys direkt am Anfang
Config.load_api_keys(); # Nutzt jetzt logging intern
# --- Logdatei-Konfiguration abschließen ---
log_mode_name = args.mode if args.mode else "interactive"; # Verwenden Sie den CLI Modus Namen, wenn vorhanden
LOG_FILE = create_log_filename(log_mode_name); # Annahme: create_log_filename ist global
try:
file_handler = logging.FileHandler(LOG_FILE, mode='a', encoding='utf-8'); file_handler.setLevel(log_level); file_handler.setFormatter(logging.Formatter(log_format));
logging.getLogger('').addHandler(file_handler); logging.info(f"Logging wird jetzt auch in Datei geschrieben: {LOG_FILE}");
except Exception as e:
print(f"[ERROR] Konnte FileHandler für Logdatei '{LOG_FILE}' nicht erstellen: {e}");
logging.getLogger('').handlers = [h for h in logging.getLogger('').handlers if not isinstance(h, logging.FileHandler)];
logging.error(f"Konnte FileHandler für Logdatei '{LOG_FILE}' nicht erstellen: {e}");
# --- JETZT die Startmeldungen loggen (gehen jetzt in Konsole UND Datei) ---
logging.info(f"===== Skript gestartet ====="); logging.info(f"Version: {Config.VERSION}"); logging.info(f"Logdatei: {LOG_FILE}");
if args.mode: logging.info(f"Betriebsmodus (CLI): {args.mode}");
if args.limit is not None: logging.info(f"CLI Argument --limit: {args.limit}");
if args.start_row is not None: logging.info(f"CLI Argument --start_row: {args.start_row}");
if 'steps' in args and args.steps: logging.info(f"CLI Argument --steps: '{args.steps}' (relevant für 'reeval' Modus)");
if 'min_umsatz' in args: logging.info(f"CLI Argument --min_umsatz: {args.min_umsatz}");
if 'min_employees' in args: logging.info(f"CLI Argument --min_employees: {args.min_employees}");
if 'model_out' in args: logging.info(f"CLI Argument --model_out: '{args.model_out}'");
if 'imputer_out' in args: logging.info(f"CLI Argument --imputer_out: '{args.imputer_out}'");
if 'patterns_out' in args: logging.info(f"CLI Argument --patterns_out: '{args.patterns_out}'");
# --- Vorbereitung (Schema, Sheet Handler etc.) ---
load_target_schema(); # Annahme: load_target_schema ist global definiert
try: sheet_handler = GoogleSheetHandler(); # Annahme: GoogleSheetHandler ist global definiert
except Exception as e: logging.critical(f"FATAL: Initialisierung GoogleSheetHandlers fehlgeschlagen: {e}"); logging.critical(f"Bitte Logdatei prüfen: {LOG_FILE}"); return;
try: wiki_scraper = WikipediaScraper(); # Annahme: WikipediaScraper ist global definiert
except Exception as e: logging.critical(f"FATAL: Initialisierung WikipediaScrapers fehlgeschlagen: {e}"); logging.critical(f"Bitte Logdatei prüfen: {LOG_FILE}"); return;
# Initialisiere DataProcessor Instanz mit Handlern
data_processor = DataProcessor(sheet_handler, wiki_scraper); # <<< KORRIGIERTER AUFRUF
# --- Start der Benutzerinteraktion / Modusausführung ---
start_time = time.time(); logging.info(f"Starte Verarbeitung um {datetime.now().strftime('%H:%M:%S')}...");
try:
# Rufe die Funktion auf, die das Menü und den Dispatching übernimmt
# Wenn CLI args gesetzt sind, wird das Menü übersprungen.
run_user_interface(
data_processor = data_processor, # Instanz übergeben
cli_mode = args.mode,
cli_limit = args.limit,
cli_start_row = args.start_row,
cli_steps = args.steps, # <<< NEU: steps Argument übergeben
cli_min_umsatz = args.min_umsatz, # <<< NEU: min_umsatz übergeben
cli_min_employees = args.min_employees # <<< NEU: min_employees übergeben
# Weitere CLI args hier übergeben, falls nötig (z.B. für train_technician_model)
);
except KeyboardInterrupt: logging.warning("Skript durch Benutzer unterbrochen (KeyboardInterrupt)."); print("\n! Skript wurde manuell beendet.");
except Exception as e: logging.critical(f"FATAL: Unerwarteter Fehler im Hauptausführungsblock: {e}"); logging.exception("Traceback des kritischen Fehlers:");
# --- Abschluss ---
end_time = time.time(); duration = end_time - start_time;
logging.info(f"Verarbeitung abgeschlossen um {datetime.now().strftime('%H:%M:%S')}."); logging.info(f"Gesamtdauer: {duration:.2f} Sekunden."); logging.info(f"===== Skript beendet =====");
# Schließe Logging Handler explizit
logging.shutdown();
# Logfile Pfad für den Nutzer ausgeben
print(f"\nVerarbeitung abgeschlossen. Logfile: {LOG_FILE}");
# ==================== __main__ BLOCK ====================
# Dieser Block wird ausgeführt, wenn das Skript direkt gestartet wird.
if __name__ == '__main__':
# --- Sicherstellen, dass alle globalen Imports hier sind ---
# ... (alle Imports wie am Anfang des Skripts) ...
# --- Sicherstellen, dass alle globalen Helfer-Funktionen hier oder importiert sind ---
# Kopieren Sie die Definitionen der globalen Helfer Funktionen hierher oder stellen Sie sicher, dass sie importiert werden können.
# Global Helper Functions: clean_text, simple_normalize_url, normalize_string,
# extract_numeric_value, get_numeric_filter_value, fuzzy_similarity, token_count,
# call_openai_chat, summarize_website_content, evaluate_branche_chatgpt,
# is_valid_wikipedia_article_url, serp_website_lookup, serp_wikipedia_lookup,
# search_linkedin_contacts, get_gender, get_email_address, load_target_schema,
# map_external_branch, alignment_demo, retry_on_failure, create_log_filename,
# debug_print, _process_batch (falls global),
# Kriterien-Funktionen (criteria_m_filled_an_empty, criteria_size_meets_threshold etc.),
# Übergangsfunktionen (process_wiki_reextract_missing_an).
# --- Sicherstellen, dass alle Klassen hier definiert sind ---
# Kopieren Sie die Definitionen der Klassen hierher oder stellen Sie sicher, dass sie importiert werden können.
# Klassen: Config, GoogleSheetHandler, WikipediaScraper, DataProcessor.
# Die main Funktion aufrufen
main();