10947 lines
687 KiB
Python
10947 lines
687 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Automatisiertes Unternehmensbewertungs-Skript - Refactoring v1.7.8
|
||
Basierend auf v1.6.x - Umstrukturierung in modulare Klassen und flexibles UI.
|
||
|
||
Dieses Skript dient der automatisierten Anreicherung, Validierung und Standardisierung
|
||
von Unternehmensdaten, primär aus einem Google Sheet, ergänzt durch Web Scraping,
|
||
Wikipedia, OpenAI (ChatGPT) und SerpAPI (Google Search, LinkedIn).
|
||
|
||
Autor: Christian Godelmann
|
||
Version: v1.7.8
|
||
|
||
Hinweis zur Struktur:
|
||
Dieser Code wird in logischen Bloecken uebermittelt. Fuegen Sie die Bloecke
|
||
nacheinander in diese einzige Datei ein, achten Sie sorgfaeltig auf die
|
||
Einrueckung. Jeder Block muss auf oberster Ebene eingefuegt werden (keine Einrueckung).
|
||
Die Kommentare wie '# =================================================='
|
||
markieren den Beginn neuer logischer Sektionen oder Klassen.
|
||
"""
|
||
|
||
# ==============================================================================
|
||
# 1. IMPORTS
|
||
# ==============================================================================
|
||
# Standardbibliotheken
|
||
import os
|
||
import time
|
||
import re
|
||
import csv
|
||
import json
|
||
import pickle
|
||
import threading
|
||
import traceback
|
||
import logging # logging Modul importieren
|
||
import argparse
|
||
import random # Fuer Jitter im Retry Decorator
|
||
from datetime import datetime
|
||
from urllib.parse import urlparse, urlencode, unquote
|
||
import openai # Sicherstellen, dass openai global importiert wird
|
||
# Externe Bibliotheken
|
||
import gspread
|
||
# Stellen Sie sicher, dass gspread >= 5.0.0 installiert ist, da APIError anders behandelt wird
|
||
# (Unser Code sollte mit den neueren Versionen kompatibel sein)
|
||
import wikipedia
|
||
# Stellen Sie sicher, dass wikipedia-api nicht gleichzeitig installiert ist (Konflikt).
|
||
import requests # Fuer HTTP-Anfragen
|
||
# Stellen Sie sicher, dass requests >= 2.0.0 installiert ist
|
||
from bs4 import BeautifulSoup # Fuer HTML-Parsing
|
||
# Stellen Sie sicher, dass lxml oder html5lib installiert ist (empfohlen statt html.parser)
|
||
# z.B. pip install lxml
|
||
from oauth2client.service_account import ServiceAccountCredentials # gspread dependency
|
||
from difflib import SequenceMatcher # Fuer String-Aehnlichkeit
|
||
import unicodedata # Fuer Text-Normalisierung
|
||
|
||
# Bibliotheken fuer Datenanalyse und ML
|
||
import pandas as pd
|
||
import numpy as np
|
||
from imblearn.over_sampling import SMOTE
|
||
from sklearn.ensemble import RandomForestClassifier
|
||
from sklearn.model_selection import train_test_split, GridSearchCV
|
||
from sklearn.impute import SimpleImputer
|
||
from sklearn.tree import DecisionTreeClassifier, export_text
|
||
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
|
||
import concurrent.futures # Fuer parallele Verarbeitung
|
||
from sklearn.model_selection import GridSearchCV
|
||
from imblearn.pipeline import Pipeline as ImbPipeline # Alias, um Kollision mit sklearn.pipeline zu vermeiden
|
||
|
||
# Spezifische externe Tools
|
||
try:
|
||
import gender_guesser.detector as gender # Fuer Geschlechtserkennung
|
||
# Initialisieren Sie den Detector einmal global (wird im Helper benutzt)
|
||
gender_detector = gender.Detector()
|
||
print("gender_guesser.Detector initialisiert.")
|
||
except ImportError:
|
||
gender = None
|
||
gender_detector = None
|
||
print("gender_guesser Bibliothek nicht gefunden. Geschlechtserkennung deaktiviert.")
|
||
except Exception as e:
|
||
gender = None
|
||
gender_detector = None
|
||
print(f"Fehler bei Initialisierung von gender_guesser: {e}. Geschlechtserkennung deaktiviert.")
|
||
|
||
|
||
# Optional: tiktoken fuer Token-Zaehlung (Modus 8)
|
||
try:
|
||
import tiktoken
|
||
print("tiktoken importiert.") # Debugging-Ausgabe (geht nur an Konsole vor Logger Setup)
|
||
except ImportError:
|
||
tiktoken = None
|
||
print("tiktoken nicht gefunden. Token-Zaehlung wird geschaetzt.") # Debugging-Ausgabe
|
||
|
||
|
||
# ==============================================================================
|
||
# 2. GLOBALE KONSTANTEN UND KONFIGURATION
|
||
# (Logisch 'config.py')
|
||
# ==============================================================================
|
||
|
||
# --- Dateipfade ---
|
||
CREDENTIALS_FILE = "service_account.json"
|
||
API_KEY_FILE = "api_key.txt" # OpenAI
|
||
SERP_API_KEY_FILE = "serpApiKey.txt"
|
||
GENDERIZE_API_KEY_FILE = "genderize_API_Key.txt"
|
||
BRANCH_MAPPING_FILE = "ziel_Branchenschema.csv" # Enthält Zielschema
|
||
LOG_DIR = "Log"
|
||
|
||
# --- ML Modell Artefakte ---
|
||
MODEL_FILE = "technician_decision_tree_model.pkl"
|
||
IMPUTER_FILE = "median_imputer.pkl"
|
||
PATTERNS_FILE_TXT = "technician_patterns.txt" # Alt (Optional beibehalten)
|
||
PATTERNS_FILE_JSON = "technician_patterns.json" # Neu (Empfohlen)
|
||
|
||
def load_branch_mapping(file_path='Branchen.csv'):
|
||
logger = logging.getLogger(__name__) # Logger holen, um sicherzugehen
|
||
|
||
# +++ Zusätzlicher Debugging-Schritt: Pfad und Existenz prüfen +++
|
||
absolute_path = os.path.abspath(file_path)
|
||
logger.info(f"Versuche Branchen-Mapping zu laden von: '{absolute_path}'")
|
||
if not os.path.exists(file_path):
|
||
logger.error(f"DATEI NICHT GEFUNDEN an Pfad: '{absolute_path}'")
|
||
return {} # Gib leeres Dict zurück, wenn Datei nicht existiert
|
||
|
||
try:
|
||
df_mapping = pd.read_csv(file_path, sep=';', encoding='utf-8-sig')
|
||
logger.info(f"Datei '{file_path}' erfolgreich mit pandas gelesen. {len(df_mapping)} Zeilen gefunden.")
|
||
logger.debug(f"Gelesene Spaltennamen: {list(df_mapping.columns)}")
|
||
|
||
# Spaltennamen bereinigen
|
||
df_mapping.columns = [str(col).strip() for col in df_mapping.columns]
|
||
logger.debug(f"Bereinigte Spaltennamen: {list(df_mapping.columns)}")
|
||
|
||
expected_cols = ['Branch Group', 'Branch']
|
||
if not all(col in df_mapping.columns for col in expected_cols):
|
||
logger.error(f"FEHLER: Erwartete Spalten {expected_cols} in '{file_path}' nicht gefunden. "
|
||
f"Gefundene Spalten nach Bereinigung: {list(df_mapping.columns)}")
|
||
return {}
|
||
|
||
# Normalisierte Keys erstellen
|
||
df_mapping['normalized_keys'] = df_mapping['Branch'].apply(normalize_for_mapping)
|
||
|
||
# Duplikate in den normalisierten Keys prüfen
|
||
if df_mapping['normalized_keys'].duplicated().any():
|
||
duplicates = df_mapping[df_mapping['normalized_keys'].duplicated()]['normalized_keys']
|
||
logger.warning(f"WARNUNG: Duplikate in normalisierten Branchen-Keys gefunden! Dies kann zu inkonsistentem Mapping führen. Duplikate: {list(duplicates)}")
|
||
|
||
branch_map_dict = pd.Series(
|
||
df_mapping['Branch Group'].str.strip().values,
|
||
index=df_mapping['normalized_keys']
|
||
).to_dict()
|
||
|
||
logger.info(f"Branchen-Mapping aus '{file_path}' erfolgreich geladen ({len(branch_map_dict)} Einträge).")
|
||
return branch_map_dict
|
||
|
||
except Exception as e:
|
||
logger.error(f"FATALER FEHLER beim Laden oder Verarbeiten der Branchen-Mapping-Datei '{file_path}':")
|
||
# Gib den vollständigen Traceback in das Log aus, um den genauen Fehlerort zu sehen
|
||
logger.error(traceback.format_exc())
|
||
return {}
|
||
|
||
# In Config-Klasse oder global aufrufen:
|
||
|
||
|
||
|
||
# --- Globale Konfiguration Klasse ---
|
||
class Config:
|
||
"""Zentrale Konfigurationseinstellungen."""
|
||
VERSION = "v1.7.8"
|
||
LANG = "de" # Sprache fuer Wikipedia etc.
|
||
# ACHTUNG: SHEET_URL ist hier ein Platzhalter. Ersetzen Sie ihn durch Ihre tatsaechliche URL.
|
||
SHEET_URL = "https://docs.google.com/spreadsheets/d/1u_gHr9JUfmV1-iviRzbSe3575QEp7KLhK5jFV_gJcgo" # <<< ERSETZEN SIE DIES!
|
||
MAX_RETRIES = 5 # Anzahl der Versuche (nicht Wiederholungen nach dem ersten Fehler) fuer wiederholbare Fehler
|
||
RETRY_DELAY = 10 # Basiswartezeit (Sekunden) fuer Retries (exponentieller Backoff wird im Decorator angewendet)
|
||
REQUEST_TIMEOUT = 20 # Timeout (Sekunden) fuer externe HTTP/API Anfragen (Requests)
|
||
SIMILARITY_THRESHOLD = 0.65 # Schwelle fuer Namensaaehnlichkeit bei Wikipedia Validierung
|
||
DEBUG = True # Detailliertes Logging aktivieren/deaktivieren
|
||
WIKIPEDIA_SEARCH_RESULTS = 5 # Anzahl Ergebnisse bei Wikipedia Suche ueber Bibliothek
|
||
HTML_PARSER = "html.parser" # Parser fuer BeautifulSoup ('lxml' ist schneller, erfordert aber Installation)
|
||
TOKEN_MODEL = "gpt-3.5-turbo" # OpenAI Modell fuer Token-Zaehlung/Chat (Standard fuer die meisten Calls)
|
||
USER_AGENT = 'Mozilla/5.0 (compatible; UnternehmenSkript/1.0; +https://www.example.com/bot)' # User-Agent fuer Web Scraping/Requests (Beispiel URL anpassen)
|
||
BRANCH_MAPPING = load_branch_mapping()
|
||
|
||
# --- Konfiguration fuer Batching & Parallelisierung ---
|
||
# Passen Sie diese Werte an die Leistung Ihres Systems und die API-Limits an.
|
||
PROCESSING_BATCH_SIZE = 20 # Anzahl Zeilen pro Verarbeitungs-Batch (fuer _process_single_row in Batches)
|
||
OPENAI_BATCH_SIZE_LIMIT = 4 # Max. Texte pro OpenAI Call fuer Zusammenfassung (nur fuer summarize_batch_openai)
|
||
MAX_SCRAPING_WORKERS = 10 # Threads fuer paralleles Website-Scraping
|
||
UPDATE_BATCH_ROW_LIMIT = 50 # Zeilen sammeln fuer gebuendelte Sheet Updates (effizienter)
|
||
MAX_BRANCH_WORKERS = 10 # Threads fuer parallele Branchenbewertung
|
||
OPENAI_CONCURRENCY_LIMIT = 3 # Max. gleichzeitige OpenAI Calls (Semaphore fuer Branch Evaluation)
|
||
PROCESSING_BRANCH_BATCH_SIZE = 20 # Batch-Groesse fuer Branch-Evaluierung
|
||
SERPAPI_DELAY = 1.5 # Pause zwischen einzelnen SerpAPI-Aufrufen (Sekunden)
|
||
|
||
|
||
PLAUSI_UMSATZ_MIN_WARNUNG = 50000
|
||
PLAUSI_UMSATZ_MAX_WARNUNG = 200000000000
|
||
PLAUSI_MA_MIN_WARNUNG_ABS = 1
|
||
PLAUSI_MA_MIN_WARNUNG_BEI_UMSATZ = 3
|
||
PLAUSI_UMSATZ_MIN_SCHWELLE_FUER_MA_CHECK = 1000000
|
||
PLAUSI_MA_MAX_WARNUNG = 1000000
|
||
PLAUSI_RATIO_UMSATZ_PRO_MA_MIN = 25000
|
||
PLAUSI_RATIO_UMSATZ_PRO_MA_MAX = 1500000
|
||
PLAUSI_ABWEICHUNG_CRM_WIKI_PROZENT = 30
|
||
|
||
|
||
# --- API Schluessel Speicherung (werden in main() geladen) ---
|
||
API_KEYS = {}
|
||
|
||
@classmethod
|
||
def load_api_keys(cls):
|
||
"""Laedt API-Schluessel aus den definierten Dateien."""
|
||
print("Lade API-Schluessel...")
|
||
cls.API_KEYS['openai'] = cls._load_key_from_file(API_KEY_FILE)
|
||
cls.API_KEYS['serpapi'] = cls._load_key_from_file(SERP_API_KEY_FILE)
|
||
cls.API_KEYS['genderize'] = cls._load_key_from_file(GENDERIZE_API_KEY_FILE)
|
||
|
||
# import openai # <--- DIESER IMPORT IST NUN ENTFERNT
|
||
|
||
if cls.API_KEYS.get('openai'):
|
||
# Stelle sicher, dass das 'openai' Modul hier im Scope ist,
|
||
# indem wir auf den globalen Import zugreifen.
|
||
# Da 'openai' schon global importiert wurde (ganz oben im Skript),
|
||
# ist es hier direkt verfügbar.
|
||
openai.api_key = cls.API_KEYS['openai']
|
||
print("OpenAI API Key erfolgreich geladen.")
|
||
else:
|
||
print("WARNUNG: OpenAI API Key konnte nicht geladen werden (Datei fehlt oder ist leer?). OpenAI-Funktionen sind deaktiviert.")
|
||
|
||
if not cls.API_KEYS.get('serpapi'):
|
||
print("WARNUNG: SerpAPI Key konnte nicht geladen werden (Datei fehlt oder ist leer?). Bestimmte Suchfunktionen sind deaktiviert.")
|
||
if not cls.API_KEYS.get('genderize'):
|
||
print("WARNUNG: Genderize API Key konnte nicht geladen werden (Datei fehlt oder ist leer?). Geschlechtserkennung ist eingeschraenkt.")
|
||
|
||
|
||
@staticmethod
|
||
def _load_key_from_file(filepath):
|
||
"""Hilfsfunktion zum Laden eines Schluessels aus einer Datei."""
|
||
try:
|
||
# Verwenden Sie "r" fuer Textmodus und geben Sie das Encoding an
|
||
with open(filepath, "r", encoding="utf-8") as f:
|
||
key = f.read().strip()
|
||
if key:
|
||
return key
|
||
else:
|
||
print(f"WARNUNG: Datei '{filepath}' ist leer.")
|
||
return None
|
||
except FileNotFoundError:
|
||
print(f"INFO: API-Schluesseldatei '{filepath}' nicht gefunden.")
|
||
return None
|
||
except Exception as e:
|
||
print(f"FEHLER beim Lesen der Schluesseldatei '{filepath}': {e}")
|
||
return None
|
||
|
||
|
||
# --- Globale Spalten-Mapping (WICHTIG: MUSS ZU IHREM SHEET PASSEN!) ---
|
||
# --- Globale Spalten-Mapping (WICHTIG: MUSS ZU IHREM SHEET PASSEN!) ---
|
||
# Version 1.7.6 - 57 Spalten (A-BE)
|
||
COLUMN_MAP = {
|
||
# CRM-Daten Teil 1 (A-C)
|
||
"ReEval Flag": 0, "CRM Name": 1, "CRM Kurzform": 2,
|
||
|
||
# Parent Account Info (D)
|
||
"Parent Account Name": 3, # D (NEU)
|
||
|
||
# CRM-Daten Teil 2 (E-N), alte D-M verschoben um +1
|
||
"CRM Website": 4, # E
|
||
"CRM Ort": 5, # F
|
||
"CRM Land": 6, # G
|
||
"CRM Beschreibung": 7, # H
|
||
"CRM Branche": 8, # I
|
||
"CRM Beschreibung Branche extern": 9, # J
|
||
"CRM Anzahl Techniker": 10, # K
|
||
"CRM Umsatz": 11, # L
|
||
"CRM Anzahl Mitarbeiter": 12, # M
|
||
"CRM Vorschlag Wiki URL": 13, # N
|
||
|
||
# System Vorschlag Parent & Status & Timestamp (O-Q)
|
||
"System Vorschlag Parent Account": 14, # O (NEU)
|
||
"Parent Vorschlag Status": 15, # P (NEU)
|
||
"Parent Vorschlag Timestamp": 16, # Q (NEU)
|
||
|
||
# Wikipedia-Daten & -Status (R-AG), alte N-AB verschoben um +4
|
||
"Wiki URL": 17, # R
|
||
"Wiki Sitz Stadt": 18, # S
|
||
"Wiki Sitz Land": 19, # T
|
||
"Wiki Absatz": 20, # U
|
||
"Wiki Branche": 21, # V
|
||
"Wiki Umsatz": 22, # W
|
||
"Wiki Mitarbeiter": 23, # X
|
||
"Wiki Kategorien": 24, # Y
|
||
"Wikipedia Timestamp": 25, # Z
|
||
"Wiki Verif. Timestamp": 26, # AA
|
||
"SerpAPI Wiki Search Timestamp": 27, # AB
|
||
"Chat Wiki Konsistenzpruefung": 28, # AC
|
||
"Chat Begruendung Wiki Inkonsistenz": 29, # AD
|
||
"Chat Vorschlag Wiki Artikel": 30, # AE
|
||
"Begruendung bei Abweichung": 31, # AF
|
||
|
||
# Website-Daten (AH-AL), alte AC-AG verschoben um +4
|
||
"Website Rohtext": 32, # AG
|
||
"Website Zusammenfassung": 33, # AH
|
||
"Website Meta-Details": 34, # AI
|
||
"Website Scrape Timestamp": 35, # AJ
|
||
"URL Prüfstatus": 36, # AK
|
||
|
||
# ChatGPT Branchen- & weitere Schätzungen (AM-AY), alte AH-AT verschoben um +4
|
||
"Chat Vorschlag Branche": 37, # AL
|
||
"Chat Branche Konfidenz": 38, # AM
|
||
"Chat Konsistenz Branche": 39, # AN
|
||
"Chat Begruendung Abweichung Branche": 40, # AO
|
||
"Chat Pruefung FSM Relevanz": 41, # AP
|
||
"Chat Begruendung für FSM Relevanz": 42, # AQ
|
||
"Chat Schaetzung Anzahl Mitarbeiter": 43, # AR
|
||
"Chat Konsistenzprüfung Mitarbeiterzahl": 44, # AS
|
||
"Chat Begruendung Abweichung Mitarbeiterzahl": 45, # AT
|
||
"Chat Einschätzung Anzahl Servicetechniker": 46, # AU
|
||
"Chat Begruendung Abweichung Anzahl Servicetechniker": 47, # AV
|
||
"Chat Schätzung Umsatz": 48, # AW
|
||
"Chat Begruendung Abweichung Umsatz": 49, # AX
|
||
|
||
# LinkedIn-Kontakte (AZ-BC), alte AU-AX verschoben um +4
|
||
"Linked Serviceleiter gefunden": 50, # AY
|
||
"Linked It-Leiter gefunden": 51, # AZ
|
||
"Linked Management gefunden": 52, # BA
|
||
"Linked Disponent gefunden": 53, # BB
|
||
|
||
# Timestamps, Konsolidierte Werte, ML & Plausi (BD-BP)
|
||
"Contact Search Timestamp": 54, # BC
|
||
"Finaler Umsatz (Wiki>CRM)": 55, # BD
|
||
"Finaler Mitarbeiter (Wiki>CRM)": 56, # BE
|
||
"Geschaetzter Techniker Bucket": 57, # BF
|
||
"Plausibilität Umsatz": 58, # BG
|
||
"Plausibilität Mitarbeiter": 59, # BH
|
||
"Plausibilität Umsatz/MA Ratio": 60, # BI
|
||
"Abweichung Umsatz CRM/Wiki": 61, # BJ
|
||
"Abweichung MA CRM/Wiki": 62, # BK
|
||
"Plausibilität Begründung": 63, # BL
|
||
"Plausibilität Prüfdatum": 64, # BM
|
||
"Timestamp letzte Pruefung": 65, # BN (ChatGPT Eval TS)
|
||
"Version": 66, # BO
|
||
"Tokens": 67, # BP
|
||
}
|
||
|
||
# --- Globale Variablen fuer Branch Mapping (werden von load_target_schema() befuellt) ---
|
||
BRANCH_MAPPING = {}
|
||
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar."
|
||
ALLOWED_TARGET_BRANCHES = []
|
||
FOCUS_TARGET_BRANCHES = [] # NEU
|
||
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar."
|
||
FOCUS_BRANCHES_PROMPT_PART = "" # NEU: Für den Prompt-Teil der Fokusbranchen
|
||
|
||
# Marker für URLs, die erneut per SERP gesucht werden sollen
|
||
URL_CHECK_MARKER = "URL_CHECK_NEEDED" # <<< NEU HINZUFÜGEN
|
||
|
||
# Liste gängiger User-Agents für Rotation
|
||
USER_AGENTS = [
|
||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36',
|
||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36',
|
||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
|
||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
|
||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
|
||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15',
|
||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15',
|
||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/109.0',
|
||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:108.0) Gecko/20100101 Firefox/108.0',
|
||
'Mozilla/5.0 (X11; Linux i686; rv:108.0) Gecko/20100101 Firefox/108.0',
|
||
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:108.0) Gecko/20100101 Firefox/108.0',
|
||
]
|
||
|
||
# ==============================================================================
|
||
# Ende Basis-Setup Block
|
||
# ==============================================================================
|
||
|
||
# ==============================================================================
|
||
# GLOBALE HELPER FUNCTIONS (PART 1: Retry Decorator)
|
||
# ==============================================================================
|
||
|
||
# Imports für den Retry Decorator (um NameErrors zu vermeiden)
|
||
from openai.error import AuthenticationError, OpenAIError, RateLimitError, APIError, Timeout, InvalidRequestError, ServiceUnavailableError # Beispielhafte spezifische Fehler
|
||
|
||
# Logger fuer den Retry Decorator selbst (Nutzt den globalen Root Logger)
|
||
decorator_logger = logging.getLogger(__name__ + ".Retry")
|
||
|
||
# --- Retry Decorator ---
|
||
# KORRIGIERTE Version (Behandelt SpreadsheetNotFound und 404/400/401/403 HTTPError explizit)
|
||
def retry_on_failure(func):
|
||
"""
|
||
Decorator, der eine Funktion bei bestimmten Fehlern mehrmals wiederholt.
|
||
Implementiert exponentiellen Backoff mit Jitter.
|
||
"""
|
||
def wrapper(*args, **kwargs):
|
||
func_name = func.__name__
|
||
# Versuche, das 'self' Argument fuer Methoden zu extrahieren, falls vorhanden
|
||
self_arg = args[0] if args and hasattr(args[0], func_name) and isinstance(args[0], object) else None
|
||
# Konstruiere einen aussagekraeftigeren Funktionsnamen fuer die Logs
|
||
effective_func_name = f"{self_arg.__class__.__name__}.{func_name}" if self_arg else func_name
|
||
|
||
# Basiswartezeit und maximale Anzahl Versuche aus Config holen
|
||
max_retries_config = getattr(Config, 'MAX_RETRIES', 3) # Anzahl der Versuche (nicht Wiederholungen nach dem ersten Fehler)
|
||
base_delay = getattr(Config, 'RETRY_DELAY', 5)
|
||
|
||
# Wenn max_retries_config 0 oder weniger ist, einfach einmal ausfuehren
|
||
if max_retries_config <= 0:
|
||
try:
|
||
return func(*args, **kwargs)
|
||
except Exception as e:
|
||
# Fehler loggen und weitergeben, wenn keine Retries konfiguriert sind
|
||
decorator_logger.error(f"FEHLER bei '{effective_func_name}' (keine Retries konfiguriert). {type(e).__name__} - {str(e)[:150]}...")
|
||
# Log traceback fuer unerwartete Fehler (nicht die spezifischen API/Netzwerkfehler)
|
||
if not isinstance(e, (requests.exceptions.RequestException, gspread.exceptions.APIError, OpenAIError, wikipedia.exceptions.WikipediaException)): # <<< GEÄNDERT (openai.error.OpenAIError -> OpenAIError)
|
||
decorator_logger.exception("Details zum Fehler:")
|
||
raise e # Re-raise the exception
|
||
|
||
|
||
# --- Retry logic for max_retries_config > 0 ---
|
||
# Die Schleife laeuft max_retries_config mal.
|
||
for attempt in range(max_retries_config):
|
||
try:
|
||
# Logge jeden Versuch, ausser den ersten (optional, um Log-Laerm zu reduzieren)
|
||
if attempt > 0:
|
||
decorator_logger.warning(f"Wiederhole Versuch {attempt + 1}/{max_retries_config} fuer '{effective_func_name}'...")
|
||
|
||
return func(*args, **kwargs) # Call the original function
|
||
|
||
# Spezifische Exceptions, die ein Retry nicht rechtfertigen (permanente Fehler)
|
||
except (gspread.exceptions.SpreadsheetNotFound, AuthenticationError, ValueError) as e: # <<< GEÄNDERT (openai.error.AuthenticationError -> AuthenticationError)
|
||
# Diese Fehler deuten auf ein permanentes Problem hin (falsche URL, falscher Key, falsche Eingabe)
|
||
decorator_logger.critical(f"❌ ENDGUELTIGER FEHLER bei '{effective_func_name}': Permanentes Problem erkannt. {type(e).__name__} - {str(e)[:150]}...")
|
||
decorator_logger.exception("Details:") # Log traceback fuer permanente Fehler
|
||
raise e # Leiten Sie diese Exception sofort weiter
|
||
|
||
# Fangen Sie Requests HTTP Errors (wie 404)
|
||
except requests.exceptions.HTTPError as e:
|
||
if hasattr(e, 'response') and e.response is not None:
|
||
status_code = e.response.status_code
|
||
# Definieren Sie hier eine Liste von Status-Codes, die NICHT wiederholt werden sollen
|
||
non_retryable_status_codes = [404, 400, 401, 403] # Not Found, Bad Request, Unauthorized, Forbidden
|
||
|
||
if status_code in non_retryable_status_codes:
|
||
decorator_logger.critical(f"❌ ENDGUELTIGER FEHLER bei '{effective_func_name}': HTTP Fehler {status_code} erhalten ({e.response.reason}). Nicht wiederholbar. {str(e)[:100]}...") # Kuerzer im Log
|
||
decorator_logger.exception("Details:") # Log traceback
|
||
raise e # Leiten Sie diese nicht-wiederholbare Exception sofort weiter
|
||
# Ansonsten behandle HTTP Errors wie andere RequestExceptions (weiter unten)
|
||
|
||
# Wenn kein Response-Objekt oder kein spezifischer Statuscode gehandhabt wurde,
|
||
# lassen Sie diesen Fehler durchfallen zur allgemeinen RequestException Behandlung.
|
||
|
||
|
||
# Fangen Sie andere wiederholbare Exceptions (Netzwerk, Rate Limit, Timeout etc.)
|
||
except (requests.exceptions.RequestException, gspread.exceptions.APIError, OpenAIError, wikipedia.exceptions.WikipediaException) as e: # <<< GEÄNDERT (openai.error.OpenAIError -> OpenAIError)
|
||
error_msg = str(e)
|
||
error_type = type(e).__name__
|
||
|
||
if attempt < max_retries_config - 1: # Wenn nicht der letzte Versuch
|
||
wait_time = base_delay * (2 ** attempt) + random.uniform(0, 1) # Exponentieller Backoff mit Jitter
|
||
# Loggen Sie den spezifischen Fehler und die Wartezeit
|
||
if isinstance(e, RateLimitError): # <<< GEÄNDERT (Prüfung auf spezifischen OpenAI Fehler)
|
||
decorator_logger.warning(f"🚦 RATE LIMIT ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...")
|
||
elif isinstance(e, Timeout) and isinstance(e, OpenAIError): # <<< GEÄNDERT (Prüfung auf OpenAI Timeout)
|
||
decorator_logger.warning(f"⏰ OPENAI TIMEOUT ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...")
|
||
elif isinstance(e, gspread.exceptions.APIError) and hasattr(e, 'response') and e.response is not None and e.response.status_code == 429: # <<< GEÄNDERT (Gspread Rate Limit Check)
|
||
decorator_logger.warning(f"🚦 GSPREAD RATE LIMIT ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...")
|
||
elif isinstance(e, requests.exceptions.Timeout):
|
||
decorator_logger.warning(f"⏰ REQUESTS TIMEOUT ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...")
|
||
elif isinstance(e, requests.exceptions.RequestException): # Allgemeine RequestException
|
||
decorator_logger.warning(f"🌐 NETZWERKFEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...")
|
||
elif isinstance(e, OpenAIError): # Allgemeine OpenAI Fehler
|
||
decorator_logger.warning(f"🤖 OPENAI FEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...")
|
||
elif isinstance(e, wikipedia.exceptions.WikipediaException): # Allgemeine Wikipedia Fehler
|
||
decorator_logger.warning(f"📚 WIKIPEDIA FEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...")
|
||
else: # Andere wiederholbare Exceptions
|
||
decorator_logger.warning(f"♻️ WIEDERHOLBARER FEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...")
|
||
|
||
time.sleep(wait_time) # Warte vor dem nächsten Versuch
|
||
else: # Letzter Versuch fehlgeschlagen
|
||
decorator_logger.error(f"❌ ENDGUELTIGER FEHLER bei '{effective_func_name}' nach {max_retries_config} Versuchen.")
|
||
raise e # Leite die ursprüngliche Exception weiter
|
||
|
||
except Exception as e:
|
||
# Fangen Sie sofort alle anderen unerwarteten Exceptions ab (z. B. Programmierfehler)
|
||
# Diese sollten nicht wiederholt werden.
|
||
decorator_logger.critical(f"💥 UNERWARTETER FEHLER ({type(e).__name__}) bei '{effective_func_name}'. KEIN RETRY VERSUCHT.")
|
||
decorator_logger.exception("Details zum unerwarteten Fehler:") # Loggen Sie den vollständigen Traceback
|
||
raise e # Leiten Sie die Exception sofort weiter
|
||
|
||
# Dieser Teil sollte theoretisch nicht erreicht werden, wenn max_retries_config > 0
|
||
# und eine Exception immer zu einer raise e Anweisung führt.
|
||
raise RuntimeError(f"Retry decorator logic error: Loop completed unexpectedly for {effective_func_name}. This should not happen.")
|
||
|
||
return wrapper # Gibt die Wrapper-Funktion zurück
|
||
|
||
# ==============================================================================
|
||
# Ende Retry Decorator Block
|
||
# ==============================================================================
|
||
|
||
# ==============================================================================
|
||
# GLOBALE HELPER FUNCTIONS (PART 2: Logging & Token Count)
|
||
# ==============================================================================
|
||
|
||
# --- Token Count Funktion ---
|
||
def token_count(text, model=None):
|
||
"""Zaehlt Tokens via tiktoken oder schaetzt ueber Leerzeichen."""
|
||
logger = logging.getLogger(__name__) # Logger-Instanz holen
|
||
if not text or not isinstance(text, str): return 0
|
||
# ... (Rest der token_count Funktion wie in Block 3/36 gesendet) ...
|
||
current_model = model if model else getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo')
|
||
if tiktoken:
|
||
try:
|
||
if not hasattr(token_count, 'enc_cache'): token_count.enc_cache = {}
|
||
if current_model not in token_count.enc_cache: token_count.enc_cache[current_model] = tiktoken.encoding_for_model(current_model)
|
||
enc = token_count.enc_cache[current_model]
|
||
return len(enc.encode(text))
|
||
except Exception as e:
|
||
logger.debug(f"Fehler beim Token-Counting mit tiktoken fuer Modell '{current_model}': {e} - Fallback zur Schaetzung.")
|
||
return len(str(text).split())
|
||
else:
|
||
return len(str(text).split())
|
||
|
||
|
||
# --- Logging Helpers ---
|
||
LOG_FILE = None # Initialisierung
|
||
|
||
def create_log_filename(mode):
|
||
"""Erstellt einen zeitgestempelten Logdateinamen im LOG_DIR."""
|
||
logger = logging.getLogger(__name__) # Logger-Instanz holen
|
||
log_dir_path = LOG_DIR
|
||
# ... (Rest der create_log_filename Funktion wie in Block 3/36 gesendet) ...
|
||
if not os.path.exists(log_dir_path):
|
||
try:
|
||
os.makedirs(log_dir_path, exist_ok=True)
|
||
logger.info(f"Log-Verzeichnis '{log_dir_path}' erstellt.")
|
||
except Exception as e:
|
||
logger.error(f"FEHLER: Konnte Log-Verzeichnis '{log_dir_path}' nicht erstellen: {e}")
|
||
log_dir_path = "."
|
||
logger.warning(f"Versuche, Logdatei im aktuellen Verzeichnis '{log_dir_path}' zu erstellen.")
|
||
try:
|
||
now = datetime.now().strftime("%d-%m-%Y_%H-%M")
|
||
ver_short = getattr(Config, 'VERSION', 'unknown').replace(".", "")
|
||
filename = f"{now}_{ver_short}_Modus{mode}.txt"
|
||
return os.path.join(log_dir_path, filename)
|
||
except Exception as e_fallback:
|
||
logger.error(f"FEHLER: Konnte Logdateinamen auch im Fallback-Verzeichnis '{log_dir_path}' nicht erstellen: {e_fallback}")
|
||
return None
|
||
|
||
# ==============================================================================
|
||
# Ende Grundlegende Helfer Block
|
||
# ==============================================================================
|
||
|
||
# ==============================================================================
|
||
# GLOBALE HELPER FUNCTIONS (PART 2: Text, String & URL Utilities)
|
||
# ==============================================================================
|
||
|
||
# --- Text Normalisierung & Reinigung ---
|
||
# Basierend auf Code aus Teil 3.
|
||
# Nutzt globale Helfer: re, unicodedata.
|
||
def simple_normalize_url(url):
|
||
"""Normalisiert URL zu domain.tld oder k.A. (ohne www, ohne Pfad)."""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
|
||
if not url or not isinstance(url, str): return "k.A."
|
||
url = url.strip()
|
||
# Pruefe auf Kleinbuchstaben "k.A." und leere Strings nach dem Strippen
|
||
if not url or url.lower() == 'k.a.': return "k.A."
|
||
# Falls kein Schema vorhanden ist, hinzufuegen (HTTPS bevorzugen)
|
||
if not url.lower().startswith(("http://", "https://")):
|
||
url = "https://" + url # Fuege HTTPS als Standard hinzu
|
||
try:
|
||
# Parse die URL in ihre Komponenten
|
||
parsed = urlparse(url)
|
||
# Extrahieren Sie den Netzwerkteil (Domain und Port)
|
||
domain_part = parsed.netloc
|
||
# Wenn der Netzwerkteil leer ist, kann die URL nicht normalisiert werden
|
||
if not domain_part:
|
||
logger.debug(f"URL '{url[:100]}...' konnte nicht sinnvoll geparst werden (leerer netloc).")
|
||
return "k.A."
|
||
# Port entfernen, falls vorhanden (z.B. :8080)
|
||
domain_part = domain_part.split(":", 1)[0]
|
||
# Entferne User/Passwort-Teile, falls vorhanden (user:pass@domain)
|
||
if '@' in domain_part:
|
||
domain_part = domain_part.split('@', 1)[1]
|
||
|
||
# Wandle Punycode (IDN) in Unicode um fuer Lesbarkeit (z.B. xn--...)
|
||
try:
|
||
# Versuchen Sie die IDNA-Dekodierung
|
||
domain_part = domain_part.encode('ascii').decode('idna')
|
||
except UnicodeDecodeError:
|
||
# Logge eine Warnung, wenn die Dekodierung fehlschlaegt, aber behalte den Original-Domain-Teil
|
||
# logger.warning(f"Fehler bei IDNA-Dekodierung fuer Domain '{domain_part}' aus URL '{url[:100]}...'. Behalte Original.") # Zu viel Laerm im Debug
|
||
pass # Behalte den urspruenglichen domain_part, wenn Dekodierung fehlschlaegt
|
||
|
||
# Konvertiere den Domain-Teil zu Kleinbuchstaben
|
||
domain_part = domain_part.lower()
|
||
|
||
# Optional: "www." am Anfang entfernen (optional, kann an Praeferenz angepasst werden)
|
||
if domain_part.startswith("www."):
|
||
# Stellen Sie sicher, dass die Domain nach dem Entfernen nicht leer ist (z.B. nur "www.")
|
||
domain_part = domain_part[4:]
|
||
|
||
# Einfache Pruefung auf mindestens einen Punkt (Basic TLD check)
|
||
# Stellen Sie auch sicher, dass der Domain-Teil nach Bearbeitung nicht leer ist.
|
||
# Eine einfache Pruefung auf das Vorhandensein eines Punktes und alphabetische Zeichen am Ende der TLD.
|
||
if domain_part and '.' in domain_part:
|
||
# Pruefen Sie, ob der Teil nach dem letzten Punkt (die TLD) aus Buchstaben besteht und mindestens 2 Zeichen lang ist.
|
||
parts = domain_part.split('.')
|
||
if len(parts) > 1 and parts[-1].isalpha() and len(parts[-1]) >= 2:
|
||
# Rueckgabe des normalisierten Domain-Teils
|
||
return domain_part
|
||
else:
|
||
# Wenn die TLD-Pruefung fehlschlaegt
|
||
logger.debug(f"URL '{url[:100]}...' normalisiert zu '{domain_part}', aber TLD-Pruefung schlug fehl.")
|
||
return "k.A."
|
||
else:
|
||
# Wenn kein Punkt im Domain-Teil ist oder er leer ist
|
||
logger.debug(f"URL '{url[:100]}...' normalisiert zu '{domain_part}', enthaelt keinen Punkt oder ist leer.")
|
||
return "k.A."
|
||
|
||
except Exception as e:
|
||
# Fange unerwartete Fehler beim Parsen oder Bearbeiten der URL ab
|
||
logger.error(f"Fehler bei URL-Normalisierung fuer '{url[:100]}...': {e}")
|
||
# Rueckgabe eines Fehlerwerts
|
||
return "k.A. (Fehler Normalisierung)"
|
||
|
||
|
||
def normalize_string(s):
|
||
"""Normalisiert Umlaute und Sonderzeichen nach einer definierten Liste."""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
|
||
if not s or not isinstance(s, str): return ""
|
||
# Ersetzungen fuer gaengige deutsche Umlaute und Sonderzeichen
|
||
replacements = { 'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue', 'ß': 'ss', 'ä': 'ae', 'ö': 'oe', 'ü': 'ue',
|
||
'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Å': 'A', 'Æ': 'AE', 'à': 'a', 'á': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'æ': 'ae',
|
||
'Ç': 'C', 'ç': 'c', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e',
|
||
'Ì': 'I', 'Í': 'I', 'Î': 'I', 'Ï': 'I', 'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i', 'Ñ': 'N', 'ñ': 'n',
|
||
'Ò': 'O', 'Ó': 'O', 'Ô': 'O', 'Õ': 'O', 'Ø': 'O', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', 'ø': 'o', 'Œ': 'OE', 'œ': 'oe',
|
||
'Š': 'S', 'š': 's', 'Ž': 'Z', 'ž': 'z', 'Ý': 'Y', 'ý': 'y', 'ÿ': 'y', 'Đ': 'D', 'đ': 'd',
|
||
'č': 'c', 'Č': 'C', 'ć': 'c', 'Ć': 'C', 'ł': 'l', 'Ł': 'L', 'ğ': 'g', 'Ğ': 'G', 'ş': 's', 'Ş': 'S',
|
||
'ă': 'a', 'Ă': 'A', 'ı': 'i', 'İ': 'I', 'ň': 'n', 'Ň': 'N', 'ř': 'r', 'Ř': 'R', 'ő': 'o', 'Ő': 'O', 'ű': 'u', 'Ű': 'U', 'ț': 't', 'Ț': 'T', 'ș': 's', 'Ș': 'S'
|
||
}
|
||
# Versuche unicodedata Normalisierung zuerst, um Akzente etc. zu entfernen, die nicht in der Liste sind
|
||
try:
|
||
# 'NFKD' zerlegt Zeichen in ihre Basisform + kombinierende Zeichen (z.B. 'ä' -> 'a', '¨').
|
||
# .encode('ascii', 'ignore') entfernt dann die kombinierenden Zeichen.
|
||
s = unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode('ascii')
|
||
except Exception as e:
|
||
# Logge Fehler bei der unicodedata Normalisierung, aber fahre mit manuellen Ersetzungen fort
|
||
logger.debug(f"Fehler bei unicodedata Normalisierung fuer '{str(s)[:50]}...': {e}")
|
||
pass # Faert mit dem urspruenglichen String fort, wenn unicodedata fehlschlaegt
|
||
|
||
# Dann manuelle Ersetzungen fuer spezifische Faelle (wie Umlaute -> Ae etc.)
|
||
for src, target in replacements.items():
|
||
s = s.replace(src, target)
|
||
|
||
return s
|
||
|
||
|
||
def clean_text(text):
|
||
"""
|
||
Bereinigt Text (Unicode, Referenzen, Whitespace, etc.) von Wikipedia, Websites etc.
|
||
Entfernt gaengige unerwuenschte Muster wie [1], [Bearbeiten].
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
|
||
if text is None: return "k.A." # Behandle None explizit
|
||
try:
|
||
text = str(text) # Sicherstellen, dass es ein String ist
|
||
if not text.strip(): return "k.A." # Leere oder nur Whitespace-Strings
|
||
|
||
# Normalisiert Whitespace, Ligaturen etc. (NFC ist oft ein guter Kompromiss)
|
||
text = unicodedata.normalize("NFC", text)
|
||
|
||
# Entfernt Referenz-Tags wie [1], [2]
|
||
text = re.sub(r'\[\d+\]', '', text)
|
||
# Entfernt gaengige Wikipedia-Bearbeitungslinks
|
||
text = re.sub(r'\[\s*Bearbeiten\s*\|\s*Quelltext bearbeiten\s*\]', '', text, flags=re.IGNORECASE)
|
||
# Entfernt Koordinaten-Spans, die manchmal im Text auftauchen koennen
|
||
text = re.sub(r'\[koordinaten\]', '', text, flags=re.IGNORECASE)
|
||
|
||
|
||
# Ersetzt multiple Leerzeichen/Tabs/Newlines durch ein einzelnes Leerzeichen
|
||
text = re.sub(r'\s+', ' ', text).strip()
|
||
|
||
# Wenn nach Bereinigung leer, gib k.A. zurueck
|
||
return text if text else "k.A."
|
||
except Exception as e:
|
||
# Fehlermeldung beim Bereinigen
|
||
logger.error(f"Fehler bei clean_text fuer Input '{str(text)[:50]}...': {e}")
|
||
return "k.A. (Fehler Bereinigung)"
|
||
|
||
|
||
def normalize_company_name(name):
|
||
"""
|
||
Entfernt gaengige Rechtsformzusaetze etc. fuer Vergleiche.
|
||
Nutzt clean_text und normalize_string.
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
if not name: return ""
|
||
# Vorab bereinigen und normalisieren (Umlaute etc.)
|
||
name = clean_text(name)
|
||
name = normalize_string(name)
|
||
|
||
# Liste von Rechtsformen und generischen Zusaetzen (case-insensitive)
|
||
# Verwenden Sie raw strings r'' fuer die Regex-Patterns
|
||
forms = [
|
||
r'gmbh', r'ges\.?\s*m\.?\s*b\.?\s*h\.?', r'gesellschaft mit beschraenkter haftung',
|
||
r'ug', r'u\.g\.', r'unternehmergesellschaft', r'haftungsbeschraenkt',
|
||
r'ag', r'a\.g\.', r'aktiengesellschaft',
|
||
r'ohg', r'o\.h\.g\.', r'offene handelsgesellschaft',
|
||
r'kg', r'k\.g\.', r'kommanditgesellschaft',
|
||
r'gmbh\s*&\s*co\.?\s*kg', r'ges\.?\s*m\.?\s*b\.?\s*h\.?\s*&\s*co\.?\s*k\.g\.?',
|
||
r'ag\s*&\s*co\.?\s*kg', r'a\.g\.?\s*&\s*co\.?\s*k\.g\.?',
|
||
r'e\.k\.?', r'e\.kfm\.?', r'e\.kfr\.?', r'eingetragene[rn]? kauffrau', r'eingetragene[rn]? kaufmann',
|
||
r'ltd\.?', r'limited',
|
||
r'ltd\s*&\s*co\.?\s*kg',
|
||
r's\.?a\.?r\.?l\.?', r'sarl', r'sagl', # CH/LU/etc. SARL, It. S.a.g.l.
|
||
r's\.?a\.?', r'societe anonyme', r'sociedad anonima', # Franz/Span. SA
|
||
r's\.?p\.?a\.?', r'societa per azioni', # It. SpA
|
||
r'b\.?v\.?', r'besloten vennootschap', # NL BV
|
||
r'n\.?v\.?', r'naamloze vennootschap', # NL NV
|
||
r'plc\.?', r'public limited company', # UK Plc
|
||
r'inc\.?', r'incorporated', # US Inc
|
||
r'corp\.?', r'corporation', # US Corp
|
||
r'llc\.?', r'limited liability company', # US LLC
|
||
r'kgaa', r'kommanditgesellschaft auf aktien', # DE KGaA
|
||
r'se', r'societas europaea', # SE (Europa)
|
||
r'e\.?g\.?', r'eingetragene genossenschaft', r'genossenschaft', r'genmbh', # DE eG
|
||
r'e\.?v\.?', r'eingetragener verein', r'verein', # DE eV
|
||
r'stiftung', r'ggmbh', r'gemeinnuetzige gmbh', r'gemeinnuetzige[rn]? gmbh', r'gug', # DE Stiftungen, gemeinnuetzige
|
||
r'partg\.?', r'partnerschaftsgesellschaft', r'partgmbb', # DE PartG
|
||
r'og', r'o\.g\.', r'offene gesellschaft', # AT OG
|
||
r'e\.u\.', r'eingetragenes unternehmen', # AT EU
|
||
r'ges\.?n\.?b\.?r\.?', r'gesellschaft nach buergerlichem recht', # DE GbR
|
||
r'kollektivgesellschaft', r'einzelfirma', # CH
|
||
# Zusaetzliche generische Begriffe am Ende (koennen auch Firmenbestandteile sein, daher Vorsicht)
|
||
r'gruppe', r'holding', r'international', r'systeme', r'technik', r'logistik',
|
||
r'solutions', r'services', r'management', r'consulting', r'produktion',
|
||
r'vertrieb', r'entwicklung', r'maschinenbau', r'anlagenbau',
|
||
r'engineering', r'technologie' # Weitere gaengige Begriffe
|
||
]
|
||
# Pattern fuer ganze Woerter (case-insensitive), escaped um Regex-Sonderzeichen zu behandeln
|
||
forms_escaped = [re.escape(form) for form in forms]
|
||
# \b fuer Wortgrenzen, (?:...) fuer non-capturing group
|
||
pattern = r'\b(?:' + '|'.join(forms_escaped) + r')\b'
|
||
normalized = re.sub(pattern, '', name, flags=re.IGNORECASE)
|
||
|
||
# Interpunktion entfernen/ersetzen (ausser evtl. &)
|
||
# Entferne Punkt, Komma, Semikolon, Doppelpunkt
|
||
normalized = re.sub(r'[.,;:]', '', normalized)
|
||
# Ersetze Bindestriche, Gedankenstriche, Schraegstriche durch ein einzelnes Leerzeichen
|
||
normalized = re.sub(r'[\-–/]', ' ', normalized)
|
||
# Reduziere multiple Leerzeichen auf ein einzelnes und trimme Enden
|
||
normalized = re.sub(r'\s+', ' ', normalized).strip()
|
||
|
||
return normalized.lower()
|
||
|
||
|
||
def fuzzy_similarity(str1, str2):
|
||
"""Berechnet Aehnlichkeit zwischen 0 und 1 (case-insensitive)."""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
if not str1 or not str2: return 0.0
|
||
# Sicherstellen, dass beide Inputs Strings sind
|
||
return SequenceMatcher(None, str(str1).lower(), str(str2).lower()).ratio()
|
||
|
||
# ==============================================================================
|
||
# Ende Text, String & URL Utilities Block
|
||
# ==============================================================================
|
||
|
||
# ==============================================================================
|
||
# GLOBALE HELPER FUNCTIONS (PART 3: Numeric Extraction Utilities)
|
||
# ==============================================================================
|
||
|
||
# --- Numerische Extraktion ---
|
||
# Basierend auf Code aus Teil 4 & Teil 2.
|
||
# Extrahiert und normalisiert Zahlenwerte aus Strings.
|
||
# Nutzt globale Helfer: clean_text, re.
|
||
# Globale Funktion (ersetzen Sie Ihre bestehende Version vollständig hiermit)
|
||
def extract_numeric_value(raw_value, is_umsatz=False):
|
||
logger = logging.getLogger(__name__ + ".extract_numeric_value")
|
||
if raw_value is None or pd.isna(raw_value):
|
||
return "k.A."
|
||
|
||
raw_value_str_original_for_debug = str(raw_value)
|
||
text_to_parse = str(raw_value).strip()
|
||
|
||
if not text_to_parse or text_to_parse.lower() in ['k.a.', 'n/a', '-']:
|
||
return "k.A."
|
||
|
||
text_to_parse = text_to_parse.replace("’", "'").replace("‘", "'")
|
||
|
||
test_val_for_zero = text_to_parse.replace(',', '.').replace(' ', '')
|
||
if test_val_for_zero in ['0', '0.0', '0.00', '0.000']:
|
||
logger.debug(f"Input '{raw_value_str_original_for_debug}' direkt als '0' interpretiert.")
|
||
return "0"
|
||
|
||
try:
|
||
text_processed = text_to_parse
|
||
|
||
prefixes_to_remove = [
|
||
r'ca\.?\s*', r'circa\s*', r'rund\s*', r'etwa\s*', r'über\s*', r'unter\s*',
|
||
r'mehr als\s*', r'weniger als\s*', r'bis zu\s*', r'about\s*', r'over\s*',
|
||
r'approx\.?\s*', r'around\s*', r'up to\s*', r'~\s*', r'rd\.?\s*'
|
||
]
|
||
for prefix_pattern in prefixes_to_remove:
|
||
text_processed = re.sub(f'(?i)^{prefix_pattern}', '', text_processed).strip()
|
||
|
||
currency_patterns = [
|
||
r'(?:US|USD)\$\s*', r'US\$\s*', r'EUR\s*€?\s*', r'€\s*', r'CHF\s*', r'GBP\s*£?\s*', r'£\s*',
|
||
r'JPY\s*¥?\s*', r'¥\s*', r'₹\s*', r'[Cc][Hh][Ff]\s*'
|
||
]
|
||
for curr_pattern in currency_patterns:
|
||
text_processed = re.sub(curr_pattern, '', text_processed, flags=re.IGNORECASE).strip()
|
||
|
||
text_processed = re.sub(r'\(.*?\)|\[.*?\]', '', text_processed).strip()
|
||
text_processed = re.split(r'\s*(-|–|bis)\s*', text_processed, 1)[0].strip()
|
||
|
||
if not text_processed:
|
||
logger.debug(f"Text nach erweiterter Vorreinigung leer für '{raw_value_str_original_for_debug}'")
|
||
return "k.A."
|
||
|
||
text_cleaned_for_units = clean_text(text_processed).lower()
|
||
num_match = re.search(r'([\d.,\'\s]+)', text_processed)
|
||
|
||
num_str_candidate = ""
|
||
unit_part_str = ""
|
||
|
||
if num_match:
|
||
num_str_candidate = num_match.group(1).strip()
|
||
potential_unit_start_index = num_match.end()
|
||
unit_part_str = text_processed[potential_unit_start_index:].strip()
|
||
else:
|
||
logger.debug(f"Kein Zahlen-Match in '{text_processed}' (Original: '{raw_value_str_original_for_debug}')")
|
||
return "k.A."
|
||
|
||
if not num_str_candidate:
|
||
logger.debug(f"Zahlenkandidat war leer oder nur Whitespace für '{raw_value_str_original_for_debug}' nach match in '{text_processed}'")
|
||
return "k.A."
|
||
|
||
cleaned_num_str = num_str_candidate.replace("'", "").replace(" ", "")
|
||
if not cleaned_num_str:
|
||
logger.debug(f"Zahlenkandidat '{num_str_candidate}' wurde zu leerem String nach Entfernung von ' und Leerraum.")
|
||
return "k.A."
|
||
|
||
has_dot = '.' in cleaned_num_str
|
||
has_comma = ',' in cleaned_num_str
|
||
|
||
if has_dot and has_comma:
|
||
last_dot_pos = cleaned_num_str.rfind('.')
|
||
last_comma_pos = cleaned_num_str.rfind(',')
|
||
if last_dot_pos > last_comma_pos:
|
||
cleaned_num_str = cleaned_num_str.replace(',', '')
|
||
else:
|
||
cleaned_num_str = cleaned_num_str.replace('.', '')
|
||
cleaned_num_str = cleaned_num_str.replace(',', '.')
|
||
elif has_comma:
|
||
if is_umsatz: # Für Umsatz kann Komma Dezimal sein
|
||
if re.search(r',\d{1,2}$', cleaned_num_str): # Letztes Komma, 1-2 Ziffern danach
|
||
parts = cleaned_num_str.rsplit(',', 1)
|
||
integer_part = parts[0].replace(',', '')
|
||
cleaned_num_str = f"{integer_part}.{parts[1]}"
|
||
else: # Alle Kommas sind Tausendertrenner
|
||
cleaned_num_str = cleaned_num_str.replace(',', '')
|
||
else: # Für Mitarbeiter ist Komma IMMER Tausendertrenner
|
||
cleaned_num_str = cleaned_num_str.replace(',', '')
|
||
elif has_dot:
|
||
# Für Umsatz und Mitarbeiter kann Punkt Dezimal sein
|
||
if re.search(r'\.\d{1,2}$', cleaned_num_str): # Letzter Punkt, 1-2 Ziffern danach
|
||
parts = cleaned_num_str.rsplit('.', 1)
|
||
integer_part = parts[0].replace('.', '')
|
||
cleaned_num_str = f"{integer_part}.{parts[1]}"
|
||
else: # Alle Punkte sind Tausendertrenner
|
||
cleaned_num_str = cleaned_num_str.replace('.', '')
|
||
|
||
if not re.fullmatch(r'-?\d+(\.\d+)?', cleaned_num_str):
|
||
logger.debug(f"Kein gültiger numerischer String nach Trennzeichenbehandlung: '{cleaned_num_str}' (Num-Kandidat: '{num_str_candidate}', Original: '{raw_value_str_original_for_debug}')")
|
||
return "k.A."
|
||
|
||
num_as_float = float(cleaned_num_str)
|
||
|
||
scaled_num = num_as_float
|
||
string_for_unit_search = unit_part_str.lower() if unit_part_str else text_cleaned_for_units
|
||
logger.debug(f"String für Einheitensuche: '{string_for_unit_search}' (num_as_float: {num_as_float})")
|
||
|
||
if is_umsatz:
|
||
multiplikator = 1.0
|
||
einheit_gefunden = False
|
||
if re.search(r'\b(mrd\.?|milliarden|billion|mia\.?)\b', string_for_unit_search):
|
||
multiplikator = 1000.0
|
||
einheit_gefunden = True
|
||
elif re.search(r'\bcrore\b', string_for_unit_search):
|
||
multiplikator = 10.0
|
||
einheit_gefunden = True
|
||
elif re.search(r'\b(mio\.?|mill\.?|millionen|mn)\b', string_for_unit_search):
|
||
multiplikator = 1.0
|
||
einheit_gefunden = True
|
||
elif re.search(r'\b(tsd\.?|tausend|k\b(?!\w))\b', string_for_unit_search):
|
||
multiplikator = 0.001
|
||
einheit_gefunden = True
|
||
logger.debug(f"Umsatz: num_as_float={num_as_float}, gefundene Einheit? {einheit_gefunden}, Multiplikator={multiplikator}")
|
||
scaled_num = num_as_float * multiplikator
|
||
else:
|
||
multiplikator = 1.0
|
||
if re.search(r'\b(mrd\.?|milliarden|billion|mia\.?)\b', string_for_unit_search):
|
||
multiplikator = 1000000000.0
|
||
elif re.search(r'\b(mio\.?|mill\.?|millionen|mn)\b', string_for_unit_search):
|
||
multiplikator = 1000000.0
|
||
elif re.search(r'\b(tsd\.?|tausend|k\b(?!\w))\b', string_for_unit_search):
|
||
multiplikator = 1000.0
|
||
logger.debug(f"Mitarbeiter: num_as_float={num_as_float}, Multiplikator={multiplikator}")
|
||
scaled_num = num_as_float * multiplikator
|
||
|
||
if pd.isna(scaled_num):
|
||
return "k.A."
|
||
|
||
if scaled_num >= 0:
|
||
return str(int(round(scaled_num)))
|
||
else:
|
||
logger.debug(f"Negative Zahl nach Skalierung: {scaled_num} für Input '{raw_value_str_original_for_debug}'")
|
||
return "k.A."
|
||
|
||
except ValueError as e:
|
||
logger.debug(f"ValueError bei Konvertierung zu float: '{e}' (cleaned_num_str: '{cleaned_num_str if 'cleaned_num_str' in locals() else 'N/A'}', Original: '{raw_value_str_original_for_debug[:30]}...')")
|
||
return "k.A."
|
||
except Exception as e_general:
|
||
logger.error(f"Unerwarteter Fehler in extract_numeric_value für '{raw_value_str_original_for_debug[:50]}...': {e_general}")
|
||
logger.debug(traceback.format_exc())
|
||
return "k.A."
|
||
|
||
|
||
|
||
# --- Numerische Extraktion fuer FILTERLOGIK (gibt 0 statt k.A. zurueck) ---
|
||
# Basierend auf Code aus Teil 2.
|
||
# Extrahiert und normalisiert Zahlenwerte fuer Vergleichslogik.
|
||
# Nutzt globale Helfer: clean_text, re.
|
||
# Globale Funktion (ersetzen Sie Ihre bestehende Version)
|
||
# Globale Funktion (ersetzen Sie Ihre bestehende Version)
|
||
def get_numeric_filter_value(value_str, is_umsatz=False):
|
||
logger = logging.getLogger(__name__ + ".get_numeric_filter_value") # Spezifischerer Logger-Name
|
||
if value_str is None or pd.isna(value_str) or str(value_str).strip() == '':
|
||
return 0.0 if is_umsatz else 0
|
||
|
||
raw_value_str_original = str(value_str).strip()
|
||
|
||
if raw_value_str_original.lower() in ['k.a.', 'n/a', '-', '0', '0.0', '0,0', '0.00', '0,000', '0.000']:
|
||
return 0.0 if is_umsatz else 0
|
||
|
||
try:
|
||
# Schritt 1: Grundlegende Textbereinigung
|
||
processed_value = clean_text(raw_value_str_original)
|
||
if processed_value.lower() in ['k.a.', 'n/a', '-']:
|
||
return 0.0 if is_umsatz else 0
|
||
|
||
# Schritt 2: Präfixe, Suffixe, Währungssymbole und Spannen entfernen
|
||
processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|ueber|unter|mehr als|weniger als|bis zu)\s+', '', processed_value)
|
||
processed_value = re.sub(r'[€$£¥]', '', processed_value).strip()
|
||
processed_value = re.split(r'\s*(-|–|bis)\s*', processed_value, 1)[0].strip()
|
||
|
||
# Schritt 3: Klammerinhalte entfernen (z.B. Jahreszahlen)
|
||
num_extraction_str = re.sub(r'\(.*?\)', '', processed_value).strip()
|
||
|
||
# Schritt 4: Apostrophe und Leerzeichen zwischen Ziffern entfernen
|
||
num_extraction_str = num_extraction_str.replace("'", "")
|
||
num_extraction_str = re.sub(r'(?<=\d)\s+(?=\d)', '', num_extraction_str) # Entfernt Leerzeichen nur zwischen Ziffern
|
||
|
||
if not num_extraction_str: return 0.0 if is_umsatz else 0
|
||
|
||
# Schritt 5: Punkte und Kommas als Tausender-/Dezimaltrennzeichen standardisieren
|
||
has_dot = '.' in num_extraction_str
|
||
has_comma = ',' in num_extraction_str
|
||
|
||
if has_dot and has_comma:
|
||
if num_extraction_str.rfind('.') > num_extraction_str.rfind(','): # US-Stil: 1,234.56
|
||
num_extraction_str = num_extraction_str.replace(',', '') # Kommas als Tausendertrenner entfernen
|
||
else: # EU-Stil: 1.234,56
|
||
num_extraction_str = num_extraction_str.replace('.', '') # Punkte als Tausendertrenner entfernen
|
||
num_extraction_str = num_extraction_str.replace(',', '.') # Komma zu Dezimalpunkt
|
||
elif has_comma: # Nur Kommas
|
||
# Wenn ein einzelnes Komma wahrscheinlich ein Dezimaltrennzeichen ist (z.B. "123,45")
|
||
if num_extraction_str.count(',') == 1 and re.search(r',\d{1,2}$', num_extraction_str) and not re.search(r',\d{3}(,|\s|\Z)', num_extraction_str):
|
||
num_extraction_str = num_extraction_str.replace(',', '.')
|
||
else: # Ansonsten sind Kommas Tausendertrenner
|
||
num_extraction_str = num_extraction_str.replace(',', '')
|
||
elif has_dot: # Nur Punkte
|
||
# Wenn ein einzelner Punkt wahrscheinlich ein Dezimaltrennzeichen ist (z.B. "123.45")
|
||
if num_extraction_str.count('.') == 1 and re.search(r'\.\d{1,2}$', num_extraction_str) and not re.search(r'\.\d{3}(?!\d)', num_extraction_str): # (?!\d) stellt sicher, dass nicht .000 Teil einer größeren Zahl ist
|
||
pass # Punkt ist Dezimal, bleibt
|
||
else: # Ansonsten sind Punkte Tausendertrenner
|
||
num_extraction_str = num_extraction_str.replace('.', '')
|
||
|
||
# Schritt 6: Finale Validierung und Konvertierung zu float
|
||
if not re.fullmatch(r'-?\d+(\.\d+)?', num_extraction_str): # Prüft ob String eine gültige Zahl ist
|
||
logger.debug(f"Kein gültiger numerischer String nach Trennzeichenbehandlung: '{num_extraction_str}' (Original: '{raw_value_str_original}')")
|
||
return 0.0 if is_umsatz else 0
|
||
|
||
num_as_float = float(num_extraction_str)
|
||
|
||
# Schritt 7: Einheiten-Skalierung
|
||
scaled_num = num_as_float
|
||
original_lower = raw_value_str_original.lower()
|
||
|
||
if is_umsatz: # Zielwert soll in Millionen sein
|
||
if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower):
|
||
scaled_num = num_as_float * 1000.0
|
||
elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower):
|
||
scaled_num = num_as_float / 1000.0
|
||
# Ansonsten: num_as_float wird als bereits in Mio. € interpretiert (da CRM-Umsatz so definiert ist)
|
||
else: # Mitarbeiter (absolute Zahl)
|
||
if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower): scaled_num = num_as_float * 1000000000.0
|
||
elif re.search(r'\bmio\s*\b|\bmillionen\s*\b|\bmill[.]?\s*\b', original_lower): scaled_num = num_as_float * 1000000.0
|
||
elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): scaled_num = num_as_float * 1000.0
|
||
|
||
# Für Filterlogik nur positive Werte, sonst 0
|
||
return scaled_num if scaled_num > 0 else (0.0 if is_umsatz else 0)
|
||
|
||
except ValueError as e:
|
||
logger.debug(f"ValueError '{e}' bei Konvertierung (get_numeric_filter_value) von '{num_extraction_str if 'num_extraction_str' in locals() and isinstance(num_extraction_str, str) else raw_value_str_original[:30]}...'")
|
||
return 0.0 if is_umsatz else 0
|
||
except Exception as e_general:
|
||
logger.error(f"Unerwarteter Fehler in get_numeric_filter_value für '{raw_value_str_original[:50]}...': {e_general}")
|
||
logger.debug(traceback.format_exc())
|
||
return 0.0 if is_umsatz else 0
|
||
|
||
|
||
# ==============================================================================
|
||
# Ende Numerische Extraktion Utilities Block
|
||
# ==============================================================================
|
||
|
||
# ==============================================================================
|
||
# GLOBALE HELPER FUNCTIONS (PART 4: Gender & Email Utilities)
|
||
# ==============================================================================
|
||
|
||
# --- Gender und Email Helpers ---
|
||
# Basierend auf Code aus Teil 4.
|
||
# Nutzt globale Helfer: gender_guesser, Config.API_KEYS, requests, retry_on_failure,
|
||
# simple_normalize_url, normalize_string, re.
|
||
|
||
# Annahme: gender_guesser ist installiert
|
||
# Initialisieren Sie den Detector einmal global, um Ressourcen zu sparen
|
||
try:
|
||
import gender_guesser.detector as gender
|
||
gender_detector = gender.Detector()
|
||
print("gender_guesser.Detector initialisiert.") # Verwendet print
|
||
except ImportError:
|
||
gender = None # Setzen Sie den Namen auf None, falls der Import fehlschlaegt
|
||
gender_detector = None
|
||
print("WARNUNG: gender_guesser Bibliothek nicht gefunden. Geschlechtserkennung deaktiviert.") # Verwendet print
|
||
except Exception as e:
|
||
gender = None # Setzen Sie den Namen auf None, falls der Import fehlschlaegt
|
||
gender_detector = None
|
||
print(f"FEHLER: Fehler bei Initialisierung von gender_guesser: {e}. Geschlechtserkennung deaktiviert.") # Verwendet print
|
||
|
||
|
||
def get_gender(firstname):
|
||
"""Ermittelt Geschlecht via gender-guesser und Fallback Genderize API."""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
|
||
if not firstname or not isinstance(firstname, str): return "unknown"
|
||
# Nehmen Sie nur den ersten Teil des Vornamens und bereinigen Sie ihn
|
||
firstname_clean = str(firstname).strip().split(" ")[0]
|
||
if not firstname_clean: return "unknown"
|
||
|
||
# 1. Versuch: gender-guesser (nutzt globale Instanz)
|
||
result_gg = "unknown"
|
||
if gender_detector: # Pruefe, ob der Detector initialisiert wurde
|
||
try:
|
||
# get_gender ist case-insensitive per Default
|
||
result_gg = gender_detector.get_gender(firstname_clean)
|
||
# logger.debug(f"GenderGuesser fuer '{firstname_clean}': {result_gg}") # Zu viel Laerm
|
||
except Exception as e_gg:
|
||
logger.warning(f"Fehler bei gender-guesser fuer '{firstname_clean}': {e_gg}")
|
||
result_gg = "unknown" # Fallback bei Fehler
|
||
|
||
# 2. Fallback: Genderize API (nur wenn gender-guesser unsicher ist)
|
||
# Wenn gender-guesser ein unsicheres Ergebnis liefert ("andy", "unknown", "mostly_...")
|
||
if result_gg in ["andy", "unknown", "mostly_male", "mostly_female"]:
|
||
genderize_key = Config.API_KEYS.get('genderize')
|
||
# Nur versuchen, wenn der API Key verfuegbar ist
|
||
if not genderize_key:
|
||
# logger.debug("Genderize API-Schluessel nicht verfuegbar, Fallback nicht moeglich.") # Zu viel Laerm
|
||
# Geben Sie das Ergebnis von gender-guesser zurueck, wenn es "mostly_" war, sonst "unknown"
|
||
return result_gg if result_gg.startswith("mostly_") else "unknown"
|
||
|
||
# API Call nutzt den retry_on_failure Decorator
|
||
# Definiere die Funktion hier, da sie spezifisch fuer diesen Anwendungsfall ist
|
||
@retry_on_failure
|
||
def call_genderize(name, api_key):
|
||
params = {"name": name, "apikey": api_key, "country_id": "DE"} # DE als Standardland
|
||
# logger.debug(f"Genderize API-Anfrage fuer '{name}'...") # Zu viel Laerm im Debug
|
||
# Fuehrt die GET-Anfrage aus. Der retry_on_failure Decorator behandelt RequestsExceptions.
|
||
response = requests.get("https://api.genderize.io", params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) # Nutze Config Timeout
|
||
# Wirft HTTPError fuer schlechte Antworten (4xx oder 5xx), wird vom Decorator gefangen
|
||
response.raise_for_status()
|
||
# Gibt die JSON-Antwort zurueck
|
||
data = response.json()
|
||
# logger.debug(f" -> Genderize Antwort fuer '{name}': {data}") # Zu viel Laerm im Debug
|
||
return data
|
||
|
||
try:
|
||
# Rufen Sie die API-Wrapper-Funktion auf
|
||
genderize_data = call_genderize(firstname_clean, genderize_key)
|
||
|
||
# Extrahieren Sie die Daten aus der API-Antwort
|
||
api_gender = genderize_data.get("gender")
|
||
probability = genderize_data.get("probability", 0)
|
||
count = genderize_data.get("count", 0) # Anzahl der Datenpunkte fuer diesen Namen
|
||
|
||
# Nur bei ausreichender Sicherheit (z.B. Wahrscheinlichkeit > 0.7) und wenn Genderize ein Ergebnis liefert
|
||
# und Datenpunkte vorhanden sind (count > 0).
|
||
if api_gender and probability is not None and probability > 0.7 and count is not None and count > 0:
|
||
# Loggen Sie die Uebernahme des API-Ergebnisses
|
||
logger.debug(f" -> Uebernehme Genderize Ergebnis '{api_gender}' (Prob: {probability}, Count: {count}) fuer '{firstname_clean}'")
|
||
return api_gender # Geben Sie das API-Ergebnis zurueck
|
||
else:
|
||
# Wenn Genderize unsicher ist oder kein valides Ergebnis liefert
|
||
# logger.debug(f" -> Genderize unsicher/kein Ergebnis fuer '{firstname_clean}'. Nutze Fallback: '{result_gg}'") # Zu viel Laerm
|
||
# Geben Sie das Ergebnis von gender-guesser zurueck, wenn es "mostly_" war, sonst "unknown"
|
||
return result_gg if result_gg.startswith("mostly_") else "unknown"
|
||
|
||
except Exception as e: # retry_on_failure wirft Exception im Fehlerfall erneut
|
||
# Wenn der API Call nach Retries fehlschlaegt, fangen wir die Exception hier.
|
||
# Der Fehler wird bereits vom retry_on_failure Decorator geloggt.
|
||
logger.error(f"FEHLER bei der Genderize API-Anfrage fuer '{firstname_clean}' nach Retries: {e}")
|
||
# Geben Sie das Ergebnis von gender-guesser als Fallback zurueck
|
||
return result_gg if result_gg.startswith("mostly_") else "unknown"
|
||
else:
|
||
# Wenn gender-guesser sicher war ("male", "female"), geben Sie das Ergebnis direkt zurueck
|
||
return result_gg
|
||
|
||
|
||
def get_email_address(firstname, lastname, website):
|
||
"""
|
||
Generiert eine moegliche E-Mail-Adresse im Format vorname.nachname@domain.tld.
|
||
Normalisiert Namen und extrahiert die Domain aus der Website-URL.
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
|
||
if not all([firstname, lastname, website]) or not all(isinstance(x, str) and x.strip() for x in [firstname, lastname, website]): # Pruefen Sie auf nicht-leere Strings
|
||
logger.debug("get_email_address skipped: Fehlende oder ungueltige Eingabe (Name, Website).")
|
||
return "" # Gebe leeren String bei fehlenden/ungueltigen Eingaben zurueck
|
||
|
||
# Extrahieren und normalisieren Sie die Domain aus der Website-URL (nutzt globale Helfer)
|
||
domain = simple_normalize_url(website)
|
||
# Wenn die Domain nicht gueltig ist oder keinen Punkt enthaelt (was fuer eine E-Mail-Domain notwendig ist)
|
||
if domain == "k.A." or '.' not in domain:
|
||
logger.debug(f"get_email_address skipped: Ungueltige Domain extrahiert aus '{website}'.")
|
||
return "" # Gebe leeren String bei ungueltiger Domain zurueck
|
||
|
||
|
||
# Normalisiere Vor- und Nachname (nutzt globale Helfer), konvertiere zu Kleinbuchstaben
|
||
normalized_first = normalize_string(firstname).lower()
|
||
normalized_last = normalize_string(lastname).lower()
|
||
|
||
# Ersetze Leerzeichen und mehrere Bindestriche durch einen einzelnen Bindestrich
|
||
normalized_first = re.sub(r'\s+', '-', normalized_first)
|
||
normalized_last = re.sub(r'\s+', '-', normalized_last)
|
||
|
||
# Entferne alle Zeichen, die NICHT alphanumerisch ('\w') oder Bindestrich ('-') sind
|
||
# Dies stellt sicher, dass die generierte E-Mail-Adresse gueltige Zeichen enthaelt
|
||
normalized_first = re.sub(r'[^\w\-]+', '', normalized_first)
|
||
normalized_last = re.sub(r'[^\w\-]+', '', normalized_last)
|
||
|
||
# Entferne fuehrende oder endende Bindestriche, falls sie nach der Bereinigung entstanden sind
|
||
normalized_first = normalized_first.strip('-')
|
||
normalized_last = normalized_last.strip('-')
|
||
|
||
# Stellen Sie sicher, dass sowohl Vor- als auch Nachname nicht leer sind nach der Bereinigung
|
||
if normalized_first and normalized_last and domain:
|
||
# Kombinieren Sie die bereinigten Teile zur E-Mail-Adresse
|
||
email_address = f"{normalized_first}.{normalized_last}@{domain}"
|
||
# logger.debug(f"Generierte E-Mail-Adresse: {email_address}") # Zu viel Laerm im Debug
|
||
return email_address
|
||
else:
|
||
# Wenn Vorname oder Nachname nach der Bereinigung leer sind
|
||
logger.debug("get_email_address skipped: Vorname oder Nachname leer nach Bereinigung.")
|
||
return "" # Gebe leeren String zurueck
|
||
|
||
|
||
# ==============================================================================
|
||
# Ende Gender & Email Utilities Block
|
||
# ==============================================================================
|
||
|
||
# ==============================================================================
|
||
# GLOBALE HELPER FUNCTIONS (PART 5: Schema Loading Utility)
|
||
# ==============================================================================
|
||
|
||
# --- Schema Loading (Ziel-Branchenschema) ---
|
||
# Basierend auf Code aus Teil 4.
|
||
# Lädt die Liste der erlaubten Zielbranchen aus einer CSV-Datei.
|
||
# Nutzt globale Variablen: BRANCH_MAPPING, TARGET_SCHEMA_STRING, ALLOWED_TARGET_BRANCHES.
|
||
# Nutzt globale Helfer: csv, os, logger.
|
||
|
||
# Globale Variablen für Branch Mapping (werden von load_target_schema() befüllt)
|
||
BRANCH_MAPPING = {} # Wird in dieser Version nicht primaer fuer Mapping genutzt, kann aber beibehalten werden
|
||
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar." # String-Repraesentation des Schemas fuer Prompts
|
||
ALLOWED_TARGET_BRANCHES = [] # Liste der erlaubten Kurzformen
|
||
|
||
|
||
def load_target_schema(csv_filepath=BRANCH_MAPPING_FILE):
|
||
logger = logging.getLogger(__name__)
|
||
global ALLOWED_TARGET_BRANCHES, FOCUS_TARGET_BRANCHES, TARGET_SCHEMA_STRING, FOCUS_BRANCHES_PROMPT_PART
|
||
|
||
ALLOWED_TARGET_BRANCHES = []
|
||
FOCUS_TARGET_BRANCHES = []
|
||
allowed_branches_set = set()
|
||
focus_branches_set = set()
|
||
line_count = 0
|
||
|
||
logger.info(f"Lade Ziel-Schema und Fokus-Branchen aus '{csv_filepath}'...")
|
||
|
||
try: # Diese Zeile muss auf derselben Ebene wie logger.info darüber sein
|
||
with open(csv_filepath, "r", encoding="utf-8-sig") as f:
|
||
reader = csv.reader(f, delimiter=';')
|
||
try:
|
||
header_row = next(reader)
|
||
logger.debug(f"Ueberspringe Header-Zeile im Schema: {header_row}")
|
||
except StopIteration:
|
||
logger.warning(f"Schema-Datei '{csv_filepath}' ist leer oder hat keinen Header.")
|
||
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar (Datei leer)."
|
||
FOCUS_BRANCHES_PROMPT_PART = ""
|
||
return
|
||
|
||
for row_num, row in enumerate(reader, 1): # Starte Zählung bei 1 für Zeilennummern nach Header
|
||
line_count = row_num
|
||
if not row: # Leere Zeile überspringen
|
||
continue
|
||
|
||
if len(row) >= 1:
|
||
target_branch = row[0].strip()
|
||
if target_branch:
|
||
allowed_branches_set.add(target_branch)
|
||
# Prüfe Spalte B (Index 1) für Fokus-Markierung
|
||
if len(row) >= 2 and row[1].strip().upper() in ["X", "FOKUS", "JA", "TRUE", "1"]:
|
||
focus_branches_set.add(target_branch)
|
||
logger.debug(f" -> Fokusbranche gefunden: '{target_branch}'")
|
||
except FileNotFoundError:
|
||
logger.critical(f"FEHLER: Schema-Datei '{csv_filepath}' nicht gefunden.")
|
||
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar (Datei nicht gefunden)."
|
||
FOCUS_BRANCHES_PROMPT_PART = ""
|
||
return
|
||
except Exception as e:
|
||
logger.critical(f"FEHLER beim Laden des Ziel-Schemas aus '{csv_filepath}' (Zeile {line_count}): {e}")
|
||
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar (Fehler beim Lesen)."
|
||
FOCUS_BRANCHES_PROMPT_PART = ""
|
||
return
|
||
|
||
ALLOWED_TARGET_BRANCHES = sorted(list(allowed_branches_set), key=str.lower)
|
||
FOCUS_TARGET_BRANCHES = sorted(list(focus_branches_set), key=str.lower)
|
||
|
||
logger.info(f"Ziel-Schema geladen: {len(ALLOWED_TARGET_BRANCHES)} eindeutige Zielbranchen, davon {len(FOCUS_TARGET_BRANCHES)} Fokusbranchen.")
|
||
|
||
if ALLOWED_TARGET_BRANCHES:
|
||
schema_lines = ["Ziel-Branchenschema: Folgende Branchenbereiche sind gueltig (Kurzformen):"]
|
||
schema_lines.extend(f"- {branch}" for branch in ALLOWED_TARGET_BRANCHES)
|
||
# Anweisungen für das Antwortformat (unverändert)
|
||
schema_lines.append("\nBitte ordne das Unternehmen ausschliesslich in einen dieser Bereiche ein. Gib NUR den exakten Kurznamen der Branche zurueck (keine Praefixe oder zusaetzliche Erklaerungen ausser im 'Begruendung'-Feld).")
|
||
schema_lines.append("Antworte ausschliesslich im folgenden Format (keine Einleitung, kein Schlusssatz):")
|
||
schema_lines.append("Branche: <Exakter Kurzname der Branche aus der Liste>")
|
||
# NEU: Konfidenz hier ins Antwortformat aufnehmen
|
||
schema_lines.append("Konfidenz: <Hoch, Mittel oder Niedrig>")
|
||
schema_lines.append("Uebereinstimmung: <ok oder X (Vergleich deines Vorschlags mit der extrahierten Kurzform der CRM-Referenz)>")
|
||
schema_lines.append("Begruendung: <Sehr kurze Begruendung fuer deinen Branchenvorschlag>")
|
||
TARGET_SCHEMA_STRING = "\n".join(schema_lines)
|
||
|
||
if FOCUS_TARGET_BRANCHES:
|
||
focus_prompt_lines = ["\nZusätzlicher Hinweis: Wenn die Wahl zwischen mehreren passenden Branchen besteht, priorisiere bitte, wenn möglich, eine der folgenden Fokusbranchen:"]
|
||
focus_prompt_lines.extend(f"- {branch}" for branch in FOCUS_TARGET_BRANCHES)
|
||
FOCUS_BRANCHES_PROMPT_PART = "\n".join(focus_prompt_lines)
|
||
else:
|
||
FOCUS_BRANCHES_PROMPT_PART = ""
|
||
logger.info("Keine Fokusbranchen im Schema definiert.")
|
||
else:
|
||
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar (Keine gueltigen Branchen in Datei gefunden)."
|
||
FOCUS_BRANCHES_PROMPT_PART = ""
|
||
logger.warning("Keine gueltigen Zielbranchen im Schema gefunden. Branchenbewertung ist nicht moeglich.")
|
||
|
||
|
||
# map_external_branch ist in dieser Version nicht mehr notwendig,
|
||
# da die Branchenevaluation ueber ChatGPT (evaluate_branche_chatgpt)
|
||
# direkt gegen ALLOWED_TARGET_BRANCHES validiert.
|
||
|
||
|
||
# ==============================================================================
|
||
# Ende Schema Loading Utility Block
|
||
# ==============================================================================
|
||
|
||
# ==============================================================================
|
||
# GLOBALE HELPER FUNCTIONS (PART 6: OpenAI API Call Wrapper)
|
||
# ==============================================================================
|
||
|
||
# --- OpenAI / CHATGPT FUNCTIONS ---
|
||
# Zentrale Funktion fuer OpenAI Chat API Aufrufe.
|
||
# Nutzt globale Helfer: Config.API_KEYS, openai, retry_on_failure, token_count (optional), logger, ValueError.
|
||
@retry_on_failure # Wende den Decorator auf diesen API Call an
|
||
def call_openai_chat(prompt, temperature=0.3, model=None):
|
||
"""
|
||
Zentrale Funktion fuer OpenAI Chat API Aufrufe.
|
||
Wird von anderen globalen Helfern oder DataProcessor Methoden aufgerufen.
|
||
|
||
Args:
|
||
prompt (str): Der Prompt-Text an die API.
|
||
temperature (float, optional): Die Temperatur fuer die Textgenerierung. Defaults to 0.3.
|
||
model (str, optional): Das zu verwendende OpenAI Modell. Defaults to Config.TOKEN_MODEL.
|
||
|
||
Returns:
|
||
str: Der bereinigte Antwortstring von der API.
|
||
Wirft Exception bei API-Fehlern nach Retries.
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
|
||
# Pruefen Sie, ob der API Key konfiguriert ist
|
||
if not Config.API_KEYS.get('openai'):
|
||
logger.error("Fehler: OpenAI API Key nicht konfiguriert.")
|
||
# Werfen Sie eine spezifische Exception, die vom retry_on_failure als permanent behandelt wird
|
||
raise openai.error.AuthenticationError("OpenAI API Key nicht konfiguriert.")
|
||
|
||
# Pruefen Sie, ob der Prompt leer ist
|
||
if not prompt or not isinstance(prompt, str) or not prompt.strip():
|
||
logger.error("Fehler: Leerer Prompt fuer OpenAI.")
|
||
# Werfen Sie eine Value Error Exception, die vom retry_on_failure behandelt wird
|
||
raise ValueError("Leerer Prompt fuer OpenAI.")
|
||
|
||
# Bestimmen Sie das zu verwendende Modell (CLI > Config > Default)
|
||
current_model = model if model else getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo')
|
||
|
||
try:
|
||
# Optional: Token zaehlen vor dem Senden (gut fuer Debugging/Monitoring)
|
||
# try:
|
||
# # Schaetzen Sie die Token-Zahl des Prompts
|
||
# prompt_tokens = token_count(prompt, model=current_model)
|
||
# # Loggen Sie die geschaetzte Token-Zahl auf Debug-Level
|
||
# logger.debug(f"Sende Prompt an OpenAI ({current_model}, geschaetzt {prompt_tokens} Tokens)...")
|
||
# except Exception as e_tc:
|
||
# # Logge Fehler beim Token-Zaehlen, aber fahre fort
|
||
# logger.debug(f"Fehler beim Token-Counting fuer Prompt: {e_tc}");
|
||
|
||
|
||
# Fuehren Sie den API Call durch. Dieser kann verschiedene Exceptions werfen (APIError, RateLimitError, InvalidRequestError etc.).
|
||
# Diese werden vom @retry_on_failure Decorator dieser Funktion behandelt.
|
||
response = openai.ChatCompletion.create(
|
||
model=current_model,
|
||
messages=[{"role": "user", "content": prompt}],
|
||
temperature=temperature
|
||
# Fuegen Sie hier ggf. weitere Parameter hinzu (max_tokens, top_p, frequency_penalty etc.)
|
||
)
|
||
|
||
# Ueberpruefen Sie die Struktur der Antwort, um sicherzustellen, dass Choices vorhanden sind
|
||
if not response or not hasattr(response, 'choices') or not response.choices:
|
||
# Wenn die API erfolgreich antwortet, aber keine Choices liefert (unerwartet)
|
||
logger.error(f"OpenAI Call erfolgreich, aber keine Choices in der Antwort erhalten. Response: {str(response)[:200]}...")
|
||
# Werfen Sie eine spezifische Exception, die vom Decorator behandelt wird
|
||
raise openai.error.APIError("Keine Choices in OpenAI Antwort erhalten.")
|
||
|
||
# Extrahieren Sie den Inhalt der ersten (und normalerweise einzigen) Antwort
|
||
# Sicherstellen, dass message und content Attribute existieren
|
||
result = response.choices[0].message.content.strip() if hasattr(response.choices[0], 'message') and hasattr(response.choices[0].message, 'content') else ""
|
||
|
||
# Wenn der extrahierte Inhalt leer ist
|
||
if not result:
|
||
logger.warning(f"OpenAI Call erfolgreich, erhielt aber leeren Inhalt in der Antwort. Prompt Anfang: {prompt[:100]}...")
|
||
# Sie koennen hier entscheiden, ob dies ein Fehler ist, der ein Retry rechtfertigt
|
||
# oder ob ein leerer String eine gueltige (wenn auch unerwuenschte) Antwort ist.
|
||
# Furs Erste werfen wir eine spezifische Exception, um es im Log zu sehen und ggf. zu wiederholen.
|
||
#raise openai.error.APIError("OpenAI Antwort hatte leeren Inhalt.") # Kann zu aggressiv sein
|
||
|
||
# Lassen wir es einfach leer zurueckgeben, wenn die API einen leeren Inhalt liefert.
|
||
return "" # Gebe einen leeren String zurueck
|
||
|
||
|
||
# Optional: Token zaehlen fuer die Antwort
|
||
# try:
|
||
# # Schaetzen Sie die Token-Zahl der Antwort
|
||
# completion_tokens = token_count(result, model=current_model)
|
||
# # Versuchen Sie, die tatsaechliche Gesamt-Token-Zahl aus dem Usage-Objekt zu holen
|
||
# total_tokens = response.usage.total_tokens if hasattr(response, 'usage') else 'N/A'
|
||
# logger.debug(f"OpenAI Antwort erhalten ({completion_tokens} Completion Tokens, {total_tokens} Gesamt).")
|
||
# # TODO: Token-Zahl irgendwo sammeln und im Sheet speichern (z.B. im DataProcessor)
|
||
# except Exception as e_tc:
|
||
# logger.debug(f"Fehler beim Token-Zaehlen der Antwort oder Usage Info: {e_tc}");
|
||
|
||
|
||
return result # Gibt den bereinigten Antwortstring zurueck
|
||
|
||
# Die spezifischen OpenAI Exceptions werden vom retry_on_failure Decorator gefangen.
|
||
# Nur andere unerwartete Exceptions kommen hier direkt an.
|
||
except Exception as e:
|
||
# Fangen Sie alle anderen unerwarteten Exceptions ab (z. B. Programmierfehler).
|
||
# Diese werden bereits vom retry_on_failure Decorator als "UNERWARTETER FEHLER" geloggt
|
||
# und dort (standardmaessig) nicht wiederholt, sondern sofort weitergeleitet.
|
||
# Werfen Sie die Exception erneut, damit der retry_on_failure Decorator sie fangen kann.
|
||
raise e # Leiten Sie die Exception weiter
|
||
|
||
|
||
# ==============================================================================
|
||
# Ende OpenAI API Call Wrapper Block
|
||
# ==============================================================================
|
||
|
||
# ==============================================================================
|
||
# GLOBALE HELPER FUNCTIONS (PART 7: OpenAI Summary Helpers)
|
||
# ==============================================================================
|
||
|
||
# --- OpenAI Summary Helpers ---
|
||
# Funktionen zur Zusammenfassung von Website-Inhalten mittels OpenAI.
|
||
# Nutzt globale Helfer: call_openai_chat, logger, token_count (optional), retry_on_failure.
|
||
|
||
def summarize_website_content(raw_text):
|
||
"""
|
||
Erstellt eine Zusammenfassung eines Website-Rohtextes ueber OpenAI.
|
||
|
||
Args:
|
||
raw_text (str): Der rohe Textinhalt der Website.
|
||
|
||
Returns:
|
||
str: Die generierte Zusammenfassung oder ein Fehlerwert ("k.A.", etc.).
|
||
Wirft Exception bei API-Fehlern nach Retries (von call_openai_chat).
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
|
||
# Pruefe, ob gueltiger Rohtext vorhanden ist (nicht leer oder Standard-Fehlerwerte)
|
||
if not raw_text or str(raw_text).strip() == "" or str(raw_text).strip().lower() in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]:
|
||
logger.debug("summarize_website_content skipped: No valid raw text provided.")
|
||
return "k.A." # Gebe "k.A." zurueck, wenn kein gueltiger Rohtext vorliegt
|
||
|
||
|
||
# Kuerze den Rohtext, falls er sehr lang ist, um Token zu sparen/Limits zu vermeiden.
|
||
# Die maximale Laenge des Prompts ist das Limit minus der erwarteten Antwortlaenge.
|
||
# Eine konservative Schaetzung fuer den Eingabetext sind 3000 Zeichen.
|
||
max_raw_length = 3000
|
||
if len(str(raw_text)) > max_raw_length:
|
||
logger.debug(f"Kuerze Rohtext fuer Zusammenfassung von {len(str(raw_text))} auf {max_raw_length} Zeichen.")
|
||
raw_text = str(raw_text)[:max_raw_length] # Kuerzen des Textes
|
||
|
||
|
||
# Erstelle den Prompt fuer die Zusammenfassung
|
||
prompt = (
|
||
"Du bist ein KI-Assistent, der Webinhalte analysiert.\n"
|
||
"Fasse den folgenden Text einer Unternehmenswebsite praegnant zusammen. "
|
||
"Konzentriere dich dabei auf:\n"
|
||
"- Haupttaetigkeitsfeld des Unternehmens\n"
|
||
"- Wichtigste Produkte und/oder Dienstleistungen\n"
|
||
"- Zielgruppe (falls erkennbar)\n\n"
|
||
f"Website-Text:\n```\n{raw_text}\n```\n\n"
|
||
"Zusammenfassung (max. 100 Woerter):"
|
||
)
|
||
|
||
# Rufe die zentrale OpenAI Chat API Funktion auf.
|
||
# call_openai_chat nutzt den retry_on_failure Decorator und wirft bei endgueltigem Fehler eine Exception.
|
||
# Diese Exception wird hier NICHT gefangen, sondern weitergereicht (z.B. an _process_single_row).
|
||
try:
|
||
# Standard Temperatur 0.2 fuer Zusammenfassungen
|
||
summary = call_openai_chat(prompt, temperature=0.2)
|
||
# Wenn call_openai_chat erfolgreich ist, gibt es den String zurueck (auch wenn leer).
|
||
# Wenn der extrahierte Inhalt leer ist, geben wir "k.A." zurueck.
|
||
return summary if summary and summary.strip() else "k.A. (Keine Zusammenfassung erhalten)"
|
||
except Exception as e:
|
||
# Wenn call_openai_chat nach Retries eine Exception wirft
|
||
# Der Fehler wird bereits vom retry_on_failure Decorator geloggt.
|
||
# Geben Sie einen Fehlerwert zurueck, der im Sheet gespeichert werden kann.
|
||
# Die Exception wird hier gefangen, um einen Rueckgabewert zu liefern, anstatt die Exception weiterzuleiten.
|
||
logger.error(f"FEHLER bei Website Zusammenfassung nach Retries: {e}") # Logge den Fehler
|
||
return f"k.A. (Fehler Zusammenfassung: {str(e)[:50]}...)" # Signalisiert Fehler
|
||
|
||
|
||
# --- Batch-Zusammenfassungsfunktion ---
|
||
# Fasst mehrere Texte in einem einzigen OpenAI API Call zusammen.
|
||
# Basierend auf summarize_batch_openai aus Teil 7/9.
|
||
# Nutzt globale Helfer: call_openai_chat, logger, token_count (optional), retry_on_failure.
|
||
@retry_on_failure # Wende den Decorator auf den gesamten Batch-API Call an
|
||
def summarize_batch_openai(tasks_data):
|
||
"""
|
||
Fasst eine Liste von Rohtexten in einem einzigen OpenAI API Call zusammen.
|
||
Dies ist effizienter fuer die Verarbeitung mehrerer Zusammenfassungen.
|
||
|
||
Args:
|
||
tasks_data (list): Eine Liste von Dictionaries, jedes enthaelt:
|
||
{'row_num': int, 'raw_text': str}
|
||
|
||
Returns:
|
||
dict: Ein Dictionary, das Zeilennummern auf ihre Zusammenfassungen mappt.
|
||
z.B. {2122: "Zusammenfassung A", 2123: "Zusammenfassung B"}
|
||
Bei Fehlern oder fehlenden Zusammenfassungen wird ein Fehlerstring verwendet.
|
||
Wirft Exception bei endgueltigen API-Fehlern nach Retries.
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
|
||
if not tasks_data: return {} # Gebe leeres Dictionary zurueck, wenn keine Tasks da sind
|
||
|
||
# Filtere Tasks, die gueltigen Text haben (nicht leer oder Standard-Fehlerwerte).
|
||
valid_tasks = [t for t in tasks_data if t.get("raw_text") and str(t["raw_text"]).strip().lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]]
|
||
# Wenn keine gueltigen Tasks vorhanden sind
|
||
if not valid_tasks:
|
||
logger.debug("Keine gueltigen Rohtexte fuer Batch-Zusammenfassung gefunden.")
|
||
# Geben Sie ein Ergebnisdict zurueck, das dies fuer alle urspruenglichen Zeilen widerspiegelt
|
||
return {t['row_num']: "k.A. (Kein gueltiger Rohtext im Batch)" for t in tasks_data}
|
||
|
||
|
||
logger.debug(f"Starte Batch-Zusammenfassung fuer {len(valid_tasks)} gueltige Texte (Zeilen: {[t['row_num'] for t in valid_tasks]})...")
|
||
|
||
# --- Aggregierten Prompt erstellen ---
|
||
prompt_parts = [
|
||
"Du bist ein KI-Assistent, der Webinhalte analysiert.",
|
||
"Fasse fuer JEDEN der folgenden Texte einer Unternehmenswebsite praegnant zusammen. " # Umlaute vermeiden im Prompt
|
||
"Konzentriere dich dabei auf:\n"
|
||
"- Haupttaetigkeitsfeld des Unternehmens\n" # Umlaute vermeiden
|
||
"- Wichtigste Produkte und/oder Dienstleistungen\n"
|
||
"- Zielgruppe (falls erkennbar)\n\n"
|
||
"Gib das Ergebnis fuer JEDEN Text im folgenden Format aus, auf einer neuen Zeile:\n" # Umlaute vermeiden
|
||
"RESULTAT <Zeilennummer>: <Zusammenfassung fuer diese Zeilennummer>\n\n" # Umlaute vermeiden
|
||
"Halte jede Zusammenfassung kurz, max. 100 Woerter.\n\n", # Umlaute vermeiden
|
||
"--- Texte zur Zusammenfassung ---"
|
||
]
|
||
text_block = ""
|
||
row_numbers_in_batch = [] # Liste der Zeilennummern, die tatsaechlich in diesem API-Prompt enthalten sind
|
||
|
||
# Baue den Textblock zusammen. Kuerze jeden einzelnen Text, um das Gesamtprompt-Limit nicht zu sprengen.
|
||
max_chars_per_single_text_in_batch = 1500 # Zeichenlimit fuer jeden Text innerhalb des Batch-Prompts
|
||
|
||
for task in valid_tasks:
|
||
row_num = task['row_num']
|
||
raw_text = str(task['raw_text']) # Sicherstellen, dass es ein String ist
|
||
raw_text_short = raw_text[:max_chars_per_single_text_in_batch] # Kuerzen fuer den Prompt
|
||
|
||
entry_text = f"\n--- TEXT Zeile {row_num} ---\n{raw_text_short}\n--- ENDE TEXT Zeile {row_num} ---\n"
|
||
text_block += entry_text
|
||
row_numbers_in_batch.append(row_num) # Fuege die Zeilennummer zur Liste im Batch hinzu
|
||
|
||
|
||
# Wenn nach der Filterung und Kuerzung keine Zeilen mehr uebrig sind (sollte oben abgefangen sein, aber zur Sicherheit)
|
||
if not row_numbers_in_batch:
|
||
logger.debug("Keine Zeilen uebrig fuer OpenAI Prompt nach Filterung/Kuerzung im Batch.")
|
||
return {t['row_num']: "k.A. (Kein Rohtext im Batch)" for t in tasks_data}
|
||
|
||
|
||
prompt_parts.append(text_block)
|
||
prompt_parts.append("\n--- Ende der Texte ---")
|
||
prompt_parts.append("\nBitte gib NUR die 'RESULTAT <Zeilennummer>: ...' Zeilen zurueck.")
|
||
final_prompt = "\n".join(prompt_parts)
|
||
|
||
# Optional: Token zaehlen zur Info, aber nicht zur Blockade
|
||
# try: prompt_tokens = token_count(final_prompt, model=getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo')); logger.debug(f"Geschaetzt Prompt-Tokens fuer Batch: {prompt_tokens}.");
|
||
# except Exception as e_tc: logger.debug(f"Fehler beim Token-Zaehlen: {e_tc}");
|
||
|
||
|
||
# --- OpenAI API Call ---
|
||
# call_openai_chat nutzt den retry_on_failure Decorator und wirft bei endgueltigem Fehler eine Exception.
|
||
# Der retry_on_failure Decorator auf dieser summarize_batch_openai Funktion faengt die Exception
|
||
# von call_openai_chat und fuehrt die Retries fuer die GESAMTE Batch-Funktion durch.
|
||
chat_response = None
|
||
try:
|
||
chat_response = call_openai_chat(final_prompt, temperature=0.2)
|
||
# Wenn call_openai_chat erfolgreich ist, gibt es den String zurueck.
|
||
# Exceptions werden nach Retries geworfen und vom aeusseren retry_on_failure dieser Funktion gefangen.
|
||
|
||
if not chat_response:
|
||
# Dieser Fall sollte nach der Aenderung in call_openai_chat nicht mehr auftreten (wuerde Exception werfen)
|
||
logger.error("call_openai_chat gab unerwarteterweise None zurueck fuer Batch-Zusammenfassung.")
|
||
# Werfen Sie eine spezifische Exception, damit der aeussere Decorator sie faengt
|
||
raise openai.error.APIError("Keine Antwort von OpenAI erhalten fuer Batch-Zusammenfassung.")
|
||
|
||
|
||
except Exception as e:
|
||
# Wenn call_openai_chat oder der aeussere retry_on_failure eine Exception wirft
|
||
# Die Exception wird hier gefangen, bevor sie an den Aufrufer (DataProcessor Methode) weitergeleitet wird.
|
||
logger.error(f"Endgueltiger FEHLER beim OpenAI-Batch-Aufruf fuer Zusammenfassung (innerhalb Batch Decorator): {e}")
|
||
# Geben Sie ein Dictionary zurueck, das signalisiert, dass fuer alle Zeilen im Batch ein Fehler aufgetreten ist
|
||
return {row_num: f"FEHLER API: {str(e)[:100]}" for row_num in row_numbers_in_batch}
|
||
|
||
|
||
# --- Antwort parsen ---
|
||
summaries = {} # Initialisieren Sie das Ergebnis-Dictionary
|
||
lines = chat_response.strip().split('\n')
|
||
parsed_count = 0
|
||
for line in lines:
|
||
# Matcht "RESULTAT <Zeilennummer>:" und den Rest der Zeile
|
||
match = re.match(r"RESULTAT (\d+): (.*)", line.strip())
|
||
if match:
|
||
row_num = int(match.group(1))
|
||
summary_text = match.group(2).strip()
|
||
# Stellen Sie sicher, dass die Zeilennummer im urspruenglichen Batch war
|
||
if row_num in row_numbers_in_batch:
|
||
summaries[row_num] = summary_text
|
||
parsed_count += 1
|
||
# else: logger.debug(f"Warnung: Antwort fuer unerwartete Zeilennummer {row_num} im Batch erhalten.") # Zu viel Laerm
|
||
|
||
logger.debug(f"Batch-Zusammenfassung: {parsed_count} von {len(row_numbers_in_batch)} Zeilen erfolgreich geparst.")
|
||
|
||
# Fuegen Sie einen Fehlerwert fuer Zeilen hinzu, die nicht geparst werden konnten (z.B. falsches Antwortformat)
|
||
if parsed_count < len(row_numbers_in_batch):
|
||
logger.warning(f"Nicht alle Zeilen aus dem Batch ({len(row_numbers_in_batch)}) konnten in der OpenAI-Antwort ({len(lines)} Zeilen) geparst werden.")
|
||
logger.debug(f"Unerwartete Antwortteile (erste 500 Zeichen): {chat_response[:500]}")
|
||
for row_num in row_numbers_in_batch:
|
||
if row_num not in summaries:
|
||
summaries[row_num] = "FEHLER: Antwort nicht geparst"
|
||
|
||
|
||
# Fuege k.A. fuer Tasks hinzu, die ungueltigen Rohtext hatten (aus valid_tasks gefiltert)
|
||
# Diese waren nie Teil des OpenAI Prompts und hatten daher kein Ergebnis
|
||
original_row_nums = {t['row_num'] for t in tasks_data}
|
||
for row_num in original_row_nums:
|
||
if row_num not in summaries:
|
||
summaries[row_num] = "k.A. (Kein gueltiger Rohtext im Batch)"
|
||
|
||
|
||
return summaries # Rueckgabe des Dictionarys mit Ergebnissen oder Fehlern
|
||
|
||
|
||
# ==============================================================================
|
||
# Ende OpenAI Summary Helpers Block
|
||
# ==============================================================================
|
||
|
||
# ==============================================================================
|
||
# GLOBALE HELPER FUNCTIONS (PART 8: OpenAI Branch Helper)
|
||
# ==============================================================================
|
||
|
||
# --- OpenAI Branch Helper ---
|
||
# Funktion zur Branchenbewertung mittels OpenAI.
|
||
# Nutzt globale Helfer: ALLOWED_TARGET_BRANCHES, TARGET_SCHEMA_STRING,
|
||
# call_openai_chat, logger, re, retry_on_failure.
|
||
@retry_on_failure
|
||
def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kategorien, website_summary):
|
||
logger = logging.getLogger(__name__)
|
||
# Zugriff auf die globalen, durch load_target_schema() befüllten Variablen
|
||
global ALLOWED_TARGET_BRANCHES, TARGET_SCHEMA_STRING, FOCUS_BRANCHES_PROMPT_PART
|
||
|
||
if not ALLOWED_TARGET_BRANCHES:
|
||
logger.critical("FEHLER in evaluate_branche_chatgpt: Ziel-Branchenschema nicht geladen.")
|
||
return {
|
||
"branch": "FEHLER - SCHEMA FEHLT",
|
||
"confidence": "N/A", # NEU: Konfidenz auch bei Fehler
|
||
"consistency": "error_schema_missing",
|
||
"justification": "Fehler: Ziel-Schema nicht geladen"
|
||
}
|
||
|
||
allowed_branches_lookup = {b.lower(): b for b in ALLOWED_TARGET_BRANCHES}
|
||
|
||
# --- Prompt für ChatGPT erstellen ---
|
||
# Beginnt mit den Regeln und der Liste der gueltigen Kurzformen (TARGET_SCHEMA_STRING)
|
||
# Fügt dann den Hinweis auf Fokusbranchen hinzu (FOCUS_BRANCHES_PROMPT_PART)
|
||
|
||
# ANPASSUNG: Antwortformat im TARGET_SCHEMA_STRING anpassen (dies geschieht in load_target_schema, aber der Effekt ist hier relevant)
|
||
# Der TARGET_SCHEMA_STRING wird in load_target_schema() aufgebaut.
|
||
# Wir müssen sicherstellen, dass die Anweisung für das Antwortformat dort auch "Konfidenz:" enthält.
|
||
# Temporäre Anpassung hier, um den Logikfluss zu zeigen - idealerweise wird TARGET_SCHEMA_STRING direkt korrekt in load_target_schema generiert.
|
||
|
||
# Neuer Prompt-Teil für das Antwortformat (wird in TARGET_SCHEMA_STRING integriert)
|
||
# Dies ist nur zur Illustration, wie TARGET_SCHEMA_STRING aussehen sollte.
|
||
# Die eigentliche Änderung für TARGET_SCHEMA_STRING muss in der Funktion load_target_schema erfolgen.
|
||
# TARGET_SCHEMA_STRING sollte so aussehen (Auszug für Antwortformat):
|
||
# "...
|
||
# Antworte ausschliesslich im folgenden Format (keine Einleitung, kein Schlusssatz):
|
||
# Branche: <Exakter Kurzname der Branche aus der Liste>
|
||
# Konfidenz: <Hoch, Mittel oder Niedrig>
|
||
# Uebereinstimmung: <ok oder X (Vergleich deines Vorschlags mit der extrahierten Kurzform der CRM-Referenz)>
|
||
# Begruendung: <Sehr kurze Begruendung fuer deinen Branchenvorschlag>
|
||
# ..."
|
||
|
||
prompt_parts = [TARGET_SCHEMA_STRING, FOCUS_BRANCHES_PROMPT_PART]
|
||
prompt_parts.append("\nOrdne das Unternehmen anhand folgender Angaben exakt einer Branche des Ziel-Branchenschemas (Kurzformen) zu.")
|
||
# NEU: Anweisung zur Konfidenz explizit hinzufügen, falls nicht schon im TARGET_SCHEMA_STRING enthalten
|
||
prompt_parts.append("Gib zusätzlich eine Konfidenz für deine Branchenwahl an (Hoch, Mittel oder Niedrig).")
|
||
|
||
|
||
# Informationen hinzufügen (unverändert)
|
||
if crm_branche and str(crm_branche).strip() and str(crm_branche).strip().lower() != "k.a.": prompt_parts.append(f"- CRM-Branche (Referenz): {str(crm_branche).strip()}")
|
||
if wiki_branche and str(wiki_branche).strip() and str(wiki_branche).strip().lower() != "k.a.":
|
||
if beschreibung and str(beschreibung).strip() and str(beschreibung).strip().lower() != "k.a.":
|
||
prompt_parts.append(f"- Beschreibung (CRM): {str(beschreibung).strip()[:500]}...")
|
||
if website_summary and str(website_summary).strip() and str(website_summary).strip().lower() != "k.a." and not str(website_summary).strip().startswith("k.A. (Fehler"):
|
||
prompt_parts.append(f"- Website-Zusammenfassung: {str(website_summary).strip()[:500]}...")
|
||
prompt_parts.append(f"- Wikipedia-Branche: {str(wiki_branche).strip()[:300]}...")
|
||
if wiki_kategorien and str(wiki_kategorien).strip() and str(wiki_kategorien).strip().lower() != "k.a.":
|
||
prompt_parts.append(f"- Wikipedia-Kategorien: {str(wiki_kategorien).strip()[:500]}...")
|
||
else:
|
||
logger.debug("evaluate_branche_chatgpt: Keine validen Wiki-Daten, nutze Website-Zusammenfassung als primäre Beschreibung (falls vorhanden).")
|
||
if website_summary and str(website_summary).strip() and str(website_summary).strip().lower() != "k.a." and not str(website_summary).strip().startswith("k.A. (Fehler"):
|
||
prompt_parts.append(f"- Website-Zusammenfassung (als Hauptbeschreibung): {str(website_summary).strip()[:800]}...")
|
||
elif beschreibung and str(beschreibung).strip() and str(beschreibung).strip().lower() != "k.a.":
|
||
prompt_parts.append(f"- Beschreibung (CRM, als Hauptbeschreibung): {str(beschreibung).strip()[:800]}...")
|
||
|
||
|
||
if len(prompt_parts) < (3 + (1 if FOCUS_BRANCHES_PROMPT_PART else 0)):
|
||
logger.warning("Warnung in evaluate_branche_chatgpt: Zu wenige Informationen für Branchenevaluierung.")
|
||
crm_short_branch_for_fallback = "k.A."
|
||
if crm_branche and isinstance(crm_branche, str) and crm_branche.strip().lower() != "k.a.":
|
||
# Da Spalte H jetzt 1:1 ist, können wir sie direkt nehmen
|
||
crm_short_branch_for_fallback = crm_branche.strip()
|
||
|
||
return {
|
||
"branch": crm_short_branch_for_fallback if crm_short_branch_for_fallback.lower() != "k.a." else "FEHLER",
|
||
"confidence": "N/A", # NEU
|
||
"consistency": "error_no_info",
|
||
"justification": "Fehler: Zu wenige Informationen fuer eine Einschaetzung"
|
||
}
|
||
|
||
prompt = "\n".join(filter(None, prompt_parts))
|
||
# logger.debug(f"Erstellter Prompt fuer Branchenevaluierung:\n---\n{prompt}\n---") # Kann bei Bedarf aktiviert werden
|
||
|
||
chat_response = None
|
||
try:
|
||
chat_response = call_openai_chat(prompt, temperature=0.0)
|
||
if not chat_response:
|
||
logger.error("call_openai_chat gab unerwarteterweise None/leer zurueck fuer Branchenevaluation.")
|
||
# Hier wird eine Exception vom Typ openai.error.APIError erwartet, die vom @retry_on_failure gefangen wird.
|
||
# Wenn es hierher kommt, ist es ein unerwarteter Zustand.
|
||
raise APIError("Keine Antwort von OpenAI erhalten fuer Branchenevaluation.") # Import APIError from openai.error
|
||
except Exception as e:
|
||
logger.error(f"Endgueltiger FEHLER beim OpenAI-Aufruf fuer Branchenevaluation: {e}")
|
||
crm_short_branch_for_fallback = "k.A."
|
||
if crm_branche and isinstance(crm_branche, str) and crm_branche.strip().lower() != "k.a.":
|
||
crm_short_branch_for_fallback = crm_branche.strip()
|
||
|
||
return {
|
||
"branch": crm_short_branch_for_fallback if crm_short_branch_for_fallback.lower() != "k.a." else "FEHLER API",
|
||
"confidence": "N/A", # NEU
|
||
"consistency": "error_api_failed",
|
||
"justification": f"Fehler API: {str(e)[:100]}"
|
||
}
|
||
|
||
# --- Antwort parsen ---
|
||
lines = chat_response.strip().split("\n")
|
||
result = {
|
||
"branch": None,
|
||
"confidence": "N/A", # NEU: Initialwert
|
||
"consistency": None,
|
||
"justification": ""
|
||
}
|
||
suggested_branch = ""
|
||
parsed_branch = False
|
||
parsed_confidence = False # NEU
|
||
|
||
for line in lines:
|
||
line_lower = line.lower(); line_stripped = line.strip()
|
||
if line_lower.startswith("branche:"):
|
||
suggested_branch = line_stripped.split(":", 1)[1].strip().strip('"\'')
|
||
parsed_branch = True
|
||
elif line_lower.startswith("konfidenz:"): # NEU
|
||
confidence_text = line_stripped.split(":", 1)[1].strip().strip('"\'')
|
||
# Validierung des Konfidenzwertes
|
||
valid_confidences = ["hoch", "mittel", "niedrig"]
|
||
if confidence_text.lower() in valid_confidences:
|
||
result["confidence"] = confidence_text.capitalize() # Speichere mit großem Anfangsbuchstaben
|
||
else:
|
||
logger.warning(f"Ungueltiger Konfidenzwert '{confidence_text}' von ChatGPT erhalten. Setze auf 'N/A'.")
|
||
result["confidence"] = "N/A (Ungueltig)"
|
||
parsed_confidence = True
|
||
elif line_lower.startswith("uebereinstimmung:") or line_lower.startswith("ubereinstimmung:"):
|
||
pass # Wird spaeter basierend auf 'branch' und 'crm_branche' gesetzt
|
||
elif line_lower.startswith("begruendung:") or line_lower.startswith("begruendung:"): # KORREKTUR: war "begruendung:"
|
||
justification_text = line_stripped.split(":", 1)[1].strip()
|
||
if result["justification"]: result["justification"] += " " + justification_text
|
||
else: result["justification"] = justification_text
|
||
|
||
if not parsed_branch or not suggested_branch or suggested_branch.lower() in ["k.a.", "n/a"]:
|
||
logger.error(f"Fehler in evaluate_branche_chatgpt: Konnte 'Branche:' nicht oder nur leer/k.A. aus Antwort parsen: {chat_response[:500]}...")
|
||
crm_short_branch_for_fallback = "k.A."
|
||
if crm_branche and isinstance(crm_branche, str) and crm_branche.strip().lower() != "k.a.":
|
||
crm_short_branch_for_fallback = crm_branche.strip()
|
||
|
||
return {
|
||
"branch": crm_short_branch_for_fallback if crm_short_branch_for_fallback.lower() != "k.a." else "FEHLER PARSING",
|
||
"confidence": result.get("confidence", "N/A"), # NEU: Konfidenz auch bei Parsing-Fehler der Branche
|
||
"consistency": "error_parsing",
|
||
"justification": f"Fehler Parsing: Antwortformat unerwartet."
|
||
}
|
||
|
||
# Wenn Konfidenz nicht explizit geparst wurde, aber Branche schon
|
||
if not parsed_confidence:
|
||
logger.warning("Konnte 'Konfidenz:' nicht aus ChatGPT-Antwort parsen. Setze auf 'N/A'.")
|
||
result["confidence"] = "N/A (Nicht geparst)"
|
||
|
||
|
||
# --- Validierung und Fallback (wie gehabt, aber mit CRM-Branche direkt) ---
|
||
final_branch = None
|
||
suggested_branch_lower = suggested_branch.lower()
|
||
|
||
if suggested_branch_lower in allowed_branches_lookup:
|
||
final_branch = allowed_branches_lookup[suggested_branch_lower]
|
||
logger.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist gueltig ('{final_branch}').")
|
||
result["consistency"] = "pending_comparison"
|
||
else:
|
||
logger.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist NICHT im Ziel-Schema. Starte Fallback...")
|
||
# CRM Branche ist jetzt direkt die Kurzform
|
||
crm_short_branch = crm_branche.strip() if crm_branche and isinstance(crm_branche, str) else "k.A."
|
||
|
||
crm_short_branch_lower = crm_short_branch.lower()
|
||
if crm_short_branch != "k.A." and crm_short_branch_lower in allowed_branches_lookup:
|
||
final_branch = allowed_branches_lookup[crm_short_branch_lower]
|
||
result["consistency"] = "fallback_crm_valid"
|
||
fallback_reason = f"Fallback: Ungueltiger ChatGPT-Vorschlag ('{suggested_branch}'). Gueltige CRM-Kurzform '{final_branch}' verwendet."
|
||
result["justification"] = f"{fallback_reason} (ChatGPT Begruendung war: {result.get('justification', 'Keine')})"
|
||
logger.info(f"Fallback auf gueltige CRM-Kurzform erfolgreich: '{final_branch}'")
|
||
result["confidence"] = "N/A (Fallback)" # Konfidenz ist nicht relevant bei Fallback auf CRM
|
||
else:
|
||
final_branch = suggested_branch
|
||
result["consistency"] = "fallback_invalid"
|
||
error_reason = f"Fehler: Ungueltiger ChatGPT-Vorschlag ('{suggested_branch}') und keine gueltige CRM-Kurzform ('{crm_short_branch}') als Fallback."
|
||
result["justification"] = f"{error_reason} (ChatGPT Begruendung war: {result.get('justification', 'Keine')})"
|
||
logger.warning(f"Fallback fehlgeschlagen. Ungueltiger Vorschlag: '{final_branch}', Ungueltige CRM-Kurzform: '{crm_short_branch}'")
|
||
final_branch = "FEHLER - UNGUELTIGE ZUWEISUNG"
|
||
result["confidence"] = "N/A (Fehler)" # Konfidenz nicht relevant bei Fehler
|
||
|
||
result["branch"] = final_branch if final_branch else "FEHLER"
|
||
|
||
# Konsistenzprüfung: ok oder X
|
||
crm_branch_to_compare = crm_branche.strip() if crm_branche and isinstance(crm_branche, str) else "k.A."
|
||
# Nur prüfen, wenn kein Fallback auf CRM oder Fehler vorliegt
|
||
if result["consistency"] == "pending_comparison" and result["branch"] != "FEHLER" and not result["branch"].startswith("FEHLER"):
|
||
if result["branch"].lower() == crm_branch_to_compare.lower(): # Direktvergleich, da CRM Spalte H angepasst wurde
|
||
result["consistency"] = "ok"
|
||
else:
|
||
result["consistency"] = "X"
|
||
|
||
# Sicherstellen, dass consistency immer einen Wert hat
|
||
if result["consistency"] == "pending_comparison": # Sollte nach obiger Logik nicht mehr der Fall sein
|
||
result["consistency"] = "error_comparison_failed"
|
||
elif result["consistency"] is None: # Fallback, falls etwas schiefgelaufen ist
|
||
result["consistency"] = "error_unknown_state"
|
||
|
||
logger.debug(f"Finale Branch-Evaluation Ergebnis: Branch='{result.get('branch')}', Confidence='{result.get('confidence')}', Consistency='{result.get('consistency')}', Justification='{result.get('justification', '')[:100]}...'")
|
||
return result
|
||
|
||
|
||
# ==============================================================================
|
||
# Ende OpenAI Branch Helper Block
|
||
# ==============================================================================
|
||
|
||
|
||
# ==============================================================================
|
||
# GLOBALE HELPER FUNCTIONS (PART 9: SerpAPI Search Helpers)
|
||
# ==============================================================================
|
||
|
||
# --- SERP API / LINKEDIN FUNCTIONS ---
|
||
# Funktionen zur Suche ueber SerpAPI (Google Search).
|
||
# Nutzt globale Helfer: Config.API_KEYS, requests, retry_on_failure,
|
||
# simple_normalize_url, normalize_company_name, unquote, logger, re.
|
||
|
||
|
||
# serp_wikipedia_lookup ist bereits in Teil 1/18 enthalten (oder sollte es sein, da es direkt nach retry_on_failure kam).
|
||
# Es ist hier im globalen Sektion 3 Block nun korrekt platziert.
|
||
@retry_on_failure # Wende den Decorator an
|
||
def serp_wikipedia_lookup(company_name, website=None, min_score=0.4):
|
||
"""
|
||
Sucht ueber SerpAPI (Google) nach dem wahrscheinlichsten Wikipedia-Artikel fuer ein Unternehmen.
|
||
Verwendet flexible Query, sammelt Top-10-Kandidaten, bewertet nach Titelaehnlichkeit
|
||
und Keywords, bevorzugt deutsche/englische Artikel.
|
||
|
||
Args:
|
||
company_name (str): Der Name des Unternehmens.
|
||
website (str, optional): Die Website des Unternehmens (fuer Kontext in Query). Defaults to None.
|
||
min_score (float, optional): Mindest-Score (Kombination aus Aehnlichkeit
|
||
und Boni) fuer einen gueltigen Treffer. Defaults to 0.4.
|
||
|
||
Returns:
|
||
str: Die URL des relevantesten Wikipedia-Artikels oder None.
|
||
Wirft Exception bei API-Fehlern nach Retries.
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
|
||
serp_key = Config.API_KEYS.get('serpapi')
|
||
if not serp_key:
|
||
logger.error("Fehler: SerpAPI Key nicht verfuegbar fuer Wikipedia Lookup.")
|
||
# Werfen Sie eine spezifische Exception, die vom retry_on_failure behandelt wird
|
||
raise ConnectionRefusedError("SerpAPI Key nicht konfiguriert.")
|
||
|
||
if not company_name or str(company_name).strip() == "":
|
||
logger.warning("serp_wikipedia_lookup: Kein Firmenname angegeben.")
|
||
# Werfen Sie einen ValueError, der vom retry_on_failure behandelt wird
|
||
raise ValueError("Kein Firmenname fuer SerpAPI Wikipedia Lookup angegeben.")
|
||
|
||
|
||
# --- Flexible Query Konstruktion ---
|
||
# Ohne Anfuehrungszeichen fuer breitere Suche
|
||
query = f'{company_name} Wikipedia'
|
||
# Fuegen Sie die Domain als Kontext hinzu, wenn vorhanden und valide
|
||
if website and simple_normalize_url(website) != "k.A.":
|
||
# Fuegen Sie die Domain als zusaetzlichen Suchterm hinzu, um die Relevanz zu verbessern
|
||
query = f'{company_name} Wikipedia {simple_normalize_url(website)}'
|
||
logger.info(f"Starte SerpAPI Wikipedia-Suche fuer '{company_name}' mit Query: '{query[:100]}...'") # Logge gekuerzte Query
|
||
|
||
params = {
|
||
"engine": "google",
|
||
"q": query,
|
||
"api_key": serp_key,
|
||
"hl": "de", # Host Language (Sprache der Benutzeroberflaeche)
|
||
"gl": "de", # Geo Location (Land)
|
||
"num": 10 # Top 10 Ergebnisse pruefen
|
||
}
|
||
api_url = "https://serpapi.com/search"
|
||
|
||
try:
|
||
# Der Requests Call wird vom retry_on_failure Decorator behandelt.
|
||
# Timeout sollte aus Config kommen.
|
||
response = requests.get(api_url, params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15))
|
||
response.raise_for_status() # Wirft HTTPError fuer 4xx/5xx Antworten. Wird vom Decorator gefangen.
|
||
data = response.json()
|
||
|
||
candidates = [] # Liste von Dictionaries: {'url': str, 'title': str}
|
||
if "organic_results" in data:
|
||
logger.debug(f" -> Pruefe {len(data['organic_results'])} organische Ergebnisse...")
|
||
# Iteriere durch alle organischen Ergebnisse
|
||
for result in data["organic_results"]:
|
||
link = result.get("link")
|
||
# Filtere gueltige Wiki-Artikel-Links (bevorzuge de oder en Wikipedia)
|
||
if link and isinstance(link, str) and "wikipedia.org/wiki/" in link.lower() \
|
||
and (link.lower().startswith("https://de.wikipedia.org") or link.lower().startswith("https://en.wikipedia.org")) \
|
||
and not any(x in link.lower() for x in ['datei:', 'spezial:', 'portal:', 'hilfe:', 'diskussion:', 'template:']): # Filter fuer unerwuenschte Wiki-Seiten
|
||
try:
|
||
# Extrahiere den Artikel-Titel aus der URL
|
||
title_part = link.split('/wiki/', 1)[1]
|
||
# Handle eventuelle Anker (#) am Ende der URL
|
||
title_part = title_part.split('#')[0]
|
||
# Dekodiere URL-kodierte Zeichen und ersetze Unterstriche durch Leerzeichen
|
||
title = unquote(title_part).replace('_', ' ')
|
||
candidates.append({'url': link, 'title': title})
|
||
# logger.debug(f" -> Kandidat gefunden: '{title}' ({link})") # Zu viel Laerm im Debug
|
||
except Exception as e_title_extract:
|
||
# Logge Fehler bei der Titel-Extraktion, aber fahre mit naechstem Kandidaten fort
|
||
logger.debug(f" -> Fehler beim Extrahieren des Titels aus Link {link[:100]}...: {e_title_extract}")
|
||
continue # Pruefe naechsten Kandidaten
|
||
|
||
# Wenn keine Kandidaten gefunden wurden
|
||
if not candidates:
|
||
logger.warning(f" -> SerpAPI: Keine de/en Wikipedia-Kandidaten-URLs in Ergebnissen fuer '{company_name}' gefunden.")
|
||
return None # Signalisiert, dass kein passender Artikel gefunden wurde
|
||
|
||
# Bewerte Kandidaten nach Relevanz
|
||
best_match_url = None
|
||
highest_score = -1.0
|
||
# Normalisiere den Suchnamen des Unternehmens (nutzt globale Helfer)
|
||
normalized_search_name = normalize_company_name(company_name)
|
||
|
||
logger.debug(f" -> Bewerte {len(candidates)} Kandidaten...")
|
||
# Iteriere durch die gesammelten Kandidaten
|
||
for cand in candidates:
|
||
url = cand['url']
|
||
title = cand['title']
|
||
try:
|
||
# Normalisiere den Titel des Kandidaten (nutzt globale Helfer)
|
||
normalized_title = normalize_company_name(title)
|
||
title_lower = title.lower() # Fuer Keyword-Suche
|
||
except Exception as e_norm:
|
||
# Logge Fehler bei der Normalisierung des Titels, aber ueberspringe diesen Kandidaten
|
||
logger.warning(f"Fehler beim Normalisieren des Titels '{title[:100]}...': {e_norm}. Ueberspringe Kandidatenbewertung.")
|
||
continue # Ueberspringe diesen Kandidaten
|
||
|
||
# 1. Basisscore: Titelaehnlichkeit (Normalisierte Namen verwenden)
|
||
similarity = SequenceMatcher(None, normalized_title, normalized_search_name).ratio()
|
||
score = similarity
|
||
# logger.debug(f" -> Kandidat '{title[:100]}...': Basis-Aehnlichkeit={similarity:.2f}")
|
||
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# +++ KORREKTUR / HINZUFÜGUNG HIER ++++++++++++++++++++++++++++++++++++++
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
bonus = 0.0 # <<<<------ HIER INITIALISIEREN!
|
||
logger.debug(f" INIT BONUS: Wert von bonus direkt nach Initialisierung: {bonus} für Kandidat '{title[:50]}...'")
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# +++ ENDE KORREKTUR / HINZUFÜGUNG ++++++++++++++++++++++++++++++++++++
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
|
||
# NEU: Starker Bonus, wenn der normalisierte Suchname signifikant im normalisierten Titel enthalten ist
|
||
# oder der normalisierte Titel im Suchnamen (für Fälle wie "Bosch (Unternehmen)" vs "Robert Bosch GmbH")
|
||
# Sicherstellen, dass Strings nicht leer sind, bevor 'in' verwendet wird
|
||
if normalized_search_name and normalized_title and \
|
||
(normalized_search_name in normalized_title or normalized_title in normalized_search_name):
|
||
bonus += 0.3 # Relativ starker Bonus für gute Namensübereinstimmung
|
||
logger.debug(f" -> Bonus +0.3 fuer Namensübereinstimmung: '{normalized_search_name}' in '{normalized_title}'")
|
||
|
||
# Bestehende Boni anpassen oder leicht reduzieren, wenn der obige Bonus schon greift
|
||
if "(unternehmen)" in title_lower:
|
||
bonus += 0.15 # Etwas reduziert, da Namensmatch wichtiger
|
||
logger.debug(" -> Bonus +0.15 fuer '(unternehmen)'")
|
||
elif re.search(r'\b(?:gmbh|ag|kg|ltd|inc|corp|s\.?a\.?|se|group|holding)\b$', title_lower):
|
||
bonus += 0.05 # Kleinerer Bonus
|
||
logger.debug(" -> Bonus +0.05 fuer Rechtsform/Gruppen-Keyword")
|
||
|
||
# 3. Bonus fuer Sprache (Deutsch bevorzugt)
|
||
if url.lower().startswith("https://de.wikipedia.org"):
|
||
bonus += 0.05
|
||
logger.debug(" -> Bonus +0.05 fuer de.wikipedia.org")
|
||
|
||
total_score = score + bonus
|
||
logger.debug(f" -> Gesamtscore fuer '{title[:100]}...': {total_score:.3f} (Aehnlichkeit={similarity:.2f}, Bonus={bonus:.2f})")
|
||
|
||
if total_score > highest_score and total_score >= min_score:
|
||
highest_score = total_score
|
||
best_match_url = url # Speichere die URL des besten Treffers
|
||
logger.debug(f" ====> Neuer bester Kandidat: {best_match_url[:100]}... (Score: {highest_score:.3f}) ====")
|
||
|
||
|
||
# Wenn nach Pruefung aller Kandidaten ein bester Treffer gefunden wurde (Score >= min_score)
|
||
if best_match_url:
|
||
logger.info(f" -> SerpAPI: Bester relevanter Wikipedia-Link ausgewaehlt: {best_match_url[:100]}... (Score: {highest_score:.3f})")
|
||
return best_match_url # Gebe die gefundene URL zurueck
|
||
else:
|
||
# Wenn keiner der Kandidaten den Mindestscore erreicht hat
|
||
logger.warning(f" -> SerpAPI: Keiner der {len(candidates)} Kandidaten erreichte den Mindestscore ({min_score}) fuer '{company_name}'.")
|
||
return None # Signalisiert, dass kein passender Artikel gefunden wurde
|
||
|
||
except Exception as e: # retry_on_failure wirft Exception im Fehlerfall erneut
|
||
# Fangen Sie alle anderen unerwarteten Exceptions ab.
|
||
# Der Fehler wird bereits vom retry_on_failure Decorator geloggt.
|
||
logger.error(f"FEHLER bei der SerpAPI Wikipedia Suche fuer '{company_name}': {e}")
|
||
# Werfen Sie die Exception erneut, damit der retry_on_failure Decorator sie handhaben kann (z.B. loggen beim endgueltigen Scheitern).
|
||
raise e # Leite die Exception weiter
|
||
|
||
|
||
@retry_on_failure
|
||
def serp_website_lookup(company_name):
|
||
"""
|
||
Ermittelt die offizielle Website eines Unternehmens ueber SerpAPI (Google Suche).
|
||
Gibt die normalisierte URL zurueck oder "k.A.".
|
||
|
||
Args:
|
||
company_name (str): Der Name des Unternehmens.
|
||
|
||
Returns:
|
||
str: Die normalisierte Website URL oder "k.A." bei Fehler/nicht gefunden.
|
||
Wirft Exception bei API-Fehlern nach Retries.
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
|
||
serp_key = Config.API_KEYS.get('serpapi')
|
||
if not serp_key:
|
||
logger.error("Fehler: SerpAPI Key nicht verfuegbar fuer Website Lookup.")
|
||
# Werfen Sie eine spezifische Exception, die vom retry_on_failure behandelt wird
|
||
raise ConnectionRefusedError("SerpAPI Key nicht konfiguriert.")
|
||
|
||
if not company_name or str(company_name).strip() == "":
|
||
logger.warning("serp_website_lookup: Kein Firmenname angegeben.")
|
||
# Werfen Sie einen ValueError
|
||
raise ValueError("Kein Firmenname fuer SerpAPI Website Lookup angegeben.")
|
||
|
||
|
||
# Blacklist unerwuenschter Domains (kann in Config verschoben werden)
|
||
# Diese Domains sind in der Regel keine offiziellen Unternehmenswebsites.
|
||
blacklist = ["bloomberg.com", "northdata.de", "finanzen.net", "handelsblatt.com", "wikipedia.org", "linkedin.com", "xing.com", "youtube.com", "facebook.com", "twitter.com", "instagram.com", "glassdoor.com", "kununu.com"]
|
||
|
||
# Query anpassen fuer bessere Ergebnisse (suche nach der "offiziellen Website")
|
||
query = f'{company_name} offizielle Website'
|
||
# Fuegen Sie optional den Ort hinzu, wenn verfuegbar (muesste als Argument uebergeben werden)
|
||
# if city and city != "k.A.": query = f'{company_name} offizielle Website {city}'
|
||
logger.info(f"Starte SerpAPI Website-Suche fuer '{company_name}' mit Query: '{query[:100]}...'") # Logge gekuerzte Query
|
||
|
||
|
||
params = {
|
||
"engine": "google",
|
||
"q": query,
|
||
"api_key": serp_key,
|
||
"hl": "de", # Host Language (Sprache der Benutzeroberflaeche)
|
||
"gl": "de", # Geo Location (Land)
|
||
"safe": "active" # SafeSearch aktivieren
|
||
}
|
||
api_url = "https://serpapi.com/search"
|
||
|
||
try:
|
||
# Der Requests Call wird vom retry_on_failure Decorator behandelt.
|
||
# Timeout sollte aus Config kommen.
|
||
response = requests.get(api_url, params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15))
|
||
response.raise_for_status() # Wirft HTTPError fuer 4xx/5xx Antworten. Wird vom Decorator gefangen.
|
||
data = response.json()
|
||
|
||
# 1. Knowledge Graph pruefen (oft die offizielle Seite)
|
||
# Dies ist oft der zuverlaessigste Treffer
|
||
if "knowledge_graph" in data and "website" in data["knowledge_graph"]:
|
||
kg_url = data["knowledge_graph"].get("website")
|
||
if kg_url and isinstance(kg_url, str): # Stelle sicher, dass kg_url ein String ist
|
||
# Pruefen Blacklist VOR Normalisierung
|
||
if any(bad_domain in kg_url.lower() for bad_domain in blacklist):
|
||
logger.debug(f" -> SerpAPI Website Lookup: KG URL '{kg_url[:100]}...' auf Blacklist. Uebersprungen.") # Gekuerzt loggen
|
||
else:
|
||
# Normalisiere die URL (nutzt globale Helfer)
|
||
normalized_url = simple_normalize_url(kg_url)
|
||
# Wenn die Normalisierung erfolgreich war (kein "k.A.")
|
||
if normalized_url != "k.A.":
|
||
logger.info(f"SERP Lookup: Website '{normalized_url}' aus Knowledge Graph fuer '{company_name}' gefunden.")
|
||
return normalized_url # Erfolgreich gefunden und zurueckgegeben
|
||
|
||
|
||
# 2. Organische Ergebnisse pruefen
|
||
if "organic_results" in data:
|
||
# Iteriere durch die ersten Ergebnisse (pruefe nur die Top N Ergebnisse)
|
||
# N ist hier fest auf 5 gesetzt, kann aber angepasst werden.
|
||
for result in data["organic_results"][:5]:
|
||
url = result.get("link", "")
|
||
title = result.get("title", "") # Titel kann Kontext geben
|
||
snippet = result.get("snippet", "") # Snippet kann Kontext geben
|
||
|
||
# Filtere: Muss gueltige URL sein, darf nicht auf Blacklist sein, muss http/https starten
|
||
if url and isinstance(url, str) and url.lower().startswith(("http://", "https://")) and not any(bad_domain in url.lower() for bad_domain in blacklist):
|
||
|
||
# Normalisiere die URL (nutzt globale Helfer)
|
||
normalized_url = simple_normalize_url(url)
|
||
|
||
# Wenn die Normalisierung erfolgreich war (kein "k.A.")
|
||
if normalized_url != "k.A.":
|
||
# Zusaetzliche Plausibilitaetspruefung: Ist die Domain oder der Firmenname
|
||
# im Titel oder Snippet des Suchergebnisses relevant fuer das Unternehmen?
|
||
# normalize_company_name nutzt globale Funktion
|
||
normalized_company = normalize_company_name(company_name)
|
||
# Extrahieren Sie den ersten Teil der Domain (vor dem ersten Punkt)
|
||
domain_part_normalized = normalized_url.replace('www.', '').split('.')[0]
|
||
title_lower = title.lower()
|
||
snippet_lower = snippet.lower()
|
||
|
||
# Pruefe, ob der normalisierte Domain-Teil im normalisierten Firmennamen enthalten ist
|
||
domain_name_match = domain_part_normalized in normalized_company
|
||
|
||
# Pruefe, ob der normalisierte Firmenname (oder Teile davon) im Titel oder Snippet vorkommt
|
||
# Kann auch auf Kurzform pruefen
|
||
name_in_result_text = normalized_company in title_lower or normalized_company in snippet_lower # Oder eine praezisere Fuzzy-Suche
|
||
|
||
# Definieren Sie Kriterien fuer einen guten Treffer im organischen Ergebnis
|
||
# Eine gute URL sollte entweder einen Domain/Name-Match haben ODER der Firmenname sollte prominent im Ergebnis-Text vorkommen.
|
||
if domain_name_match or name_in_result_text:
|
||
logger.info(f"SERP Lookup: Website '{normalized_url}' aus Organic Results fuer '{company_name}' gefunden (Domain/Name Match oder Text-Match).")
|
||
return normalized_url # Erfolgreich gefunden und zurueckgegeben
|
||
else:
|
||
# Loggen Sie, warum die URL uebersprungen wurde (nur auf Debug)
|
||
# logger.debug(f" -> SerpAPI Website Lookup: URL '{normalized_url}' uebersprungen (Domain/Name Match oder Text-Match fehlgeschlagen). Domain='{domain_part_normalized}', Name='{normalized_company}'.") # Zu viel Laerm
|
||
pass # Fahren Sie fort, um den naechsten organischen Treffer zu pruefen
|
||
|
||
|
||
# Wenn die Schleife durchlaeuft und kein passender Treffer gefunden wurde (weder KG noch Organic)
|
||
logger.info(f"SERP Lookup: Keine passende Website fuer '{company_name}' gefunden nach Pruefung KG und Top Organic Results.")
|
||
return "k.A." # Signalisiert, dass keine passende URL gefunden wurde
|
||
|
||
except Exception as e: # retry_on_failure wirft Exception im Fehlerfall erneut
|
||
# Fangen Sie alle anderen unerwarteten Exceptions ab.
|
||
# Der Fehler wird bereits vom retry_on_failure Decorator geloggt.
|
||
logger.error(f"FEHLER bei der SerpAPI Website Suche fuer '{company_name}': {e}")
|
||
# Geben Sie einen Fehlerwert zurueck oder "k.A."
|
||
return f"k.A. (Fehler Suche: {str(e)[:100]}...)" # Signalisiert Fehler bei der Suche
|
||
|
||
|
||
@retry_on_failure
|
||
def search_linkedin_contacts(company_name, website, position_query, crm_kurzform, num_results=10):
|
||
"""
|
||
Sucht LinkedIn Kontakte fuer ein Unternehmen und eine Position ueber SerpAPI (Google).
|
||
Gibt eine Liste von Kontakt-Dictionaries zurueck.
|
||
|
||
Args:
|
||
company_name (str): Der volle Unternehmensname.
|
||
website (str): Die Website des Unternehmens (fuer Email-Generierung).
|
||
position_query (str): Der Suchbegriff fuer die Position (z.B. "Serviceleiter").
|
||
crm_kurzform (str): Die Kurzform des Firmennamens (wichtig fuer Suchgenauigkeit).
|
||
num_results (int, optional): Anzahl der Suchergebnisse pro Query. Defaults to 10.
|
||
|
||
Returns:
|
||
list: Eine Liste von Dictionaries, jedes repraesentiert einen gefundenen Kontakt.
|
||
Leere Liste bei Fehler oder nicht gefunden.
|
||
Wirft Exception bei API-Fehlern nach Retries.
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
|
||
serp_key = Config.API_KEYS.get('serpapi')
|
||
if not serp_key:
|
||
logger.error("Fehler: SerpAPI Key nicht verfuegbar fuer LinkedIn Suche.")
|
||
raise ConnectionRefusedError("SerpAPI Key nicht konfiguriert.")
|
||
|
||
# Grundlegende Pruefung der Eingaben
|
||
if not all([company_name, position_query, crm_kurzform]) or not all(isinstance(x, str) and x.strip() for x in [company_name, position_query, crm_kurzform]):
|
||
logger.warning(f"search_linkedin_contacts: Fehlende oder ungueltige Eingabedaten (Name, Position, Kurzform sind Pflicht).")
|
||
# Werfen Sie einen ValueError
|
||
raise ValueError("Fehlende oder ungueltige Eingabedaten fuer LinkedIn Suche.")
|
||
|
||
# Query anpassen fuer bessere Ergebnisse
|
||
# Suche nach "[Position]" UND "[Firmenkurzform]" auf der LinkedIn /in/ Seite
|
||
# Die Firmenkurzform ist oft im Titel oder der Beschreibung des Profils
|
||
query = f'site:linkedin.com/in/ "{position_query.strip()}" "{crm_kurzform.strip()}"'
|
||
# Optional: Fuegen Sie den vollen Firmennamen hinzu als zusaetzlichen Begriff,
|
||
# kann aber die Ergebnisse stark einschraenken, wenn der volle Name lang ist oder variiert.
|
||
# query = f'site:linkedin.com/in/ "{position_query.strip()}" "{crm_kurzform.strip()}" "{company_name.strip()}"'
|
||
logger.info(f"Starte SerpAPI LinkedIn-Suche fuer '{crm_kurzform}' (Position: '{position_query}') mit Query: '{query[:100]}...'") # Logge gekuerzte Query
|
||
|
||
|
||
params = {
|
||
"engine": "google",
|
||
"q": query,
|
||
"api_key": serp_key,
|
||
"hl": "de", # Host Language
|
||
"gl": "de", # Geo Location
|
||
"num": num_results # Anzahl der Ergebnisse pro SerpAPI Call (max 100, aber oft weniger geliefert)
|
||
}
|
||
api_url = "https://serpapi.com/search"
|
||
|
||
found_contacts = [] # Liste zur Sammlung der gefundenen Kontakte
|
||
|
||
try:
|
||
# Der Requests Call wird vom retry_on_failure Decorator behandelt.
|
||
# Timeout sollte aus Config kommen.
|
||
response = requests.get(api_url, params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15))
|
||
response.raise_for_status() # Wirft HTTPError fuer 4xx/5xx Antworten. Wird vom Decorator gefangen.
|
||
data = response.json()
|
||
|
||
if "organic_results" in data:
|
||
# Gehe durch die organischen Suchergebnisse
|
||
for result in data["organic_results"]:
|
||
title = result.get("title", "") # Titel des Suchergebnisses (oft Name - Position bei Firma)
|
||
linkedin_url = result.get("link", "") # Die URL des LinkedIn Profils
|
||
snippet = result.get("snippet", "") # Snippet kann Position oder Firma enthalten
|
||
|
||
# Filtere: Muss eine LinkedIn Profil-URL sein und darf kein "sales" Profil sein
|
||
if not linkedin_url or not isinstance(linkedin_url, str) or "linkedin.com/in/" not in linkedin_url.lower() or "/sales/" in linkedin_url.lower():
|
||
#logger.debug(f" -> LinkedIn Treffer uebersprungen (kein Profil-URL oder Sales): {linkedin_url[:100]}...") # Gekuerzt loggen
|
||
continue
|
||
|
||
# Zusaetzliche Relevanzpruefung:
|
||
# Die Firmenkurzform ODER der gesuchte Positionsterm MUSS im Titel oder Snippet vorkommen.
|
||
title_lower = title.lower()
|
||
snippet_lower = snippet.lower()
|
||
crm_kurzform_lower = crm_kurzform.lower()
|
||
position_query_lower = position_query.lower()
|
||
|
||
# Pruefe, ob Kurzform ODER Position im Titel oder Snippet vorkommen
|
||
is_relevant_result = (crm_kurzform_lower in title_lower or crm_kurzform_lower in snippet_lower) or \
|
||
(position_query_lower in title_lower or position_query_lower in snippet_lower)
|
||
|
||
# Wenn das Suchergebnis nicht relevant erscheint, ueberspringe es
|
||
if not is_relevant_result:
|
||
#logger.debug(f" -> LinkedIn Treffer uebersprungen (nicht relevant fuer '{crm_kurzform}'/{position_query}): '{title[:100]}...'") # Gekuerzt loggen
|
||
continue
|
||
|
||
|
||
# --- Extrahiere Name und Position aus dem Titel (Heuristik) ---
|
||
name_part = ""
|
||
pos_part = position_query # Fallback fuer die Position
|
||
# Versuche gaengige Trennzeichen im Titel (z.B. Name - Position | Firma)
|
||
separators = [" – ", " - ", " | ", " at ", " bei "] # Laengere Trenner zuerst
|
||
title_cleaned = title.replace("...", "").strip() # Bereinige gaengige Zeichen
|
||
|
||
found_sep = False
|
||
for sep in separators:
|
||
if sep in title_cleaned:
|
||
parts = title_cleaned.split(sep, 1)
|
||
name_part = parts[0].strip() # Teil vor dem ersten Trenner ist wahrscheinlich der Name
|
||
|
||
# Der Teil nach dem ersten Trenner enthaelt wahrscheinlich Position und Firma
|
||
potential_pos_company = parts[1].strip()
|
||
# Versuche, LinkedIn-Suffixe zu entfernen (z.B. " | LinkedIn")
|
||
potential_pos_company = re.sub(r'[\s|\-]*LinkedIn[\s|\-]*Profile.*$', '', potential_pos_company, flags=re.IGNORECASE).strip()
|
||
potential_pos_company = re.sub(r'[\s|\-]*LinkedIn$', '', potential_pos_company, flags=re.IGNORECASE).strip()
|
||
|
||
|
||
# Versuche, die Firmenkurzform aus dem Position/Firma-Teil zu entfernen (ganzes Wort)
|
||
if crm_kurzform_lower in potential_pos_company.lower():
|
||
# Ersetze nur die erste gefundene Instanz der Kurzform
|
||
pos_company_cleaned = re.sub(r'\b' + re.escape(crm_kurzform_lower) + r'\b', '', potential_pos_company, flags=re.IGNORECASE).strip()
|
||
pos_company_cleaned = re.sub(r'\s+', ' ', pos_company_cleaned).strip() # Leerzeichen reduzieren nach Entfernung
|
||
else:
|
||
pos_company_cleaned = potential_pos_company # Behalte den Teil, wenn Kurzform nicht gefunden
|
||
|
||
|
||
pos_part = pos_company_cleaned if pos_company_cleaned else position_query # Nimm den bereinigten Teil oder den Suchbegriff
|
||
found_sep = True
|
||
break # Hoere beim ersten gefundenen Trennzeichen auf
|
||
|
||
|
||
# Wenn kein Trennzeichen gefunden wurde, versuche andere einfache Muster oder nimm den ganzen Titel als Name
|
||
if not found_sep:
|
||
# Muster: "[Name] [Position_Query]" oder "[Name] - LinkedIn" etc.
|
||
# Versuche, den gesuchten Positionsterm aus dem Titel zu entfernen, um den Namen zu isolieren
|
||
if position_query_lower in title_lower:
|
||
name_before_pos = title_lower.split(position_query_lower, 1)[0].strip()
|
||
name_part = title_cleaned[:len(name_before_pos)].strip() # Nimm Originaltext bis zum Beginn des Positionsterms
|
||
pos_part = position_query # Position ist der Suchbegriff
|
||
|
||
else:
|
||
# Wenn der Positionsterm nicht gefunden wurde und kein Trenner da war,
|
||
# nimm den gesamten Titel vor " - LinkedIn" als Name (weniger zuverlaessig)
|
||
name_part = re.sub(r'[\s|\-]*LinkedIn[\s|\-]*Profile.*$', '', title_cleaned, flags=re.IGNORECASE).strip()
|
||
name_part = re.sub(r'[\s|\-]*LinkedIn$', '', name_part, flags=re.IGNORECASE).strip()
|
||
pos_part = position_query # Position bleibt der Suchbegriff
|
||
|
||
|
||
# Teile den extrahierten Namen in Vor- und Nachname (einfache Annahme: erstes Wort = Vorname)
|
||
firstname = ""
|
||
lastname = ""
|
||
name_parts = name_part.split()
|
||
if len(name_parts) > 1:
|
||
firstname = name_parts[0]
|
||
lastname = " ".join(name_parts[1:]) # Der Rest ist Nachname
|
||
elif len(name_parts) == 1:
|
||
firstname = name_parts[0] # Nur Vorname gefunden?
|
||
# Wenn der Name leer ist (z.B. nach Entfernung von Rechtsformen), ueberspringe diesen Kontakt
|
||
if not firstname or not name_part.strip():
|
||
#logger.debug(f"LinkedIn Treffer uebersprungen: Name konnte nicht extrahiert werden aus Titel '{title[:100]}...'.") # Zu viel Laerm im Debug
|
||
continue
|
||
|
||
|
||
# Wenn wir bis hierher gekommen sind, scheint es ein gueltiger Kontakt zu sein.
|
||
contact_data = {
|
||
# Fuer die spaetere Verarbeitung (Gender, Email)
|
||
"Firmenname": company_name, # Originalname fuer Kontext
|
||
"CRM Kurzform": crm_kurzform,
|
||
"Website": website, # Website der Firma (fuer Email-Generierung)
|
||
"Vorname": firstname,
|
||
"Nachname": lastname,
|
||
"Position": pos_part, # Extrahierte oder Fallback Position
|
||
"LinkedInURL": linkedin_url
|
||
}
|
||
found_contacts.append(contact_data)
|
||
# logger.debug(f" -> Gefundener LinkedIn Kontakt: {firstname} {lastname} - {pos_part} (URL: {linkedin_url[:100]}...)") # Zu viel Laerm im Debug
|
||
|
||
|
||
# Wenn die Schleife durchlaeuft und Kontakte gefunden wurden
|
||
logger.info(f"LinkedIn Suche fuer '{position_query}' bei '{crm_kurzform}' ergab {len(found_contacts)} Kontakte.")
|
||
return found_contacts # Gibt die Liste der gefundenen Kontakte zurueck (leer bei nicht gefunden oder Fehler)
|
||
|
||
except Exception as e: # retry_on_failure wirft Exception im Fehlerfall erneut
|
||
# Fangen Sie alle anderen unerwarteten Exceptions ab.
|
||
# Der Fehler wird bereits vom retry_on_failure Decorator geloggt.
|
||
logger.error(f"FEHLER bei der SerpAPI LinkedIn Suche (Query: '{position_query}', Firma: '{crm_kurzform}'): {e}")
|
||
# Geben Sie eine leere Liste zurueck, da bei Fehler keine Kontakte gefunden wurden
|
||
return [] # Signalisiert Fehler bei der Suche
|
||
|
||
|
||
# ==============================================================================
|
||
# Ende SerpAPI Search Helpers Block
|
||
# ==============================================================================
|
||
|
||
# ==============================================================================
|
||
# GLOBALE HELPER FUNCTIONS (PART 10: Website Raw Scraping Function)
|
||
# ==============================================================================
|
||
|
||
# --- Globale Funktion zum Scrapen des Website Rohtextes ---
|
||
# Basierend auf get_website_raw aus Teil 7. Global platziert.
|
||
# Nutzt globale Helfer: simple_normalize_url, clean_text, re, requests, BeautifulSoup, Config, getattr, logger, retry_on_failure, USER_AGENTS, URL_CHECK_MARKER.
|
||
@retry_on_failure # Wende den Decorator auf diese Funktion an
|
||
def get_website_raw(url, max_length=20000, verify_cert=False): # verify_cert Default ist jetzt False
|
||
"""
|
||
Holt Textinhalt von einer Website, versucht Cookie-Banner zu umgehen.
|
||
Ignoriert standardmäßig SSL-Zertifikatfehler und gibt spezifischere Fehlerwerte
|
||
oder einen Marker fuer erneute URL-Suche zurueck.
|
||
"""
|
||
logger = logging.getLogger(__name__)
|
||
if not url or not isinstance(url, str) or url.strip().lower() in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"]:
|
||
logger.debug(f"get_website_raw skipped: Ungueltige oder leere URL '{url}'.")
|
||
return "k.A."
|
||
|
||
if not url.lower().startswith(("http://", "https://")):
|
||
url = "https://" + url
|
||
|
||
headers = {
|
||
"User-Agent": random.choice(USER_AGENTS) # Wählt zufälligen User-Agent aus der Liste
|
||
}
|
||
response = None
|
||
error_reason = "Unbekannter Fehler" # Default
|
||
return_marker = False # Flag für URL_CHECK_MARKER
|
||
|
||
try:
|
||
logger.debug(f"Versuche Website abzurufen: {url[:100]}... (verify={verify_cert})")
|
||
response = requests.get(
|
||
url,
|
||
timeout=getattr(Config, 'REQUEST_TIMEOUT', 20), # Timeout aus Config (Standard 20s)
|
||
headers=headers,
|
||
verify=verify_cert, # Nutzt den Default oder den übergebenen Wert
|
||
allow_redirects=True,
|
||
stream=False
|
||
)
|
||
response.raise_for_status() # Wirft HTTPError fuer 4xx/5xx
|
||
error_reason = None # Kein Fehler, wenn erfolgreich
|
||
|
||
except requests.exceptions.SSLError as e_ssl:
|
||
error_reason = f"SSL Fehler: {str(e_ssl)[:100]}..."
|
||
logger.warning(f"SSL Fehler (verify={verify_cert}) fuer {url[:100]}...: {e_ssl}")
|
||
# Wenn verify=True war, hätte man hier nochmal mit False versuchen können,
|
||
# aber da der Default jetzt False ist, ist ein erneuter Versuch meist redundant.
|
||
|
||
except requests.exceptions.Timeout as e_timeout:
|
||
error_reason = f"Timeout ({getattr(Config, 'REQUEST_TIMEOUT', 20)}s)"
|
||
logger.warning(f"{error_reason} fuer {url[:100]}...")
|
||
|
||
except requests.exceptions.ConnectionError as e_conn:
|
||
error_reason = f"Connection Error: {str(e_conn)[:100]}..."
|
||
logger.warning(f"{error_reason} fuer {url[:100]}...")
|
||
# Prüfe, ob es ein 'Name or service not known' oder 'Connection refused' Fehler ist etc.
|
||
if "[Errno -2] Name or service not known" in str(e_conn) or \
|
||
"[Errno -3] Temporary failure in name resolution" in str(e_conn) or \
|
||
"[Errno 111] Connection refused" in str(e_conn) or \
|
||
"[Errno 113] No route to host" in str(e_conn) or \
|
||
"Failed to establish a new connection" in str(e_conn):
|
||
return_marker = True # Starker Hinweis auf falsche/unerreichbare URL
|
||
|
||
except requests.exceptions.HTTPError as e_http:
|
||
status_code = e_http.response.status_code
|
||
error_reason = f"HTTP Error {status_code} ({e_http.response.reason})"
|
||
logger.warning(f"{error_reason} fuer {url[:100]}...")
|
||
if status_code == 404: # Bei 404 Fehler auch Marker setzen
|
||
return_marker = True
|
||
|
||
except Exception as e_gen:
|
||
error_reason = f"Allg. Fehler: {type(e_gen).__name__} - {str(e_gen)[:100]}..."
|
||
logger.error(f"Allgemeiner Fehler beim Abrufen von {url[:100]}...: {e_gen}")
|
||
logger.debug(traceback.format_exc())
|
||
|
||
|
||
if return_marker:
|
||
logger.warning(f"Markiere URL {url[:100]}... zur erneuten Prüfung (Grund: {error_reason}).")
|
||
return URL_CHECK_MARKER # <<< Speziellen Marker zurückgeben
|
||
elif response is None or error_reason:
|
||
return f"k.A. ({error_reason})"
|
||
|
||
|
||
# --- Ab hier: Verarbeitung der erfolgreichen Response (Code bleibt gleich wie vorher) ---
|
||
try:
|
||
response.encoding = response.apparent_encoding
|
||
soup = BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser'))
|
||
|
||
content_selectors = [
|
||
'main', 'article', '#content', '#main-content', '.main-content', '.content',
|
||
'div[role="main"]', 'div.page-content', 'div.container'
|
||
]
|
||
content_area = None
|
||
for selector in content_selectors:
|
||
content_area = soup.select_one(selector)
|
||
if content_area: break
|
||
|
||
if not content_area:
|
||
content_area = soup.find('body')
|
||
if content_area:
|
||
banner_selectors = [
|
||
'[id*="cookie"]', '[class*="cookie"]', '[id*="consent"]', '[class*="consent"]',
|
||
'.cookie-banner', '.consent-banner', '.modal', '#modal', '.popup', '#popup',
|
||
'[role="dialog"]', '[aria-modal="true"]'
|
||
]
|
||
banners_removed_count = 0
|
||
for selector in banner_selectors:
|
||
try:
|
||
potential_banners = content_area.select(selector)
|
||
for banner in potential_banners:
|
||
banner_text = banner.get_text(" ", strip=True).lower()
|
||
keywords = ["cookie", "zustimm", "ablehnen", "einverstanden", "datenschutz", "privacy", "akzeptier", "einstellung", "partner", "analyse", "marketing"]
|
||
element_id_class = (banner.get('id', '') + ' ' + ' '.join(banner.get('class', []))).lower()
|
||
if any(keyword in banner_text for keyword in keywords) or any(keyword in element_id_class for keyword in keywords):
|
||
banner.decompose()
|
||
banners_removed_count += 1
|
||
except Exception as e_select:
|
||
logger.debug(f"Fehler beim Versuch Banner mit Selektor '{selector}' zu entfernen: {e_select}")
|
||
if banners_removed_count > 0:
|
||
logger.debug(f"{banners_removed_count} potenzielle Banner-Elemente fuer {url[:100]}... entfernt.")
|
||
|
||
if content_area:
|
||
for script_or_style in content_area(["script", "style"]):
|
||
script_or_style.decompose()
|
||
|
||
text = content_area.get_text(separator=' ', strip=True)
|
||
text = re.sub(r'\s+', ' ', text).strip()
|
||
|
||
banner_keywords_strict = ["cookie", "zustimmen", "ablehnen", "einverstanden", "datenschutz", "privacy", "akzeptier", "einstellung", "partner", "analyse", "marketing"]
|
||
text_lower = text.lower()
|
||
keyword_hits = sum(1 for keyword in banner_keywords_strict if keyword in text_lower)
|
||
|
||
if len(text) < 500 and keyword_hits >= 3:
|
||
logger.warning(f"WARNUNG: Extrahierter Text fuer {url[:100]}... scheint nur Cookie-Banner zu sein (Laenge {len(text)}, {keyword_hits} Keywords). Verwerfe Text.")
|
||
return "k.A. (Nur Cookie-Banner erkannt)"
|
||
|
||
if len(text.split()) < 10 or len(text) < 50: # Ursprünglich 50, aber das ist sehr restriktiv für kurze Seiten
|
||
pass # Nur kurze Texte sind nicht unbedingt ein Fehler
|
||
|
||
result = text[:max_length]
|
||
logger.debug(f"Website {url[:100]}... erfolgreich gescrapt. Extrahierter Text (Laenge {len(result)}).")
|
||
return result if result else "k.A. (Extraktion leer)"
|
||
else:
|
||
logger.warning(f"Kein <body> oder spezifischer Inhaltsbereich gefunden in {url[:100]}...")
|
||
return "k.A. (Kein Body gefunden)"
|
||
|
||
except Exception as e_parse:
|
||
logger.error(f"Fehler beim Parsen von HTML von {url[:100]}...: {type(e_parse).__name__} - {e_parse}")
|
||
logger.debug(traceback.format_exc())
|
||
return f"k.A. (Fehler Parsing: {str(e_parse)[:50]}...)"
|
||
|
||
# ==============================================================================
|
||
# Ende Website Raw Scraping Funktion Block
|
||
# ==============================================================================
|
||
|
||
# ==============================================================================
|
||
# GLOBALE HELPER FUNCTIONS (PART 11: Website Details Scraping Function)
|
||
# ==============================================================================
|
||
|
||
# --- Experimentelle Website Details Scraping Funktion ---
|
||
# Basierend auf scrape_website_details aus Teil 10. Global platziert.
|
||
# Nutzt globale Helfer: retry_on_failure, requests, BeautifulSoup, Config, getattr, clean_text, logger.
|
||
# Diese Funktion ist als experimentelles Dienstprogramm gedacht.
|
||
# Ihre Implementierung hängt stark von der Struktur der Zielwebsites ab.
|
||
# Derzeit extrahiert sie nur grundlegende Meta-Informationen.
|
||
def scrape_website_details(url):
|
||
"""
|
||
EXPERIMENTELL: Scrapt eine Website und extrahiert spezifische Details.
|
||
Diese Funktion muss je nach Zielwebsite(s) implementiert/angepasst werden.
|
||
|
||
Args:
|
||
url (str): Die URL der Website.
|
||
|
||
Returns:
|
||
str: Extrahierte Details als String oder Fehler/k.A.
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
logger = logging.getLogger(__name__) # <<< DIESE ZEILE HINZUFÜGEN
|
||
# Pruefen Sie auf ungueltige oder leere URLs
|
||
if not url or not isinstance(url, str) or url.strip().lower() in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"]:
|
||
logger.debug(f"scrape_website_details skipped: Ungueltige oder leere URL '{url}'.")
|
||
return "k.A." # Gebe "k.A." zurueck bei ungueltigen Eingaben
|
||
|
||
|
||
logger.warning(f"Ausführe 'scrape_website_details' fuer URL {url[:100]}...") # Logge den Start auf Warning
|
||
|
||
try: # Beginn des try-Blocks
|
||
# Hilfsfunktion zum Abrufen des Soup-Objekts mit Retry.
|
||
@retry_on_failure
|
||
def get_soup_for_details(target_url):
|
||
if not target_url or not isinstance(target_url, str):
|
||
logger.error(f"get_soup_for_details: Ungültige URL '{target_url}' erhalten.")
|
||
raise ValueError(f"Ungültige URL für get_soup_for_details: {target_url}")
|
||
|
||
if not target_url.lower().startswith(("http://", "https://")):
|
||
logger.debug(f"get_soup_for_details: Füge 'https://' zu URL '{target_url}' hinzu.")
|
||
target_url = "https://" + target_url
|
||
|
||
response = requests.get(target_url, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15), verify=True)
|
||
response.raise_for_status()
|
||
response.encoding = response.apparent_encoding
|
||
return BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser'))
|
||
# Ende der Definition von get_soup_for_details
|
||
|
||
# Rufen Sie die Hilfsfunktion auf, um das Soup-Objekt zu erhalten
|
||
# Diese Zeile muss auf derselben Einrückungsebene wie die @retry_on_failure Zeile sein,
|
||
# da sie die nächste Anweisung innerhalb des try-Blocks nach der Funktionsdefinition ist.
|
||
soup = get_soup_for_details(url) # Korrekte Einrückung (innerhalb des try-Blocks)
|
||
|
||
if soup: # Korrekte Einrückung (innerhalb des try-Blocks)
|
||
# --- Extrahiere spezifische Details ---
|
||
title = soup.find('title')
|
||
meta_desc = soup.find('meta', attrs={'name': 'description'})
|
||
h1 = soup.find('h1')
|
||
|
||
details_list = []
|
||
|
||
if title: details_list.append(f"Title: {clean_text(title.get_text())}")
|
||
if meta_desc and meta_desc.get('content'): details_list.append(f"Description: {clean_text(meta_desc['content'])}")
|
||
if h1: details_list.append(f"H1: {clean_text(h1.get_text())}")
|
||
|
||
if details_list:
|
||
result_string = " | ".join(details_list)
|
||
logger.debug(f"Details fuer {url[:100]}... extrahiert: {result_string[:100]}...")
|
||
return result_string
|
||
else:
|
||
logger.debug(f"Keine Standard-Details (Title, Description, H1) gefunden fuer {url[:100]}...")
|
||
return "k.A. (Keine Standard-Details gefunden)"
|
||
else:
|
||
logger.error(f"Scraping fuer Details fehlgeschlagen nach Retries fuer {url[:100]}...")
|
||
return "k.A. (Scraping fehlgeschlagen)"
|
||
|
||
except Exception as e: # Dieser except-Block gehört zum äußeren try
|
||
logger.error(f"FEHLER in scrape_website_details fuer {url[:100]}...: {type(e).__name__} - {e}")
|
||
logger.debug(traceback.format_exc()) # Hinzugefügt für besseres Debugging
|
||
return f"k.A. (Fehler: {str(e)[:100]}...)"
|
||
|
||
|
||
def is_valid_wikipedia_article_url(url_to_check, lang=None):
|
||
"""
|
||
Prueft, ob eine gegebene URL zu einem gueltigen, existierenden Wikipedia-Artikel
|
||
fuehrt (keine Begriffsklaerung, kein Fehler).
|
||
Nutzt die wikipedia-Bibliothek.
|
||
|
||
Args:
|
||
url_to_check (str): Die zu pruefende Wikipedia-URL.
|
||
lang (str, optional): Die Sprache der Wikipedia (z.B. 'de', 'en').
|
||
Wenn None, wird die aktuell in der wikipedia-Bibliothek
|
||
gesetzte Sprache verwendet.
|
||
|
||
Returns:
|
||
bool: True, wenn die URL zu einem gueltigen Artikel fuehrt, sonst False.
|
||
"""
|
||
logger = logging.getLogger(__name__)
|
||
if not url_to_check or not isinstance(url_to_check, str) or "wikipedia.org/wiki/" not in url_to_check.lower():
|
||
logger.debug(f"is_valid_wikipedia_article_url: Ungueltige URL-Struktur: {url_to_check[:100]}...")
|
||
return False
|
||
|
||
original_lang = None
|
||
if lang:
|
||
try:
|
||
original_lang = wikipedia.get_lang() # Speichere aktuelle Sprache
|
||
wikipedia.set_lang(lang)
|
||
logger.debug(f"Temporaer Wikipedia-Sprache auf '{lang}' gesetzt für Validierung.")
|
||
except Exception as e_lang:
|
||
logger.warning(f"Konnte Wikipedia-Sprache nicht auf '{lang}' setzen für Validierung: {e_lang}")
|
||
# Fahre mit der global eingestellten Sprache fort
|
||
|
||
|
||
is_valid = False
|
||
try:
|
||
# Extrahiere den Titel aus der URL
|
||
title_part = url_to_check.split('/wiki/', 1)[1].split('#')[0]
|
||
title = unquote(title_part).replace('_', ' ')
|
||
|
||
logger.debug(f"Validiere Wikipedia-Artikel: '{title[:100]}...' (URL: {url_to_check[:100]}...)")
|
||
# Versuche, die Seite zu laden. auto_suggest=False, um keine alternativen Vorschlaege zu bekommen.
|
||
# preload=True laedt den Inhalt direkt, um Fehler frueh zu erkennen.
|
||
page = wikipedia.page(title, auto_suggest=False, preload=True)
|
||
# Wenn keine Exception geworfen wurde, existiert die Seite.
|
||
# Wir nehmen an, dass es ein gueltiger Artikel ist, wenn keine DisambiguationError auftritt.
|
||
# Eine genauere Pruefung, ob es wirklich ein *Unternehmens*-Artikel ist,
|
||
# wuerde die Logik von WikipediaScraper._validate_article erfordern.
|
||
is_valid = True
|
||
logger.debug(f" -> Artikel '{title[:100]}...' scheint valide zu sein (Seite geladen).")
|
||
|
||
except wikipedia.exceptions.PageError:
|
||
logger.debug(f" -> Seite '{title[:100]}...' nicht gefunden (PageError).")
|
||
is_valid = False
|
||
except wikipedia.exceptions.DisambiguationError as e_disamb:
|
||
logger.debug(f" -> Seite '{title[:100]}...' ist eine Begriffsklaerungsseite. Optionen: {str(e_disamb.options)[:100]}...")
|
||
is_valid = False # Begriffsklaerungen sind keine direkten Artikel
|
||
except Exception as e:
|
||
logger.error(f" -> Unerwarteter Fehler bei Validierung von '{title[:100]}...': {type(e).__name__} - {e}")
|
||
logger.debug(traceback.format_exc())
|
||
is_valid = False
|
||
finally:
|
||
if original_lang: # Setze Sprache zurueck, falls sie geaendert wurde
|
||
try:
|
||
wikipedia.set_lang(original_lang)
|
||
logger.debug(f"Wikipedia-Sprache zurueck auf '{original_lang}' gesetzt.")
|
||
except Exception as e_lang_reset:
|
||
logger.warning(f"Konnte Wikipedia-Sprache nicht zurueck auf '{original_lang}' setzen: {e_lang_reset}")
|
||
|
||
return is_valid
|
||
|
||
|
||
# ==============================================================================
|
||
# Ende Website Details Scraping Funktion Block
|
||
# ==============================================================================
|
||
|
||
# ==============================================================================
|
||
# GLOBALE HELPER FUNCTIONS (PART 12: Alignment Demo Function)
|
||
# ==============================================================================
|
||
|
||
# Annahme: Als globale Funktion, wie ursprünglich von Ihnen gezeigt.
|
||
# Wenn sie in DataProcessor ist, ändern Sie `sheet` zu `self.sheet_handler.sheet`
|
||
# und fügen `self` als erstes Argument der Methode hinzu.
|
||
|
||
def alignment_demo(sheet):
|
||
logger = logging.getLogger(__name__)
|
||
logger.info("Starte Alignment Demo für das Hauptblatt...")
|
||
|
||
new_headers = [
|
||
[ # Zeile 1: Spaltenname (68 Spalten)
|
||
"ReEval Flag", "CRM Name", "CRM Kurzform", "Parent Account Name",
|
||
"CRM Website", "CRM Ort", "CRM Land", "CRM Beschreibung", "CRM Branche", "CRM Beschreibung Branche extern", "CRM Anzahl Techniker", "CRM Umsatz", "CRM Anzahl Mitarbeiter", "CRM Vorschlag Wiki URL",
|
||
"System Vorschlag Parent Account", "Parent Vorschlag Status", "Parent Vorschlag Timestamp",
|
||
"Wiki URL", "Wiki Sitz Stadt", "Wiki Sitz Land", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Wikipedia Timestamp", "Wiki Verif. Timestamp", "SerpAPI Wiki Search Timestamp", "Chat Wiki Konsistenzpruefung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", "Begründung bei Abweichung",
|
||
"Website Rohtext", "Website Zusammenfassung", "Website Meta-Details", "Website Scrape Timestamp", "URL Prüfstatus",
|
||
"Chat Vorschlag Branche", "Chat Branche Konfidenz", "Chat Konsistenz Branche", "Chat Begruendung Abweichung Branche", "Chat Prüfung FSM Relevanz", "Chat Begründung für FSM Relevanz", "Chat Schätzung Anzahl Mitarbeiter", "Chat Konsistenzprüfung Mitarbeiterzahl", "Chat Begründung Abweichung Mitarbeiterzahl", "Chat Einschätzung Anzahl Servicetechniker", "Chat Begründung Abweichung Anzahl Servicetechniker", "Chat Schätzung Umsatz", "Chat Begründung Abweichung Umsatz",
|
||
"Linked Serviceleiter gefunden", "Linked It-Leiter gefunden", "Linked Management gefunden", "Linked Disponent gefunden",
|
||
"Contact Search Timestamp", "Finaler Umsatz (Wiki>CRM)", "Finaler Mitarbeiter (Wiki>CRM)", "Geschaetzter Techniker Bucket",
|
||
"Plausibilität Umsatz", "Plausibilität Mitarbeiter", "Plausibilität Umsatz/MA Ratio", "Abweichung Umsatz CRM/Wiki", "Abweichung MA CRM/Wiki", "Plausibilität Begründung",
|
||
"Plausibilität Prüfdatum",
|
||
"Timestamp letzte Pruefung", "Version", "Tokens"
|
||
],
|
||
[ # Zeile 2: Quelle der Daten (68 Spalten)
|
||
"CRM", "CRM", "CRM", "CRM/Manuell",
|
||
"CRM", "CRM", "CRM/Manuell", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM",
|
||
"System", "Manuell/System", "System",
|
||
"Wikipediascraper/SerpAPI/ChatGPT/Manuell", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "System", "System", "System", "ChatGPT API", "ChatGPT API", "ChatGPT API", "System/Manuell",
|
||
"Web Scraper", "ChatGPT API", "Web Scraper", "System", "System/Web Scraper",
|
||
"ChatGPT API", "ChatGPT API", "System/ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API", "System/ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API",
|
||
"LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)",
|
||
"System", "Skript (Wiki/CRM Logik)", "Skript (Wiki/CRM Logik)", "ML Modell / Skript",
|
||
"Skript (Plausi-Check)", "Skript (Plausi-Check)", "Skript (Plausi-Check)", "Skript (Plausi-Check)", "Skript (Plausi-Check)", "Skript (Plausi-Check)",
|
||
"System (Plausi-Check TS)",
|
||
"System", "System", "System"
|
||
],
|
||
[ # Zeile 3: Feldkategorie (68 Spalten)
|
||
"Prozess", "Firmenname", "Firmenname", "Konzernstruktur",
|
||
"Website", "Ort", "Land", "Beschreibung (Text)", "Branche", "Branche", "Anzahl Servicetechniker", "Umsatz", "Anzahl Mitarbeiter", "Wikipedia Artikel URL",
|
||
"Konzernstruktur (Vorschlag)", "Konzernstruktur (Status)", "Timestamp",
|
||
"Wikipedia Artikel URL", "Ort", "Land", "Beschreibung (Text)", "Branche", "Umsatz", "Anzahl Mitarbeiter", "Kategorien (Text)", "Timestamp", "Timestamp", "Timestamp", "Verifizierung Wiki-Artikel", "Begründung Verifizierung", "Wikipedia Artikel URL (Vorschlag)", "Begründung URL-Abweichung",
|
||
"Website-Content", "Website-Content (Zusammenfassung)", "Website-Content (Meta)", "Timestamp", "Prozess-Status",
|
||
"Branche (Vorschlag KI)", "Branche (Konfidenz KI)", "Branche (Konsistenz)", "Branche (Begründung KI)", "FSM Relevanz (KI)", "FSM Relevanz (Begründung KI)", "Anzahl Mitarbeiter (KI)", "Anzahl Mitarbeiter (Konsistenz KI)", "Anzahl Mitarbeiter (Begründung KI)", "Anzahl Servicetechniker (KI)", "Anzahl Servicetechniker (Begründung KI)", "Umsatz (KI)", "Umsatz (Begründung KI)",
|
||
"Kontakte (Anzahl)", "Kontakte (Anzahl)", "Kontakte (Anzahl)", "Kontakte (Anzahl)",
|
||
"Timestamp", "Umsatz (Konsolidiert)", "Anzahl Mitarbeiter (Konsolidiert)", "Anzahl Servicetechniker (Bucket ML)",
|
||
"Plausibilität", "Plausibilität", "Plausibilität", "Datenqualitäts-Indikator", "Datenqualitäts-Indikator", "Plausibilität (Text)",
|
||
"Timestamp (Plausi)",
|
||
"Timestamp", "Skript Version", "API Tokens"
|
||
],
|
||
[ # Zeile 4: Kurze Beschreibung (68 Spalten - JETZT AUSFÜHRLICH)
|
||
"Systemspalte, irrelevant für den Prompt. Wird genutzt um die manuelle Neuprüfung dieses Accounts durchzuführen.", #A ReEval Flag
|
||
"Enthält den Firmennamen nach bestem Gewissen. Firmennamen sind manchmal herausfordernd, insbesondere was unterschiedliche Schreibweisen, Firmierung, Tochter/Mutterfirmen etc. anbelangt. Zur besseren Trefferquote in der Wikipedia-Suche normalisieren wir den Firmennamen und entfernen sämtliche Firmenformen, wie z.B. AG, GmbH, SE etc.", #B CRM Name
|
||
"Enthält eine manuell gepflegte (normalisierte) Kurzform des Firmennamens, wie auch ein Mensch die Firma nennen würde. Dies bedeutet insbesondere, dass die Firmenform wie z.B. GmbH oder AG aus dem Namen entfernt wird. Meist entspricht die Kurzform den ersten beiden Worten des Firmennamens. Manchmal sind auch Worte nötig, wenn die ersten beiden worte zu wenig Aussagekraft haben. Beispiele dafür sind beispielsweise Firmen wie 'Schmidt & Söhne', bei denen 'Schmidt &' wenig Sinn machen würde, oder 'Philip Morris Tabakwaren' - weil in diesem Fall 'Philip Morris' zu generisch wäre bzw. wenig eindeutig.", #C CRM Kurzform
|
||
"Name der direkten Muttergesellschaft / des Hauptkonzerns (falls zutreffend). Manuell gepflegt oder aus CRM. Beeinflusst Konsolidierung und Plausi-Checks.", #D Parent Account Name
|
||
"Von uns ermittelte Website des Unternehmens, sofern verfügbar.", #E CRM Website
|
||
"von uns ermittelter Ort des Unternehmens", #F CRM Ort
|
||
"Land des Unternehmenssitzes laut CRM oder manueller Recherche. Wichtig für regionale Analysen (z.B. DACH).", #G CRM Land
|
||
"Kurze Beschreibung der Haupttätigkeit des Unternehmens aus dem CRM-System. Dient als Input für KI-Analysen.", #H CRM Beschreibung
|
||
"Branchenzuweisung aus dem CRM-System. Entspricht idealerweise einer Branche aus dem Ziel-Branchenschema.", #I CRM Branche
|
||
"Von externen Datenanbietern (z.B. Dealfront) gelieferte Beschreibung der Branche des Unternehmens. Diese Branchenbeschreibung sollte in den allermeisten Fällen sehr zutreffend sein und ist vermutlich verlässlicher als die aktuelle Branche aus Spalte I.", #J CRM Beschreibung Branche extern
|
||
"Bekannte Anzahl der Servicetechniker des Unternehmens (aus CRM oder Recherche). Dient als Ground Truth für ML.", #K CRM Anzahl Techniker
|
||
"Umsatz des Unternehmens in Millionen Euro laut CRM oder Recherche.", #L CRM Umsatz
|
||
"Anzahl der Mitarbeiter des Unternehmens laut CRM oder Recherche.", #M CRM Anzahl Mitarbeiter
|
||
"Enthält aus einer alten Recherche Vorschläge für die Wikipedia URL zum Unternehmen. Dieser muss aber nicht stimmen. Sollte als Ausgangs- und Vergleichspunkt für die nachgelagerte Wikipedia-Suche dienen. Der Wert soll mit den üblichen Methoden geprüft werden z.B. kommt die normalisierte Website vor, Ähnlichkeitsprüfung des Firmennamens mit dem Artikelnamen von Wikipedia etc.", #N CRM Vorschlag Wiki URL
|
||
"Vom System heuristisch ermittelter Vorschlag für den Parent Account (basierend auf Namensähnlichkeiten, Wiki-Infos etc.).", #O System Vorschlag Parent Account
|
||
"Status des System-Vorschlags für Parent Account (z.B. 'x' für akzeptiert, '-' für abgelehnt, '?' für unklar zur manuellen Prüfung).", #P Parent Vorschlag Status
|
||
"Zeitstempel der letzten Generierung/Änderung des Parent-Vorschlags/-Status.", #Q Parent Vorschlag Timestamp
|
||
"Wikipedia URL aus der Recherche im laufenden Prozess", #R Wiki URL (alt M)
|
||
"Aus Wikipedia-Infobox extrahierte Stadt des Unternehmenssitzes.", #S Wiki Sitz Stadt (neu)
|
||
"Aus Wikipedia-Infobox extrahiertes Land des Unternehmenssitzes.", #T Wiki Sitz Land (neu)
|
||
"Erster Absatz des Wikipedia-Artikels", #U Wiki Absatz (alt N)
|
||
"Branche aus Wikipedia-Artikel soweit verfügbar", #V Wiki Branche (alt O)
|
||
"Umsatz aus Wikipediaartikel soweit verfügbar.", #W Wiki Umsatz (alt P)
|
||
"Anzahl Mitarbeiter laut Wikipedia sofern verfügbar.", #X Wiki Mitarbeiter (alt Q)
|
||
"Komma-separierte Liste der Kategorien, denen der Artikel in Wikipedia zugewiesen wurde. Hier ist auch häufig eine Branche enthalten, häufig auch noch weitere Informationen etwa zur Gründung, ob sie etwa im DAX gelistet ist etc. Guter Anhaltspunkt zur Differenzierung von Unternehmenseinträgen und Wikipedia-Seiten, die kein Unternehmen beschreiben und fälschlicherweise zugewiesen wurden. \nBei jeder Unternehmensseite MUSS das Wort unternehmen in irgendeiner Art und Weise vorkommen.\nNEGATIVSIGNAL: EHEMALIGES UNTERNEHMEN -> Weist darauf hin, dass das Unternehmen nicht mehr besteht.", #Y Wiki Kategorien (alt R)
|
||
"Zeitstempel der letzten Wikipedia-Suche und Datenextraktion für diese Zeile (jetzt für Spalten R-Y).", #Z Wikipedia Timestamp (alt AN)
|
||
"Zeitstempel der letzten Wikipedia-Artikel-Verifizierung durch ChatGPT (Ergebnis in Spalten AC-AE).", #AA Wiki Verif. Timestamp (alt AX)
|
||
"Zeitstempel des letzten Versuchs, eine fehlende Wiki-URL (R) über SerpAPI zu suchen.", #AB SerpAPI Wiki Search Timestamp (alt AY)
|
||
"\"OK\" wird bei Firmen eingetragen, wo Firma und Wikipedia-Eintrag zusammenpassen. \"X\" wird bei Firmen eingetragen, wo Firma und Wikipedia-Eintrag nicht zusammenpassen.", #AC Chat Wiki Konsistenzpruefung (alt S)
|
||
"Begründung welche Inkonsistenz aus den Daten hervorgeht.", #AD Chat Begründung Wiki Inkonsistenz (alt T)
|
||
"URL des durch ChatGPT recherchierten Wikipedia-Artikels", #AE Chat Vorschlag Wiki Artikel (alt U)
|
||
"XXX derzeit nicht verwendet, wird vermutlich gelöscht xxx", #AF Begründung bei Abweichung (alt V)
|
||
"Roh extrahierter Textinhalt der Firmenwebsite. Basis für Zusammenfassung und KI-Analysen.", #AG Website Rohtext (neu)
|
||
"KI-generierte Zusammenfassung des Website-Rohtextes (AG). Input für Branchenbewertung.", #AH Website Zusammenfassung (neu)
|
||
"Extrahierte Meta-Daten der Website (Title, Description, H-Tags). Für schnelle Analyse & Validierung.", #AI Website Meta-Details (neu)
|
||
"Zeitstempel des letzten Website-Scraping/Summarization-Versuchs (für AG-AI).", #AJ Website Scrape Timestamp (neu)
|
||
"Status der URL-Prüfung (z.B. 'URL_CHECK_NEEDED', 'URL_OK', 'FEHLER_SSL'). Wird von 'check_urls' Modus gesetzt/genutzt.", #AK URL Prüfstatus (neu)
|
||
"Durch ChatGPT ermittelte Branche des Unternehmens", #AL Chat Vorschlag Branche (alt W)
|
||
"Konfidenz des ChatGPT-Branchenvorschlags (AL), z.B. Hoch/Mittel/Niedrig.", #AM Chat Branche Konfidenz (neu)
|
||
"\"OK\" wird bei Firmen eingetragen, wo die Einschätzung zur Branche mit der CRM Branche übereinstimmt. \"X\" wird ausgegeben, wenn die Einschätzungen nicht zusammenpassen.", #AN Chat Konsistenz Branche (alt X)
|
||
"Begründung für Abweichung der Branche von CRM Branche", #AO Chat Begruendung Abweichung Branche (alt Y)
|
||
"\"OK\" wird bei Firmen eingetragen, für die FSM relevant ist, \"X\" für Firmen, für die FSM irrelevant ist.", #AP Chat Prüfung FSM Relevanz (alt Z)
|
||
"Begründung für die Beurteilung in Spalte Chat Begründung für FSM Relevanz", #AQ Chat Begründung für FSM Relevanz (alt AA)
|
||
"Anzahl der Mitarbeiter durch ChatGPT geschätzt.", #AR Chat Schaetzung Anzahl Mitarbeiter (alt AB)
|
||
"\"OK\" wird bei Firmen eingetragen, für die Anzahl der Mitarbeiter grob mit der aus Spalte CRM Anzahl Mitarbeiter bzw. der Spalte Wiki Mitarbeiter übereinstimmt. \"X\" für Firmen, bei denen dies nicht zutrifft.", #AS Chat Konsistenzprüfung Mitarbeiterzahl (alt AC)
|
||
"Begründung für Abweichende Mitarbeiterzahl", #AT Chat Begründung Abweichung Mitarbeiterzahl (alt AD)
|
||
"Anzahl der Servicetechniker geschätzt durch Chat GPT", #AU Chat Einschätzung Anzahl Servicetechniker (alt AE)
|
||
"Begründung für Abweichungen zur Anzahl der Techniker", #AV Chat Begründung Abweichung Anzahl Servicetechniker (alt AF)
|
||
"Umsatz durch ChatGTP geschätzt", #AW Chat Schätzung Umsatz (alt AG)
|
||
"Begründung für Abweichungen zum Umsatz", #AX Chat Begründung Abweichung Umsatz (alt AH)
|
||
"Anzahl der Kontakte die zur Suche 'Serviceleiter', 'Leiter Service', 'technischer Leiter', 'Service Manager', 'Leiter Kundendienst' gefunden wurden", #AY Linked Serviceleiter gefunden (alt AI)
|
||
"Anzahl der Kontakte die zur Suche 'Leiter IT', 'IT Leiter', 'Head of IT', 'IT-Leiter', 'CIO' gefunden wurden", #AZ Linked It-Leiter gefunden (alt AJ)
|
||
"Anzahl der Kontakte die zur Suche 'Geschäftsführer', 'Geschäftsführung', 'GF', 'CEO', 'Geschäftsführerin', 'Managing Director', 'Geschäftsführender Gesellschafter' gefunden wurden", #BA Linked Management gefunden (alt AK)
|
||
"Anzahl der Kontakte die zur Suche 'Disponent', 'Einsatzplaner' gefunden wurden", #BB Linked Disponent gefunden (alt AL)
|
||
"Timestamp des Zeitpunkts zu dem die Kontaktsuche fertiggestellt wurde", #BC Contact Search Timestamp (alt AM)
|
||
"Konsolidierter Umsatzwert in Millionen Euro. Priorisiert Wiki (W) > CRM (L). Berücksichtigt Parent-Account (D).", #BD Finaler Umsatz (Wiki>CRM)
|
||
"Konsolidierte Mitarbeiterzahl (absolut). Priorisiert Wiki (X) > CRM (M). Berücksichtigt Parent-Account (D).", #BE Finaler Mitarbeiter (Wiki>CRM)
|
||
"Ergebnis der Schätzung durch das trainierte Machine-Learning-Modell (Techniker-Bucket).", #BF Geschaetzter Techniker Bucket
|
||
"Plausibilitätsstatus für den finalen Umsatzwert (BD) (z.B. OK, WARNUNG_HOCH, FEHLER_FORMAT).", #BG Plausibilität Umsatz
|
||
"Plausibilitätsstatus für die finale Mitarbeiterzahl (BE) (z.B. OK, WARNUNG_NIEDRIG).", #BH Plausibilität Mitarbeiter
|
||
"Plausibilitätsstatus für die Umsatz-pro-Mitarbeiter-Ratio (BD/BE).", #BI Plausibilität Umsatz/MA Ratio
|
||
"Indikator für Abweichung (>30%) zwischen CRM-Umsatz (L) und Wiki-Umsatz (W). Berücksichtigt Parent-Logik.", #BJ Abweichung Umsatz CRM/Wiki
|
||
"Indikator für Abweichung (>30%) zwischen CRM-MA (M) und Wiki-MA (X). Berücksichtigt Parent-Logik.", #BK Abweichung MA CRM/Wiki
|
||
"Gesammelte Begründungen für Plausibilitätswarnungen oder -fehler aus den Spalten BG-BK.", #BL Plausibilität Begründung
|
||
"Zeitstempel des letzten Laufs der Plausibilitäts-Checks für diese Zeile.", #BM Plausibilität Prüfdatum
|
||
"Timestamp des Zeitpunkts zu dem die Validierung durch ChatGPT durchgeführt wurde", #BN Timestamp letzte Prüfung (alt AO)
|
||
"Systemspalte zur Ausgabe der Skriptversion die das Ergebnis generiert hat", #BO Version (alt AP)
|
||
"Zeigt an, wie viele Tokens für den Request benötigt wurden" #BP Tokens (alt AQ)
|
||
],
|
||
[ # Zeile 5: Aufgabe / Funktion (68 Spalten - JETZT AUSFÜHRLICH)
|
||
"Datenquelle/Prozesssteuerung: 'x' markiert Zeile für Re-Evaluation im Modus 'reeval'.", #A
|
||
"Datenquelle: Firmenname aus CRM.", #B
|
||
"Datenquelle: Manuell gepflegte Kurzform des Firmennamens, primär für API-Suchen (LinkedIn, SerpAPI) und Matching genutzt.", #C
|
||
"Datenquelle: Manuell oder aus CRM gepflegter Name der Muttergesellschaft. Wird verwendet, um bei Konsolidierung und Plausibilitätsabgleich von Tochterfirmen korrekte Bezüge herzustellen.", #D
|
||
"Datenquelle/Ziel: Offizielle Website des Unternehmens. Wird für Web-Scraping und als Info für ChatGPT genutzt. Kann durch 'website_lookup' oder 'check_urls' aktualisiert werden.", #E
|
||
"Datenquelle: Ort des Unternehmenssitzes aus CRM.", #F
|
||
"Datenquelle: Land des Unternehmenssitzes laut CRM. Wichtig für regionale Analysen.", #G
|
||
"Datenquelle: Beschreibung aus CRM. Wichtiger Input für KI-Analysen (Branchen, FSM etc.).", #H
|
||
"Datenquelle: Branchenkategorie aus CRM. Dient als Referenz und für Vergleiche mit KI-Vorschlägen.", #I
|
||
"Datenquelle: Externe Branchenbeschreibung (z.B. von Dealfront). Zusätzlicher Input für KI-Branchenbewertung.", #J
|
||
"Datenquelle: Recherchierte Anzahl Servicetechniker. Dient als Ground Truth für ML-Training und Validierung der KI-Schätzung.", #K
|
||
"Datenquelle: Umsatz aus CRM (in Mio. €). Input für Konsolidierung, Plausi-Checks und ML.", #L
|
||
"Datenquelle: Mitarbeiterzahl aus CRM (absolut). Input für Konsolidierung, Plausi-Checks und ML.", #M
|
||
"Datenquelle: Alte/vorgeschlagene Wiki-URL aus CRM. Dient als initialer Input oder Vergleichswert für den Wiki-Prozess.", #N
|
||
"Ziel/System: Vom Skript generierter Vorschlag für die Muttergesellschaft (basierend auf Heuristiken), zur manuellen Prüfung.", #O
|
||
"Prozesssteuerung/Manuell: Bestätigung ('x'), Ablehnung ('-') oder Unklarheit ('?') des System-Vorschlags. Kann Übernahme in Spalte D steuern.", #P
|
||
"System: Zeitstempel für die Bearbeitung von Spalte O und P (Parent Vorschlag).", #Q
|
||
"Wird durch Wikipedia Scraper bereitgestellt", #R Wiki URL (alt M)
|
||
"Quelle: Aus Wikipedia-Infobox extrahierte Stadt des Unternehmenssitzes. Ziel: Geografische Analyse.", #S Wiki Sitz Stadt
|
||
"Quelle: Aus Wikipedia-Infobox extrahiertes Land des Unternehmenssitzes. Ziel: Geografische Analyse (DACH).", #T Wiki Sitz Land
|
||
"Wird zunächst nicht verwendet, kann möglicherweise in einem späteren Schritt z.B. zum Vergleich mit der CRM Beschreibung genutzt werden, um auf Textähnlichkeit / Übereinstimmende Worte geprüft zu werden und damit eine Validierung des Artikels zum Account sicherzustellen.", #U Wiki Absatz (alt N)
|
||
"Wird u.a. zur finalen Ermittlung der Branche im Ziel-Branchenschema genutzt und mit der CRM Branche bzw. CRM Beschreibung Branche Extern verglichen. Stimmen alle drei Einstufungen grob überein, bestärkt dies die urpsrüngliche Einstufung. Laufen diese Branchen weit auseinander, soll, sofern der Wikipedia-Artikel verifiziert ist, die Branche von Wikipedia als zuverlässigste Quelle bewertet werden, danach folgen die CRM Beschreibung Branche Extern und die CRM Branche an dritter Stelle.", #V Wiki Branche (alt O)
|
||
"Wird u.a. mit CRM Umsatz zur Validierung des Unternehmens verglichen bzw. zur Bewertung der größe / Einschätzung Anzahl der Techniker bzw. Bewertung der Relevanz für FSM genutzt.", #W Wiki Umsatz (alt P)
|
||
"Wird u.a. mit CRM Anzahl Mitarbeiter zur Validierung des Unternehmens verglichen bzw. zur Bewertung der größe / Einschätzung Anzahl der Techniker bzw. Bewertung der Relevanz für FSM genutzt.", #X Wiki Mitarbeiter (alt Q)
|
||
"Wenn Wiki Branche nicht gepflegt ist, wird dieses Feld zur finalen Ermittlung der Branche im Ziel-Branchenschema genutzt und mit der CRM Branche bzw. CRM Beschreibung Branche Extern verglichen. Dabei muss aus dem Feld natürlich die Branche ermittelt werden, die auch hier nicht zwingend eingetragen ist. Stimmen alle drei Einstufungen grob überein, bestärkt dies die urpsrüngliche Einstufung. Laufen diese Branchen weit auseinander, soll, sofern der Wikipedia-Artikel verifiziert ist, die Branche von Wikipedia als zuverlässigste Quelle bewertet werden, danach folgen die CRM Beschreibung Branche Extern und die CRM Branche an dritter Stelle.", #Y Wiki Kategorien (alt R)
|
||
"System: Timestamp der letzten Wikipedia-Suche/Datenextraktion (für Spalten R-Y). Steuert Wiederholung.", #Z Wikipedia Timestamp
|
||
"System: Timestamp der letzten ChatGPT-Verifizierung des Wiki-Artikels (R). Steuert Wiederholung.", #AA Wiki Verif. Timestamp
|
||
"System: Timestamp des letzten Versuchs, eine fehlende Wiki-URL (R) via SerpAPI zu suchen. Steuert Wiederholung.", #AB SerpAPI Wiki Search Timestamp
|
||
"\"Es soll durch ChatGPT geprüft werden, ob anhand der vorliegenden Daten bestätigt werden kann, dass der Wikipedia-Eintrag sicher das Unternehmen beschreibt. Hierzu können sämtliche Daten miteinander verglichen werden. u.a. stimmt die Website überein, ist der Umsatz in einer ähnlichen größenordnung, passt die mitarbeiterzahl etc. Bei allen daten darf eine gewisse Unschärfe zum Vergleich (+-30%) gelten.\n Es muss teilweise etwas großzügig bewertet werden, insbesondere bei Konzernstrukturen, wo oft Töchter keinen eigenen Wikipedia-Artikel haben, aber quasi am Umsatz der Mutter hängen und damit prinzipiell die gleichen Daten zur Bewertung herangezogen werden können wie für die Mutter.\nAbweichungen sollen in der Spalte Chat Begründung Wiki Inkonsistenz begründet werden.\"", #AC Chat Wiki Konsistenzpruefung (alt S)
|
||
"\"Liegt eine Inkonsistenz zwischen gefundenem Wikipedia-Artikel und dem Unternehmen vor, soll dies hier kurz begründet werden.\nWurde der Artikel als unpassend identifiziert, soll Chat GPT selbst einen passenden wikipedia-Artikel zum Unternehmen finden und diesen in Spalte Chat Vorschlag Wiki Artikel ausgeben.\"", #AD Chat Begründung Wiki Inkonsistenz (alt T)
|
||
"\"Sollte durch die Wikipedia-Suche kein Artikel gefunden werden, oder der Artikel von Chat GPT als nicht zum Unternehmen passend identifiziert werden, soll Chat GPT eigenständig nach einem Wikipedia-Artikel recherchieren. Auch dieser soll die gleichen Stufen zur Qualitätsprüfung durchlaufen wie bei der Wikipedia-Suche üblich.\nDer von ChatGPT gefundene Artikel muss vom als falsch bewerteten Artikel abweichen. Wurde kein passender Artikel gefunden, soll 'kein Artikel verfügbar' ausgegeben werden\"", #AE Chat Vorschlag Wiki Artikel (alt U)
|
||
"XXX derzeit nicht verwendet, wird vermutlich gelöscht xxx", #AF Begründung bei Abweichung (alt V)
|
||
"Ziel: Rohtext der Website. Input für Zusammenfassung (AH).", #AG Website Rohtext
|
||
"Ziel: KI-generierte Zusammenfassung des Website-Rohtextes (AG). Input für Branchenbewertung.", #AH Website Zusammenfassung
|
||
"Ziel: Strukturierte Meta-Daten der Website. Für schnelle Analyse & Validierung.", #AI Website Meta-Details
|
||
"System: Timestamp des letzten Website-Scraping/Summarization-Versuchs (für AG-AI).", #AJ Website Scrape Timestamp
|
||
"System/Ziel: Status der URL-Prüfung. 'URL_CHECK_NEEDED' triggert Neusuche.", #AK URL Prüfstatus
|
||
"\"Chat GPT soll anhand der vorliegenden Informationen prüfen, welcher Branche des Ziel-Branchenschemas das Unternehmen am ehesten zugeordnet werden kann. Das Ziel-Branchenschema darf nicht verändert oder erweitert werden, sondern die Vorschläge müssen genau dem Ziel-Branchenschema entsprechen.\nDie Bewertung soll möglichst ohne Abgleich mit der CRM Branche bewertet werden, da diese falsch sein könnte. ChatGPT soll auch die Firmenwebsite und ähnliche Quellen zur Bewertung des Unernehmens heranziehen.\"", #AL Chat Vorschlag Branche (alt W)
|
||
"Ziel: Von ChatGPT eingeschätzte Konfidenz (Hoch/Mittel/Niedrig) für den Branchenvorschlag (AL).", #AM Chat Branche Konfidenz
|
||
"Die durch uns festgelegte Branche in Spalte CRM Branche soll mit der von ChatGPT ermittelten Branche in Spalte Chat Vorschlag Branche verglichen werden.", #AN Chat Konsistenz Branche (alt X)
|
||
"Weicht die Branche von unserer Eisntufung in Spalte CRM Branche ab, soll ChatGPT die Abweichung kurz begründen.", #AO Chat Begruendung Abweichung Branche (alt Y)
|
||
"Chat GPT soll anhand der vorliegenden Informationen sowie eigener Recherche prüfen, ob für das Unternehmen der Einsatz einer Fieldservice Management Lösung vorteilhaft ist. Sprich hat das Unternehmen mutmaßlich einen technischen Außendienst bzw. Disponenten die mit der Planung mobiler Resourcen beschäftigt sind.", #AP Chat Prüfung FSM Relevanz (alt Z)
|
||
"Die in Spalte Chat Begründung für FSM Relevanz soll begründet werden.", #AQ Chat Begründung für FSM Relevanz (alt AA)
|
||
"Nur wenn kein Wikipedia-Eintrag vorliegt (Wiki URL = \"\") soll ChatGPT auf Basis öffentlich verfügbarer Informationen z.B. durch Auswertung der Firmen-Website herausfinden oder schätzen, wieviele Mitarbeiter das Unternehmen hat. Wenn keine Schätzung möglich ist, soll \"keine Schätzung möglich\" ausgegeben werden.", #AR Chat Schaetzung Anzahl Mitarbeiter (alt AB)
|
||
"Entspricht die durch ChatGPT ermittelte Mitarbeiterzahl der von uns ermittelten (Spalte CRM Anzahl Mitarbeiter) bzw. der durch Wikipedia ermittelten Mitarbeiterzahl (Spalte Wiki Mitarbeiter). Begründung bei Abweichung über +-30% in Spalte Chat Begründung Abweichung Mitarbeiter", #AS Chat Konsistenzprüfung Mitarbeiterzahl (alt AC)
|
||
"Weicht die durch Chat GPT ermittelte Mitarbeiterzahl erheblich von der Anzahl der Mitarbeiter aus dem CRM (Spalte CRM Anzahl Mitarbeiter) bzw. der von Wikipedia ermittelten Anzahl (Spalte Wiki Mitarbeiter) ab, soll dies kurz begründet werden.", #AT Chat Begründung Abweichung Mitarbeiterzahl (alt AD)
|
||
"Chat GPT soll auf basis öffentlich zugänglicher Information eine Schätzung abgeben, wieviele Servicetechniker das Unternehmen hat. Hierzu können auch Querverbindungen zwischen Anzahl der Mitarbeiter, Umsatz, Branche hergestellt werden, um eine möglichst solide Schätzung abgeben zu können. Die Schätzung soll in den Abstufungen 0, <50 , >100, >200, >500 Techniker abgegeben werden. In Entwicklung ist eine Aggegierung von branchenspezifischen Merkmalen (z.B. Umsatz, Mitarbeiterzahl) die für jede Gruppe typisch ist. In weiterer Zukunft kann hierzu möglicherweise auf ein RAG-System (Retrieval-Augmented Generation) zurückgegriffen werden. Abweichungen der Einschätzung von der durch uns ermittelten Anzahl Servicetechniker (die relativ zuverlässig ist) sollen in Spalte Chat Begründung Abweichung Anzahl Servicetechniker ausgegeben werden. Wenn die Einschätzung zu einem ähnlichen Ergebnis kommt, soll \"OK\" ausgegeben werden.", #AU Chat Einschätzung Anzahl Servicetechniker (alt AE)
|
||
"Weicht die Einschätzung in Spalte Chat Einschätzung Anzahl Servicetechniker von den durch uns recherchierten Ergebnissen in Spalte CRM Anzahl Techniker ab, soll dies begründet werden.", #AV Chat Begründung Abweichung Anzahl Servicetechniker (alt AF)
|
||
"Nur wenn kein wikipedia-Eintrag vorliegt (Wiki URL = \"\") soll ChatGPT den Umsatz auf Basis seiner Daten oder der Unternehmenswebsite ermitteln. Wenn keine Schätzung möglich ist, soll \"keine Schätzung möglich\" ausgegeben werden.", #AW Chat Schätzung Umsatz (alt AG)
|
||
"ChatGPT soll erhebliche Abweichungen beim Umsatz zwischen Chat Schätzung Umsatz, Wiki Umsatz und CRM Umsatz begründen. Sind alle Umsätze einigermaßen deckungsgleich (+-30%) soll \"OK\" ausgegeben werden.", #AX Chat Begründung Abweichung Umsatz (alt AH)
|
||
"über SerpAPI wird gemeinsam mit der Kurzform des Unternehmensnamens (Spalte CRM Kurzform) und der folgenden Liste per ODER verknüpfung gesucht.\n- 'Serviceleiter'\n- 'Leiter Service'\n- 'technischer Leiter'\n- 'Service Manager'\n- 'Leiter Kundendienst'", #AY Linked Serviceleiter gefunden (alt AI)
|
||
"über SerpAPI wird gemeinsam mit der Kurzform des Unternehmensnamens (Spalte CRM Kurzform) und der folgenden Liste per ODER verknüpfung gesucht.\n- 'Leiter IT'\n- 'IT Leiter'\n- 'Head of IT'\n- 'IT-Leiter'\n- 'CIO'", #AZ Linked It-Leiter gefunden (alt AJ)
|
||
"über SerpAPI wird gemeinsam mit der Kurzform des Unternehmensnamens (Spalte CRM Kurzform) und der folgenden Liste per ODER verknüpfung gesucht.\n- 'Geschäftsführer'\n- 'Geschäftsführung'\n- 'GF'\n- 'CEO'\n- 'Geschäftsführerin'\n- 'Managing Director'\n- 'Geschäftsführender Gesellschafter'", #BA Linked Management gefunden (alt AK)
|
||
"über SerpAPI wird gemeinsam mit der Kurzform des Unternehmensnamens (Spalte CRM Kurzform) und der folgenden Liste per ODER verknüpfung gesucht.\n- 'Disponent'\n- 'Einsatzplaner'", #BB Linked Disponent gefunden (alt AL)
|
||
"System: Timestamp der letzten Kontaktsuche. Steuert Wiederholung.", #BC Contact Search Timestamp (alt AM)
|
||
"Ziel: Konsolidierter Umsatz (Mio. €), Wiki > CRM, berücksichtigt Parent. Input für ML/Plausi.", #BD Finaler Umsatz (Wiki>CRM)
|
||
"Ziel: Konsolidierte Mitarbeiterzahl, Wiki > CRM, berücksichtigt Parent. Input für ML/Plausi.", #BE Finaler Mitarbeiter (Wiki>CRM)
|
||
"Ziel: Vom ML-Modell vorhergesagter Techniker-Bucket.", #BF Geschaetzter Techniker Bucket
|
||
"Ziel: Plausi-Flag für finalen Umsatz (BD).", #BG Plausibilität Umsatz
|
||
"Ziel: Plausi-Flag für finale Mitarbeiterzahl (BE).", #BH Plausibilität Mitarbeiter
|
||
"Ziel: Plausi-Flag für Umsatz/MA-Ratio.", #BI Plausibilität Umsatz/MA Ratio
|
||
"Ziel: Datenqualitäts-Indikator Umsatz CRM vs. Wiki, berücksichtigt Parent.", #BJ Abweichung Umsatz CRM/Wiki
|
||
"Ziel: Datenqualitäts-Indikator MA CRM vs. Wiki, berücksichtigt Parent.", #BK Abweichung MA CRM/Wiki
|
||
"Ziel: Zusammenfassung Plausi-Probleme.", #BL Plausibilität Begründung
|
||
"System: Timestamp letzter Plausi-Check. Steuert Wiederholung.", #BM Plausibilität Prüfdatum
|
||
"Wenn die ChatGPT Bewertung gestartet wird, wird der erste Eintrag ohne Zeitstempel in dieser Spalte gesucht und die Bearbeitung ab hier forgesetzt. Zeilen die bereits einen Zeitstempel haben werden bei der Bearbeitung übersprungen", #BN Timestamp letzte Prüfung (alt AO)
|
||
"Wird durch das System befüllt", #BO Version (alt AP)
|
||
"Wird durch Ticktokens berechnet" #BP Tokens (alt AQ)
|
||
]
|
||
]
|
||
|
||
num_cols = len(new_headers[0])
|
||
if not all(len(row) == num_cols for row in new_headers):
|
||
logger.critical(f"FEHLER in alignment_demo: Die Anzahl der Spalten in den Header-Zeilen ist nicht konsistent! Erwartet {num_cols} Spalten pro Zeile, aber Längen sind: {[len(row) for row in new_headers]}.")
|
||
num_cols = max(len(row) for row in new_headers) if new_headers and any(row for row in new_headers) else 0
|
||
if num_cols == 0:
|
||
logger.error("FEHLER: Konnte keine Spaltenanzahl für Alignment-Demo Header bestimmen.")
|
||
return
|
||
|
||
def colnum_string(n):
|
||
string = ""
|
||
while n > 0:
|
||
n, remainder = divmod(n - 1, 26)
|
||
string = chr(65 + remainder) + string
|
||
return string
|
||
|
||
end_col_letter = colnum_string(num_cols) # Sollte "BQ" für 68 Spalten sein
|
||
header_range = f"A1:{end_col_letter}{len(new_headers)}"
|
||
|
||
logger.info(f"Schreibe Alignment-Demo Header in Bereich {header_range}...")
|
||
try:
|
||
sheet.update(values=new_headers, range_name=header_range, value_input_option='USER_ENTERED')
|
||
logger.info(f"Alignment-Demo Header erfolgreich geschrieben in Bereich {header_range}.")
|
||
except Exception as e:
|
||
logger.error(f"FEHLER beim Schreiben der Alignment-Demo Header in Bereich {header_range}: {e}")
|
||
logger.debug(traceback.format_exc())
|
||
|
||
|
||
# ==============================================================================
|
||
# Ende Alignment Demo Funktion Block
|
||
# ==============================================================================
|
||
|
||
# ==============================================================================
|
||
# 4. HANDLER CLASSES (Google Sheet & Wikipedia)
|
||
# ==============================================================================
|
||
|
||
# --- GOOGLE SHEET HANDLER CLASS ---
|
||
# Kapselt die Interaktionen mit dem Google Sheet.
|
||
# Nutzt globale Helfer: retry_on_failure, Config, CREDENTIALS_FILE, logger,
|
||
# gspread, ServiceAccountCredentials, os, datetime, time, traceback.
|
||
class GoogleSheetHandler:
|
||
"""
|
||
Kapselt die Interaktionen mit dem Google Sheet, inklusive Verbindung,
|
||
Daten laden und Batch-Updates. Nutzt den retry_on_failure Decorator.
|
||
"""
|
||
def __init__(self):
|
||
"""
|
||
Initialisiert den Handler, stellt die Verbindung her und laedt die Daten.
|
||
"""
|
||
# Holen Sie eine Logger-Instanz fuer diese Klasse
|
||
self.logger = logging.getLogger(__name__ + ".GoogleSheetHandler") # <<< HINZUGEFÜGT
|
||
# Initialisieren Sie die Attribute
|
||
self.sheet = None
|
||
# Daten werden hier als Instanzvariable gespeichert, um nicht bei jedem Zugriff neu laden zu muessen
|
||
self.sheet_values = []
|
||
# header_rows sind fix, aber wir koennen sie hier zur Klarheit definieren
|
||
self._header_rows = 5 # Annahme: Die ersten 5 Zeilen sind Header
|
||
|
||
self.logger.info("Initialisiere GoogleSheetHandler...") # <<< GEÄNDERT
|
||
try:
|
||
# Verbindung wird bei der Initialisierung aufgebaut
|
||
self._connect()
|
||
# Daten werden ebenfalls bei der Initialisierung geladen, nur wenn die Verbindung erfolgreich war
|
||
if self.sheet:
|
||
self.load_data() # Erste Datenladung nach erfolgreicher Verbindung
|
||
else:
|
||
# Wenn die Verbindung fehlschlug (sheet ist None), aber keine Exception geworfen wurde (sollte nicht passieren)
|
||
self.logger.critical( # <<< GEÄNDERT
|
||
"GoogleSheetHandler Init FEHLER: Verbindung konnte nicht hergestellt werden (sheet ist None)."
|
||
)
|
||
raise ConnectionError(
|
||
"Google Sheet Handler Init failed: Verbindung konnte nicht hergestellt werden."
|
||
)
|
||
except Exception as e:
|
||
# Fehler bei der Initialisierung (entweder von _connect oder load_data nach Retries)
|
||
self.logger.critical( # <<< GEÄNDERT
|
||
f"FATAL: Fehler bei Initialisierung von GoogleSheetHandler: {type(e).__name__} - {e}"
|
||
)
|
||
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
|
||
raise ConnectionError(f"Google Sheet Handler Init failed: {e}")
|
||
|
||
@retry_on_failure
|
||
def _connect(self):
|
||
"""Stellt Verbindung zum Google Sheet her."""
|
||
self.sheet = None
|
||
self.logger.info("Versuche Verbindung mit Google Sheets herstellen...") # <<< GEÄNDERT
|
||
try:
|
||
if not os.path.exists(CREDENTIALS_FILE):
|
||
raise FileNotFoundError(f"Credential-Datei nicht gefunden: {CREDENTIALS_FILE}")
|
||
|
||
scope = ["https://www.googleapis.com/auth/spreadsheets"]
|
||
creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope)
|
||
gc = gspread.authorize(creds)
|
||
sh = gc.open_by_url(Config.SHEET_URL)
|
||
self.sheet = sh.sheet1
|
||
self.logger.info("Verbindung zu Google Sheets erfolgreich.") # <<< GEÄNDERT
|
||
except (gspread.exceptions.APIError, requests.exceptions.RequestException, FileNotFoundError) as e:
|
||
raise e
|
||
except Exception as e:
|
||
self.logger.error(f"FEHLER bei der Google Sheets Verbindung: {type(e).__name__} - {e}") # <<< GEÄNDERT
|
||
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
|
||
raise e
|
||
|
||
@retry_on_failure
|
||
def load_data(self):
|
||
"""
|
||
Laedt alle Daten aus dem Sheet und aktualisiert self.sheet_values.
|
||
"""
|
||
if not self.sheet:
|
||
self.logger.error("Fehler: Keine Sheet-Verbindung zum Laden der Daten.") # <<< GEÄNDERT
|
||
self.sheet_values = []
|
||
return False
|
||
|
||
self.logger.info("Lade Daten aus Google Sheet...") # <<< GEÄNDERT
|
||
try:
|
||
self.sheet_values = self.sheet.get_all_values()
|
||
|
||
if not self.sheet_values:
|
||
self.logger.warning( # <<< GEÄNDERT
|
||
"Google Sheet scheint leer zu sein oder get_all_values() lieferte keine Daten."
|
||
)
|
||
self.headers = []
|
||
return True
|
||
|
||
num_rows = len(self.sheet_values)
|
||
num_cols = len(self.sheet_values[0]) if num_rows > 0 else 0
|
||
self.logger.info(f"Daten neu geladen: {num_rows} Zeilen, {num_cols} Spalten.") # <<< GEÄNDERT
|
||
|
||
try:
|
||
max_col_idx_in_map = max(COLUMN_MAP.values())
|
||
if num_cols <= max_col_idx_in_map:
|
||
self.logger.warning( # <<< GEÄNDERT
|
||
f"Geladenes Sheet hat {num_cols} Spalten, erwartet werden aber mindestens "
|
||
f"{max_col_idx_in_map + 1} basierend auf COLUMN_MAP."
|
||
)
|
||
except ValueError:
|
||
self.logger.warning( # <<< GEÄNDERT
|
||
"COLUMN_MAP scheint leer zu sein oder enthaelt keine Werte. Kann Spaltenanzahl nicht pruefen."
|
||
)
|
||
except Exception as e:
|
||
self.logger.error(f"Fehler bei der Pruefung der Spaltenanzahl gegen COLUMN_MAP: {e}") # <<< GEÄNDERT
|
||
|
||
if num_rows > 0:
|
||
self.headers = self.sheet_values[0]
|
||
else:
|
||
self.headers = []
|
||
|
||
return True
|
||
except (gspread.exceptions.APIError, requests.exceptions.RequestException) as e:
|
||
raise e
|
||
except Exception as e:
|
||
self.logger.error( # <<< GEÄNDERT
|
||
f"Allgemeiner Fehler beim Laden der Google Sheet Daten: {type(e).__name__} - {e}"
|
||
)
|
||
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
|
||
raise e
|
||
|
||
def get_data(self):
|
||
"""
|
||
Gibt die aktuell im Handler gespeicherten Datenzeilen zurueck
|
||
(ohne die ersten N Header-Zeilen).
|
||
"""
|
||
if not self.sheet_values or len(self.sheet_values) <= self._header_rows:
|
||
self.logger.debug( # <<< GEÄNDERT
|
||
f"get_data: Keine Datenzeilen verfuegbar "
|
||
f"(geladen: {len(self.sheet_values) if self.sheet_values else 0} Zeilen, "
|
||
f"{self._header_rows} Header)."
|
||
)
|
||
return []
|
||
return self.sheet_values[self._header_rows:].copy()
|
||
|
||
def get_all_data_with_headers(self):
|
||
"""Gibt alle aktuell im Handler gespeicherten Daten inklusive Header zurueck."""
|
||
if not self.sheet_values:
|
||
self.logger.debug("get_all_data_with_headers: Keine Daten im Handler gespeichert.") # <<< GEÄNDERT
|
||
return []
|
||
return self.sheet_values.copy()
|
||
|
||
def _get_col_letter(self, col_idx_1_based):
|
||
"""
|
||
Konvertiert einen 1-basierten Spaltenindex in den entsprechenden
|
||
Google Sheets Spaltenbuchstaben (A, B, ..., Z, AA, ...).
|
||
"""
|
||
if not isinstance(col_idx_1_based, int) or col_idx_1_based < 1:
|
||
self.logger.error( # <<< GEÄNDERT
|
||
f"Ungueltiger Spaltenindex ({col_idx_1_based}) fuer _get_col_letter erhalten."
|
||
)
|
||
return None
|
||
|
||
string = ""
|
||
n = col_idx_1_based
|
||
while n > 0:
|
||
n, remainder = divmod(n - 1, 26)
|
||
string = chr(65 + remainder) + string
|
||
return string
|
||
|
||
def get_start_row_index(self, check_column_key, min_sheet_row=7):
|
||
"""
|
||
Findet den 0-basierten Index in der DATENliste (ohne Header),
|
||
ab einer Mindestzeilennummer im Sheet, in der der Wert in der
|
||
Spalte (definiert durch check_column_key) EXAKT LEER ("") ist.
|
||
Laedt die Daten vor der Pruefung neu.
|
||
|
||
Args:
|
||
check_column_key (str): Der Schluessel in COLUMN_MAP fuer die zu pruefende Spalte.
|
||
min_sheet_row (int): Die 1-basierte Zeilennummer im Sheet, ab der gesucht werden soll.
|
||
Standardmaessig ab Zeile 7 (erste Zeile nach 5 Headern und einer leeren).
|
||
|
||
Returns:
|
||
int: Der 0-basierten Index in der Datenliste (ohne Header) der ersten Zeile
|
||
mit einem EXAKT leeren Wert in der Zielspalte innerhalb des Suchbereichs.
|
||
Gibt -1 zurueck bei schwerwiegenden Fehlern (z.B. Schluessel fehlt in COLUMN_MAP).
|
||
Gibt die Laenge der Datenliste zurueck, wenn keine leere Zelle im Suchbereich gefunden wurde.
|
||
"""
|
||
if not self.load_data():
|
||
self.logger.error("Fehler beim Laden der Daten fuer get_start_row_index.") # <<< GEÄNDERT
|
||
return -1
|
||
|
||
data_rows = self.get_data()
|
||
if not data_rows:
|
||
self.logger.info("Keine Datenzeilen im Sheet gefunden. Startindex fuer leere Zelle ist 0.") # <<< GEÄNDERT
|
||
return 0
|
||
|
||
check_column_index = COLUMN_MAP.get(check_column_key)
|
||
if check_column_index is None:
|
||
self.logger.critical( # <<< GEÄNDERT
|
||
f"FEHLER: Schluessel '{check_column_key}' nicht in COLUMN_MAP gefunden fuer get_start_row_index!"
|
||
)
|
||
return -1
|
||
|
||
actual_col_letter = self._get_col_letter(check_column_index + 1)
|
||
if actual_col_letter is None:
|
||
self.logger.error( # <<< GEÄNDERT
|
||
f"FEHLER: Konnte Spaltenbuchstaben fuer Index {check_column_index + 1} nicht ermitteln."
|
||
)
|
||
actual_col_letter = f"Index_{check_column_index + 1}"
|
||
|
||
search_start_index_in_data = max(0, (min_sheet_row - 1) - self._header_rows)
|
||
|
||
self.logger.info( # <<< GEÄNDERT
|
||
f"get_start_row_index: Suche ab Daten-Index {search_start_index_in_data} "
|
||
f"(Sheet-Zeile {search_start_index_in_data + self._header_rows + 1}) "
|
||
f"nach EXAKT LEEREM Wert (=='') in Spalte '{check_column_key}' ({actual_col_letter})..."
|
||
)
|
||
|
||
if search_start_index_in_data >= len(data_rows):
|
||
self.logger.warning( # <<< GEÄNDERT
|
||
f"Start-Suchindex in Daten ({search_start_index_in_data}) liegt hinter der letzten Datenzeile ({len(data_rows)}). Keine leere Zelle gefunden im Suchbereich."
|
||
)
|
||
return len(data_rows)
|
||
|
||
for i in range(search_start_index_in_data, len(data_rows)):
|
||
row = data_rows[i]
|
||
current_sheet_row = i + self._header_rows + 1
|
||
|
||
cell_value = ""
|
||
is_exactly_empty = True
|
||
if len(row) > check_column_index:
|
||
cell_value = row[check_column_index]
|
||
if cell_value != "":
|
||
is_exactly_empty = False
|
||
else:
|
||
is_exactly_empty = True
|
||
|
||
log_debug = (i < search_start_index_in_data + 5) or (i % 1000 == 0) or is_exactly_empty
|
||
if log_debug:
|
||
self.logger.debug( # <<< GEÄNDERT
|
||
f" -> Pruefe Daten-Index {i} (Sheet {current_sheet_row}): "
|
||
f"Wert in {actual_col_letter}='{str(cell_value).strip()}' "
|
||
f"(Roh='{cell_value}' Typ: {type(cell_value)}). Leer? {is_exactly_empty}"
|
||
)
|
||
|
||
if is_exactly_empty:
|
||
self.logger.info( # <<< GEÄNDERT
|
||
f"Erste Zeile ab Sheet-Zeile {min_sheet_row} mit EXAKT LEEREM Wert in Spalte "
|
||
f"{actual_col_letter} gefunden: Zeile {current_sheet_row} (Daten-Index {i})"
|
||
)
|
||
return i
|
||
|
||
last_data_index = len(data_rows)
|
||
self.logger.info( # <<< GEÄNDERT
|
||
f"Alle Zeilen ab Daten-Index {search_start_index_in_data} im Suchbereich haben einen "
|
||
f"nicht-leeren Wert in Spalte {actual_col_letter}. Naechster Daten-Index waere {last_data_index}."
|
||
)
|
||
return last_data_index
|
||
|
||
@retry_on_failure
|
||
def batch_update_cells(self, update_data):
|
||
"""
|
||
Fuehrt ein Batch-Update im Google Sheet durch. Beinhaltet robustere
|
||
Fehlerbehandlung.
|
||
|
||
Args:
|
||
update_data (list): Eine Liste von Dictionaries, jedes mit 'range' (str)
|
||
und 'values' (list of lists).
|
||
z.B. [{'range': 'A1', 'values': [['Wert']]}, ...]
|
||
|
||
Returns:
|
||
bool: True bei Erfolg (nach allen Retries), False bei endgueltigem Fehler.
|
||
"""
|
||
if not self.sheet:
|
||
self.logger.error("FEHLER: Keine Sheet-Verbindung fuer Batch-Update.") # <<< GEÄNDERT
|
||
return False
|
||
|
||
if not update_data:
|
||
return True
|
||
|
||
try:
|
||
total_cells_to_update = sum(
|
||
len(row) for item in update_data for row in item.get('values', [])
|
||
)
|
||
self.logger.debug( # <<< GEÄNDERT
|
||
f" -> Versuche sheet.batch_update mit {len(update_data)} Anfragen "
|
||
f"({total_cells_to_update} Zellen)..."
|
||
)
|
||
self.sheet.batch_update(update_data, value_input_option='USER_ENTERED')
|
||
return True
|
||
except Exception:
|
||
self.logger.error( # <<< GEÄNDERT
|
||
f"Endgueltiger Fehler beim Batch-Update nach Retries. Kann {len(update_data)} Operationen nicht durchfuehren."
|
||
)
|
||
return False
|
||
|
||
|
||
# --- WIKIPEDIA SCRAPER CLASS ---
|
||
# Handhabt das Suchen und Extrahieren von Daten aus Wikipedia.
|
||
# Nutzt globale Helfer: wikipedia, requests, BeautifulSoup, Config, getattr, logger,
|
||
# retry_on_failure, simple_normalize_url, normalize_company_name,
|
||
# extract_numeric_value, clean_text, unquote, time, traceback.
|
||
class WikipediaScraper:
|
||
"""
|
||
Handhabt das Suchen von Wikipedia-Artikeln und das Extrahieren relevanter
|
||
Unternehmensdaten. Beinhaltet Validierungslogik fuer Artikel.
|
||
Nutzt die wikipedia-Bibliothek und Requests fuer direktes HTML-Scraping.
|
||
"""
|
||
def __init__(self, user_agent=None):
|
||
"""
|
||
Initialisiert den Scraper mit einer Requests-Session und konfigurierter
|
||
Wikipedia-Bibliothek.
|
||
|
||
Args:
|
||
user_agent (str, optional): Der User-Agent fuer Requests.
|
||
Defaults to Config.USER_AGENT.
|
||
"""
|
||
# Erhalten Sie eine Logger-Instanz fuer diese Klasse
|
||
self.logger = logging.getLogger(__name__ + ".WikipediaScraper") # <<< HINZUGEFÜGT
|
||
self.logger.debug("WikipediaScraper initialisiert.") # <<< GEÄNDERT
|
||
|
||
# User-Agent fuer Requests (nutzt Config, Fallback wenn nicht gesetzt)
|
||
self.user_agent = user_agent or getattr(
|
||
Config, 'USER_AGENT',
|
||
'Mozilla/5.0 (compatible; UnternehmenSkript/1.0; +http://www.example.com/bot)'
|
||
)
|
||
self.session = requests.Session()
|
||
self.session.headers.update({'User-Agent': self.user_agent})
|
||
self.logger.debug(f"Requests Session mit User-Agent '{self.user_agent}' initialisiert.") # <<< GEÄNDERT
|
||
|
||
# Keywords fuer die Infobox-Extraktion
|
||
self.keywords_map = {
|
||
'branche': ['branche', 'wirtschaftszweig', 'industry', 'taetigkeit', 'sektor', 'produkte', 'leistungen'],
|
||
'umsatz': ['umsatz', 'erloes', 'revenue', 'jahresumsatz', 'konzernumsatz', 'ergebnis'],
|
||
'mitarbeiter': ['mitarbeiter', 'mitarbeiterzahl', 'beschaeftigte', 'employees', 'number of employees', 'personal', 'belegschaft'],
|
||
'sitz': ['sitz', 'hauptsitz', 'unternehmenssitz', 'firmensitz', 'headquarters', 'standort', 'sitz des unternehmens', 'anschrift', 'adresse'] # <<< NEU / ERWEITERT
|
||
}
|
||
|
||
# Konfiguriere die wikipedia-Bibliothek
|
||
try:
|
||
wiki_lang = getattr(Config, 'LANG', 'de')
|
||
wikipedia.set_lang(wiki_lang)
|
||
# wikipedia.set_rate_limiting(True, min_wait=0.1) # <<< ALTE ZEILE (auskommentieren oder löschen)
|
||
wikipedia.set_rate_limiting(False) # <<< NEUE ZEILE: Ratenbegrenzung deaktivieren
|
||
self.logger.info( # <<< GEÄNDERT
|
||
f"Wikipedia library language set to '{wiki_lang}'. Rate limiting DISABLED." # <<< KORRIGIERTE LOG-NACHRICHT
|
||
)
|
||
except Exception as e:
|
||
self.logger.warning(f"Fehler beim Setzen der Wikipedia-Sprache oder Rate Limiting: {e}") # <<< GEÄNDERT
|
||
|
||
def _get_full_domain(self, website):
|
||
"""Extrahiert die normalisierte Domain (ohne www, ohne Pfad) aus einer URL."""
|
||
return simple_normalize_url(website)
|
||
|
||
def _generate_search_terms(self, company_name, website):
|
||
"""
|
||
Generiert eine Liste von Suchbegriffen fuer die Wikipedia-Suche,
|
||
inklusive normalisiertem Namen, Kurzformen und Domain.
|
||
"""
|
||
if not company_name:
|
||
return []
|
||
terms = set()
|
||
|
||
original_name_cleaned = str(company_name).strip()
|
||
if original_name_cleaned:
|
||
terms.add(original_name_cleaned)
|
||
|
||
normalized_name = normalize_company_name(company_name)
|
||
if normalized_name:
|
||
terms.add(normalized_name)
|
||
name_parts = normalized_name.split()
|
||
if len(name_parts) > 0:
|
||
terms.add(name_parts[0])
|
||
if len(name_parts) > 1:
|
||
terms.add(" ".join(name_parts[:2]))
|
||
|
||
full_domain = self._get_full_domain(website)
|
||
if full_domain != "k.A.":
|
||
terms.add(full_domain)
|
||
|
||
final_terms = [term for term in list(terms) if term][:getattr(Config, 'WIKIPEDIA_SEARCH_RESULTS', 5)]
|
||
self.logger.debug(f"Generierte Suchbegriffe fuer '{company_name[:100]}...': {final_terms}") # <<< GEÄNDERT
|
||
return final_terms
|
||
|
||
@retry_on_failure
|
||
def _get_page_soup(self, url):
|
||
"""
|
||
Holt HTML von einer URL (requests) und gibt ein BeautifulSoup-Objekt zurueck.
|
||
"""
|
||
if not url or not isinstance(url, str) or not url.lower().startswith(("http://", "https://")):
|
||
self.logger.warning(f"_get_page_soup: Ungueltige URL '{url[:100]}...'.") # <<< GEÄNDERT
|
||
return None
|
||
try:
|
||
self.logger.debug(f"_get_page_soup: Rufe URL ab: {url[:100]}...") # <<< GEÄNDERT
|
||
response = self.session.get(url, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15))
|
||
response.raise_for_status()
|
||
response.encoding = response.apparent_encoding
|
||
soup = BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser'))
|
||
self.logger.debug(f"_get_page_soup: Parsen von {url[:100]}... erfolgreich.") # <<< GEÄNDERT
|
||
return soup
|
||
except Exception as e:
|
||
self.logger.error(f"_get_page_soup: Fehler beim Abrufen oder Parsen von HTML von {url[:100]}...: {type(e).__name__} - {e}") # <<< GEÄNDERT
|
||
raise e
|
||
|
||
def _validate_article(self, page, company_name, website):
|
||
"""
|
||
Validiert, ob ein Wikipedia-Artikel (represented by the Page object)
|
||
zum Unternehmen passt.
|
||
"""
|
||
if not page or not company_name:
|
||
return False
|
||
self.logger.debug( # <<< GEÄNDERT
|
||
f"Validiere Artikel '{page.title[:100]}...' (URL: {page.url[:100]}...) "
|
||
f"fuer Firma '{company_name[:100]}' (Website: {website[:100]})..."
|
||
)
|
||
|
||
normalized_company = normalize_company_name(company_name)
|
||
normalized_title = normalize_company_name(page.title)
|
||
if not normalized_company or not normalized_title:
|
||
self.logger.warning("Validierung nicht moeglich, da Normalisierung eines Namens fehlschlug.") # <<< GEÄNDERT
|
||
return False
|
||
|
||
standard_threshold = getattr(Config, 'SIMILARITY_THRESHOLD', 0.65)
|
||
similarity = fuzzy_similarity(normalized_title, normalized_company)
|
||
self.logger.debug(f" -> Gesamt-Aehnlichkeit (normalized): {similarity:.2f}") # <<< GEÄNDERT
|
||
|
||
company_tokens = normalized_company.split()
|
||
title_tokens = normalized_title.split()
|
||
first_word_match = False
|
||
first_two_words_match = False
|
||
if company_tokens and title_tokens and company_tokens[0] == title_tokens[0]:
|
||
first_word_match = True
|
||
if len(company_tokens) > 1 and len(title_tokens) > 1 and company_tokens[1] == title_tokens[1]:
|
||
first_two_words_match = True
|
||
|
||
domain_found = False
|
||
full_domain = self._get_full_domain(website)
|
||
if full_domain != "k.A.":
|
||
self.logger.debug(f" -> Suche nach Domain '{full_domain}' in externen Links des Artikels...") # <<< GEÄNDERT
|
||
try:
|
||
article_html = page.html()
|
||
if article_html:
|
||
soup = BeautifulSoup(article_html, getattr(Config, 'HTML_PARSER', 'html.parser'))
|
||
external_links = soup.select('a[href^="http"]')
|
||
relevant_links = []
|
||
for link_tag in external_links:
|
||
href = link_tag.get('href', '')
|
||
if href and isinstance(href, str) and full_domain in simple_normalize_url(href):
|
||
if not any(ex in href.lower() for ex in ['wikipedia.org', 'wikimedia.org', 'wikidata.org', 'archive.org', 'webcitation.org']):
|
||
relevant_links.append(link_tag)
|
||
if relevant_links:
|
||
domain_found = True
|
||
except KeyError as e_key:
|
||
if 'extlinks' in str(e_key).lower(): # Spezifisch für diesen häufigen Fehler
|
||
self.logger.warning(f"KeyError ('{e_key}') beim Zugriff auf page.html() oder Parsing für Domain-Check bei Artikel '{page.title[:100]}...'. Domain-Validierung übersprungen.")
|
||
domain_found = False
|
||
else:
|
||
self.logger.error(f"Unerwarteter KeyError bei Domain-Prüfung für '{page.title[:100]}...': {e_key}")
|
||
domain_found = False # Im Zweifel als nicht gefunden werten
|
||
# Überlegen, ob hier nicht `raise e_key` besser wäre, wenn es nicht 'extlinks' ist.
|
||
except Exception as e_link_check:
|
||
self.logger.error(
|
||
f"Allgemeiner Fehler waehrend der Domain-Link-Pruefung fuer '{page.title[:100]}...': "
|
||
f"{type(e_link_check).__name__} - {e_link_check}"
|
||
)
|
||
domain_found = False
|
||
|
||
|
||
# Dynamische Schwellenwert-Entscheidung
|
||
is_valid = False
|
||
reason = "Keine Validierungsregel traf zu"
|
||
if similarity >= standard_threshold:
|
||
is_valid = True
|
||
reason = f"Gesamt-Aehnlichkeit ({similarity:.2f}) >= Standard-Schwelle ({standard_threshold:.2f})"
|
||
elif domain_found and first_two_words_match:
|
||
is_valid = True
|
||
reason = f"Domain gefunden UND erste 2 normalisierte Worte stimmen ueberein (Sim={similarity:.2f})"
|
||
elif domain_found and first_word_match and similarity >= 0.40:
|
||
is_valid = True
|
||
reason = f"Domain gefunden UND erstes normalisiertes Wort stimmt ueberein UND Aehnlichkeit >= 0.40 (Sim={similarity:.2f})"
|
||
elif first_two_words_match and similarity >= 0.45:
|
||
is_valid = True
|
||
reason = f"Erste zwei normalisierte Worte stimmen ueberein UND Aehnlichkeit >= 0.45 (Sim={similarity:.2f})"
|
||
elif domain_found and similarity >= 0.50:
|
||
is_valid = True
|
||
reason = f"Domain gefunden UND Aehnlichkeit >= 0.50 (Sim={similarity:.2f})"
|
||
elif first_word_match and similarity >= 0.55:
|
||
is_valid = True
|
||
reason = f"Erstes normalisiertes Wort stimmt ueberein UND Aehnlichkeit >= 0.55 (Sim={similarity:.2f})"
|
||
|
||
log_level = logging.INFO if is_valid else logging.DEBUG
|
||
self.logger.log( # <<< GEÄNDERT
|
||
log_level,
|
||
f" => Artikel '{page.title[:100]}...' "
|
||
f"{'VALIDIERT' if is_valid else 'NICHT validiert'} "
|
||
f"(Grund: {reason}. Details: Sim={similarity:.2f}, Domain? {domain_found}, "
|
||
f"1stWord? {first_word_match}, 2ndWord? {first_two_words_match})"
|
||
)
|
||
return is_valid
|
||
|
||
def _extract_first_paragraph_from_soup(self, soup):
|
||
"""
|
||
Extrahiert den ersten aussagekraeftigen Absatz aus dem Soup-Objekt eines Wikipedia-Artikels.
|
||
Entfernt Referenzen und unerwuenschte Elemente.
|
||
"""
|
||
if not soup:
|
||
return "k.A."
|
||
paragraph_text = "k.A."
|
||
try:
|
||
content_div = soup.find('div', class_='mw-parser-output')
|
||
search_area = content_div if content_div else soup
|
||
|
||
paragraphs = search_area.find_all('p', recursive=False)
|
||
if not paragraphs:
|
||
paragraphs = search_area.find_all('p')
|
||
|
||
for p in paragraphs:
|
||
for sup in p.find_all('sup', class_='reference'):
|
||
sup.decompose()
|
||
for span in p.find_all('span', style=lambda v: v and 'display:none' in v):
|
||
span.decompose()
|
||
for span in p.find_all('span', id='coordinates'):
|
||
span.decompose()
|
||
|
||
text = clean_text(p.get_text(separator=' ', strip=True))
|
||
if text != "k.A." and len(text) > 50:
|
||
if not re.match(
|
||
r'^(Datei:|Abbildung:|Siehe auch:|Einzelnachweise|Siehe auch|Literatur)',
|
||
text,
|
||
re.IGNORECASE
|
||
):
|
||
paragraph_text = text[:1500]
|
||
break
|
||
|
||
if paragraph_text == "k.A.":
|
||
self.logger.debug("Kein passender erster Absatz gefunden nach Pruefung der <p>-Tags.") # <<< GEÄNDERT
|
||
except Exception as e:
|
||
self.logger.error(f"Fehler beim Extrahieren des ersten Absatzes: {type(e).__name__} - {e}") # <<< GEÄNDERT
|
||
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
|
||
return paragraph_text
|
||
|
||
def extract_categories(self, soup):
|
||
"""
|
||
Extrahiert Wikipedia-Kategorien aus dem Soup-Objekt.
|
||
"""
|
||
if not soup:
|
||
return "k.A."
|
||
cats_filtered = []
|
||
try:
|
||
cat_div = soup.find('div', id="mw-normal-catlinks")
|
||
if cat_div:
|
||
ul = cat_div.find('ul')
|
||
if ul:
|
||
cats = [clean_text(li.get_text()) for li in ul.find_all('li')]
|
||
cats_filtered = [
|
||
c for c in cats
|
||
if c and isinstance(c, str) and c.strip() and "kategorien:" not in c.lower()
|
||
]
|
||
self.logger.debug(f"Kategorien gefunden: {cats_filtered}") # <<< GEÄNDERT
|
||
else:
|
||
self.logger.debug("Kein 'ul' Tag in 'mw-normal-catlinks' gefunden.") # <<< GEÄNDERT
|
||
else:
|
||
self.logger.debug("Kein 'div#mw-normal-catlinks' gefunden.") # <<< GEÄNDERT
|
||
except Exception as e:
|
||
self.logger.error(f"Fehler beim Extrahieren der Kategorien: {type(e).__name__} - {e}") # <<< GEÄNDERT
|
||
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
|
||
return ", ".join(cats_filtered) if cats_filtered else "k.A."
|
||
|
||
def _extract_infobox_value(self, soup, target):
|
||
"""
|
||
Extrahiert gezielt Branche, Umsatz oder Mitarbeiter aus der Infobox
|
||
eines Wikipedia-Artikels Soup-Objekts.
|
||
"""
|
||
self.logger.debug(f"--- Entering _extract_infobox_value for target '{target}' ---") # <<< GEÄNDERT
|
||
if not soup or target not in self.keywords_map:
|
||
self.logger.debug( # <<< GEÄNDERT
|
||
f"_extract_infobox_value: Ungueltiger Input (Soup: {soup is not None}, Target: {target})"
|
||
)
|
||
return "k.A."
|
||
keywords = self.keywords_map[target]
|
||
self.logger.debug(f"_extract_infobox_value: Suche nach '{target}' mit Keywords: {keywords}") # <<< GEÄNDERT
|
||
|
||
infobox = soup.select_one('table[class*="infobox"]')
|
||
if not infobox:
|
||
self.logger.debug(" -> KEINE Infobox via select_one 'table[class*=\"infobox\"]' gefunden.") # <<< GEÄNDERT
|
||
return "k.A."
|
||
self.logger.debug(" -> Infobox gefunden.") # <<< GEÄNDERT
|
||
|
||
value_found = "k.A."
|
||
try:
|
||
rows = infobox.find_all('tr')
|
||
for idx, row in enumerate(rows):
|
||
cells = row.find_all(['th', 'td'], recursive=False)
|
||
header_text = None
|
||
value_cell = None
|
||
|
||
if len(cells) >= 2 and cells[0].name == 'th' and cells[1].name == 'td':
|
||
header_text = cells[0].get_text(strip=True)
|
||
value_cell = cells[1]
|
||
elif len(cells) >= 2 and cells[0].name == 'td' and cells[1].name == 'td':
|
||
style = cells[0].get('style', '').lower()
|
||
first_cell_is_header_like = False
|
||
if 'font-weight' in style and ('bold' in style or '700' in style):
|
||
first_cell_is_header_like = True
|
||
elif cells[0].find(['b', 'strong'], recursive=False):
|
||
first_cell_is_header_like = True
|
||
if first_cell_is_header_like:
|
||
header_text = cells[0].get_text(strip=True)
|
||
value_cell = cells[1]
|
||
|
||
if header_text is not None and value_cell is not None:
|
||
header_text_lower = header_text.lower()
|
||
matched_keyword = None
|
||
for kw in keywords:
|
||
if kw in header_text_lower:
|
||
matched_keyword = kw
|
||
break
|
||
if matched_keyword:
|
||
for sup in value_cell.find_all(['sup', 'span']):
|
||
if (
|
||
(sup.name == 'sup' and sup.has_attr('class') and 'reference' in sup['class'])
|
||
or (sup.name == 'span' and sup.get('style') and 'display:none' in sup['style'])
|
||
):
|
||
sup.decompose()
|
||
|
||
raw_value_text = value_cell.get_text(separator=' ', strip=True)
|
||
|
||
if target == 'branche':
|
||
clean_val = clean_text(raw_value_text)
|
||
clean_val = re.sub(r'\s*\([^)]*\)', '', clean_val).strip()
|
||
clean_val = clean_val.split('\n')[0].strip()
|
||
value_found = clean_val if clean_val else "k.A."
|
||
self.logger.info(f" --> Branche extrahiert: '{value_found}'") # <<< GEÄNDERT
|
||
elif target == 'umsatz':
|
||
numeric_val_str = extract_numeric_value(raw_value_text, is_umsatz=True)
|
||
value_found = numeric_val_str
|
||
self.logger.info( # <<< GEÄNDERT
|
||
f" --> Umsatz extrahiert (aus '{raw_value_text[:50]}...'): '{value_found}'"
|
||
)
|
||
elif target == 'mitarbeiter':
|
||
numeric_val_str = extract_numeric_value(raw_value_text, is_umsatz=False)
|
||
value_found = numeric_val_str
|
||
self.logger.info( # <<< GEÄNDERT
|
||
f" --> Mitarbeiter extrahiert (aus '{raw_value_text[:50]}...'): '{value_found}'"
|
||
)
|
||
elif target == 'sitz': # Wir nennen es intern 'sitz', um den ganzen String zu bekommen
|
||
# Logik zum Extrahieren des gesamten Sitz-Strings (weitgehend wie zuvor für Branche)
|
||
for sup in value_cell.find_all(['sup', 'span']):
|
||
if ( (sup.name == 'sup' and sup.has_attr('class') and 'reference' in sup['class']) or
|
||
(sup.name == 'span' and sup.get('style') and 'display:none' in sup['style']) ):
|
||
sup.decompose()
|
||
|
||
raw_sitz_text = value_cell.get_text(separator=' ', strip=True)
|
||
cleaned_sitz_text = clean_text(raw_sitz_text) # clean_text anwenden
|
||
|
||
# Manchmal gibt es mehrere Zeilen, nur die erste nehmen oder intelligent verbinden
|
||
cleaned_sitz_text = cleaned_sitz_text.split('\n')[0].strip()
|
||
|
||
value_found = cleaned_sitz_text if cleaned_sitz_text else "k.A."
|
||
self.logger.info(f" --> Roher Sitz-Text extrahiert (aus '{raw_value_text[:50]}...'): '{value_found}'")
|
||
# Die Aufteilung in Stadt und Land erfolgt in extract_company_data
|
||
break
|
||
|
||
if value_found != "k.A.":
|
||
self.logger.debug(f" -> Finaler Wert fuer '{target}' gefunden: '{value_found}'") # <<< GEÄNDERT
|
||
else:
|
||
self.logger.debug( # <<< GEÄNDERT
|
||
f" -> Kein passender Eintrag fuer '{target}' in der gesamten Infobox gefunden."
|
||
)
|
||
except Exception as e:
|
||
self.logger.exception( # <<< GEÄNDERT
|
||
f"Fehler beim Durchlaufen der Infobox-Zeilen fuer '{target}': {e}"
|
||
)
|
||
return "k.A. (Fehler Extraktion)"
|
||
|
||
return value_found
|
||
|
||
def _parse_sitz_string_detailed(self, raw_sitz_string_input):
|
||
"""
|
||
Versucht, aus einem rohen Sitz-String Stadt und Land detailliert zu extrahieren.
|
||
Nutzt erweiterte Länderlisten und Heuristiken.
|
||
|
||
Args:
|
||
raw_sitz_string_input (str): Der zu parsende String.
|
||
|
||
Returns:
|
||
dict: {'sitz_stadt': '...', 'sitz_land': '...'}
|
||
"""
|
||
sitz_stadt_val = "k.A."
|
||
sitz_land_val = "k.A."
|
||
|
||
if not raw_sitz_string_input or not isinstance(raw_sitz_string_input, str):
|
||
return {'sitz_stadt': sitz_stadt_val, 'sitz_land': sitz_land_val}
|
||
|
||
temp_sitz = raw_sitz_string_input.strip()
|
||
if not temp_sitz or temp_sitz.lower() == "k.a.":
|
||
return {'sitz_stadt': sitz_stadt_val, 'sitz_land': sitz_land_val}
|
||
|
||
# --- Definitionen (könnten für Performance auch Klassenattribute sein) ---
|
||
known_countries_detailed = {
|
||
"deutschland": "Deutschland", "germany": "Deutschland", "de": "Deutschland", "brd": "Deutschland", "d-": "Deutschland",
|
||
"österreich": "Österreich", "austria": "Österreich", "at": "Österreich", "a-": "Österreich",
|
||
"schweiz": "Schweiz", "switzerland": "Schweiz", "ch": "Schweiz", "suisse": "Schweiz", "svizzera": "Schweiz", "ch-": "Schweiz",
|
||
"usa": "USA", "u.s.": "USA", "u.s.a.": "USA", "united states": "USA", "vereinigte staaten": "USA",
|
||
"vereinigtes königreich": "Vereinigtes Königreich", "united kingdom": "Vereinigtes Königreich", "uk": "Vereinigtes Königreich", "gb": "Vereinigtes Königreich", "england": "Vereinigtes Königreich",
|
||
"frankreich": "Frankreich", "france": "Frankreich", "fr": "Frankreich", "f-": "Frankreich",
|
||
"niederlande": "Niederlande", "netherlands": "Niederlande", "nl": "Niederlande", "holland": "Niederlande",
|
||
"belgien": "Belgien", "belgium": "Belgien", "be": "Belgien",
|
||
"luxemburg": "Luxemburg", "luxembourg": "Luxemburg", "lu": "Luxemburg",
|
||
"italien": "Italien", "italy": "Italien", "it": "Italien", "i-": "Italien",
|
||
"spanien": "Spanien", "spain": "Spanien", "es": "Spanien", "españa": "Spanien",
|
||
"polen": "Polen", "poland": "Polen", "pl": "Polen",
|
||
"japan": "Japan", "jp": "Japan",
|
||
"kanada": "Kanada", "canada": "Kanada", # "ca" ist hier wegen US-Staaten problematisch als alleiniger Key
|
||
"taiwan": "Taiwan",
|
||
"dänemark": "Dänemark", "denmark": "Dänemark", "dk": "Dänemark",
|
||
"schweden": "Schweden", "sweden": "Schweden", "se": "Schweden",
|
||
"norwegen": "Norwegen", "norway": "Norwegen", "no": "Norwegen",
|
||
"finnland": "Finnland", "finland": "Finnland", "fi": "Finnland",
|
||
"irland": "Irland", "ireland": "Irland", "ie": "Irland",
|
||
"litauen": "Litauen", "lithuania": "Litauen", "lt": "Litauen",
|
||
# ... (Weitere nach Bedarf aus Ihrer CSV und Beobachtungen ergänzen)
|
||
}
|
||
|
||
region_to_country = {
|
||
"nrw": "Deutschland", "nordrhein-westfalen": "Deutschland", "hessen": "Deutschland",
|
||
"bayern": "Deutschland", "bavaria": "Deutschland", "baden-württemberg": "Deutschland", "bw": "Deutschland",
|
||
"zg": "Schweiz", "zug": "Schweiz", "zh": "Schweiz", "zürich": "Schweiz", "be": "Schweiz", "bern": "Schweiz",
|
||
"ag": "Schweiz", "aargau": "Schweiz", "sg": "Schweiz", "st. gallen": "Schweiz",
|
||
"va": "USA", "virginia": "USA", "ca": "USA", "california": "USA",
|
||
"ny": "USA", "new york": "USA", "il": "USA", "illinois": "USA",
|
||
"tx": "USA", "texas": "USA", "fl": "USA", "florida": "USA",
|
||
"pa": "USA", "pennsylvania": "USA", "oh": "USA", "ohio": "USA",
|
||
"ma": "USA", "massachusetts": "USA", "nj": "USA", "new jersey": "USA",
|
||
"on": "Kanada", "ontario": "Kanada", # Beispiel für Kanada
|
||
# ... (Weitere nach Bedarf)
|
||
}
|
||
# --- Ende Definitionen ---
|
||
|
||
extracted_country = ""
|
||
original_temp_sitz = temp_sitz # Für späteren Abgleich
|
||
|
||
# 1. Land in Klammern am Ende: Stadt (Land) oder Stadt (Region)
|
||
klammer_match = re.search(r'\(([^)]+)\)$', temp_sitz)
|
||
if klammer_match:
|
||
potential_suffix_in_klammer = klammer_match.group(1).strip().lower()
|
||
if potential_suffix_in_klammer in known_countries_detailed:
|
||
extracted_country = known_countries_detailed[potential_suffix_in_klammer]
|
||
temp_sitz = temp_sitz[:klammer_match.start()].strip(" ,")
|
||
elif potential_suffix_in_klammer in region_to_country:
|
||
extracted_country = region_to_country[potential_suffix_in_klammer]
|
||
temp_sitz = temp_sitz[:klammer_match.start()].strip(" ,")
|
||
|
||
# 2. Ländercode-Präfix (z.B. D-PLZ, CH-PLZ)
|
||
if not extracted_country:
|
||
prefix_match = re.match(r'^([A-Za-z]{1,3})\s*-\s*(\d{4,}[\w\s-]*)$', temp_sitz, re.IGNORECASE)
|
||
if prefix_match:
|
||
code, rest_nach_plz = prefix_match.group(1).lower(), prefix_match.group(2)
|
||
if code in known_countries_detailed:
|
||
extracted_country = known_countries_detailed[code]
|
||
temp_sitz = rest_nach_plz.strip() # Der Rest nach dem Präfix und PLZ ist die Stadt
|
||
# Fallback für US-Staaten Codes (z.B. VA, U.S.)
|
||
elif code in region_to_country and region_to_country[code] == "USA":
|
||
extracted_country = "USA"
|
||
temp_sitz = rest_nach_plz.strip()
|
||
|
||
|
||
# 3. Komma-getrennte Liste: Land oder Region am Ende
|
||
if not extracted_country and ',' in temp_sitz:
|
||
parts = [p.strip() for p in temp_sitz.split(',')]
|
||
if len(parts) > 1:
|
||
# Prüfe die letzten Teile auf bekannte Länder oder Regionen
|
||
# Prüfe von längeren Suffixen zu kürzeren
|
||
for num_suffix_parts in range(min(3, len(parts)-1 ), 0, -1): # max 3 Teile als Suffix, min 1
|
||
potential_suffix = ", ".join(parts[-(num_suffix_parts):]).lower()
|
||
if potential_suffix in known_countries_detailed:
|
||
extracted_country = known_countries_detailed[potential_suffix]
|
||
temp_sitz = ", ".join(parts[:-(num_suffix_parts)]).strip(" ,")
|
||
break
|
||
elif potential_suffix in region_to_country:
|
||
extracted_country = region_to_country[potential_suffix]
|
||
temp_sitz = ", ".join(parts[:-(num_suffix_parts)]).strip(" ,")
|
||
break
|
||
if not extracted_country: # Fallback, falls oben nichts passte, nur den letzten Teil prüfen
|
||
last_part_lower = parts[-1].lower()
|
||
if last_part_lower in known_countries_detailed:
|
||
extracted_country = known_countries_detailed[last_part_lower]
|
||
temp_sitz = ", ".join(parts[:-1]).strip(" ,")
|
||
elif last_part_lower in region_to_country:
|
||
extracted_country = region_to_country[last_part_lower]
|
||
temp_sitz = ", ".join(parts[:-1]).strip(" ,")
|
||
|
||
# 4. Land steht direkt am Ende des (Rest-)Strings (ohne Komma davor)
|
||
if not extracted_country:
|
||
# Sortiere Länder nach Länge absteigend, um spezifischere Übereinstimmungen zuerst zu finden
|
||
sorted_countries = sorted(known_countries_detailed.keys(), key=len, reverse=True)
|
||
for country_key in sorted_countries:
|
||
# Suche nach " Stadt Land" oder nur "Land"
|
||
if temp_sitz.lower().endswith(f" {country_key}"):
|
||
extracted_country = known_countries_detailed[country_key]
|
||
temp_sitz = temp_sitz[:-len(f" {country_key}")].strip(" ,")
|
||
break
|
||
elif temp_sitz.lower() == country_key: # Der ganze String ist das Land
|
||
extracted_country = known_countries_detailed[country_key]
|
||
temp_sitz = ""
|
||
break
|
||
|
||
# 5. Gesamter (verbleibender) String ist ein bekanntes Land
|
||
if not extracted_country and temp_sitz.lower() in known_countries_detailed:
|
||
extracted_country = known_countries_detailed[temp_sitz.lower()]
|
||
temp_sitz = ""
|
||
|
||
sitz_land_val = extracted_country if extracted_country else "k.A."
|
||
|
||
# Stadt ist der Rest, PLZ entfernen
|
||
sitz_stadt_val = re.sub(r'^\d{4,8}\s*', '', temp_sitz).strip(" ,")
|
||
if not sitz_stadt_val: # Wenn nach allem die Stadt leer ist
|
||
if original_temp_sitz.lower() != "k.a." and sitz_land_val == "k.A.":
|
||
# Wenn Original was hatte und kein Land gefunden wurde, nimm Original als Stadt
|
||
sitz_stadt_val = re.sub(r'^\d{4,8}\s*', '', original_temp_sitz).strip(" ,")
|
||
else:
|
||
sitz_stadt_val = "k.A."
|
||
|
||
# Finale Bereinigung der Stadt, falls das Land fälschlicherweise noch drin ist
|
||
if sitz_land_val != "k.A." and sitz_land_val in sitz_stadt_val:
|
||
sitz_stadt_val = sitz_stadt_val.replace(sitz_land_val, "").strip(" ,")
|
||
if not sitz_stadt_val : sitz_stadt_val = "k.A."
|
||
|
||
|
||
return {'sitz_stadt': sitz_stadt_val, 'sitz_land': sitz_land_val}
|
||
|
||
# Die Methode extract_company_data muss jetzt _parse_sitz_string_detailed verwenden:
|
||
@retry_on_failure
|
||
def extract_company_data(self, page_url):
|
||
# ... (Anfang der Methode bleibt gleich: default_result, URL-Prüfung, soup holen) ...
|
||
# ... (Extraktion von first_paragraph, categories_val, branche_val, umsatz_val, mitarbeiter_val bleibt gleich) ...
|
||
|
||
self.logger.debug(" -> Extrahiere Sitz aus Infobox...")
|
||
raw_sitz_string = self._extract_infobox_value(soup, 'sitz') # Holt den gesamten Sitz-String
|
||
|
||
# NEU: Aufruf der detaillierten Parsing-Methode
|
||
parsed_sitz = self._parse_sitz_string_detailed(raw_sitz_string)
|
||
sitz_stadt_val = parsed_sitz['sitz_stadt']
|
||
sitz_land_val = parsed_sitz['sitz_land']
|
||
|
||
result = {
|
||
'url': page_url,
|
||
'sitz_stadt': sitz_stadt_val,
|
||
'sitz_land': sitz_land_val,
|
||
'first_paragraph': first_paragraph,
|
||
'branche': branche_val,
|
||
'umsatz': umsatz_val,
|
||
'mitarbeiter': mitarbeiter_val,
|
||
'categories': categories_val
|
||
}
|
||
# ... (Rest der Methode mit Logging bleibt gleich) ...
|
||
self.logger.info(
|
||
f" -> Extrahierte Daten: Sitz Stadt='{sitz_stadt_val}', Sitz Land='{sitz_land_val}', P='{first_paragraph[:30]}...', "
|
||
f"B='{branche_val}', U='{umsatz_val}', M='{mitarbeiter_val}', "
|
||
f"C='{categories_val[:50]}...'"
|
||
)
|
||
return result
|
||
|
||
@retry_on_failure
|
||
def search_company_article(self, company_name, website=None, max_recursion_depth=1): # max_recursion_depth hinzugefügt
|
||
"""
|
||
Sucht einen passenden Wikipedia-Artikel fuer das Unternehmen und gibt das
|
||
wikipedia.WikipediaPage Objekt zurueck, wenn ein relevanter und validierter
|
||
Artikel gefunden wird. Behandelt explizit Begriffsklaerungsseiten.
|
||
"""
|
||
if not company_name or str(company_name).strip() == "":
|
||
self.logger.warning("Wikipedia search skipped: No company name provided.")
|
||
raise ValueError("Kein Firmenname fuer Wikipedia Suche angegeben.")
|
||
|
||
search_terms = self._generate_search_terms(company_name, website)
|
||
if not search_terms:
|
||
self.logger.warning(f"Keine Suchbegriffe fuer '{company_name[:100]}...' generiert.")
|
||
return None
|
||
|
||
self.logger.info(
|
||
f"Starte Wikipedia-Suche fuer '{company_name[:100]}...' "
|
||
f"(Website: {website[:100]}...) mit Begriffen: {search_terms}"
|
||
)
|
||
processed_titles = set()
|
||
original_search_name_norm = normalize_company_name(company_name) # Einmal normalisieren
|
||
processed_titles = set()
|
||
|
||
|
||
|
||
|
||
def check_page_recursive(title_to_check, original_search_name_for_validation, current_depth_val):
|
||
# Beachten Sie die Parameternamen hier.
|
||
# original_search_name_for_validation wird für die Ähnlichkeitsprüfung mit Optionen verwendet.
|
||
# company_name (aus der äußeren Funktion) wird für self._validate_article verwendet.
|
||
|
||
if title_to_check in processed_titles:
|
||
self.logger.debug(f" -> Titel '{title_to_check[:100]}...' bereits verarbeitet. Übersprungen.")
|
||
return None
|
||
if current_depth_val > max_recursion_depth:
|
||
self.logger.debug(f" -> Maximale Rekursionstiefe ({max_recursion_depth}) für '{title_to_check[:100]}...' erreicht. Stoppe.")
|
||
return None
|
||
|
||
processed_titles.add(title_to_check)
|
||
self.logger.debug(f" -> Pruefe potenziellen Artikel: '{title_to_check[:100]}...' (Tiefe: {current_depth_val})")
|
||
|
||
normalized_option_title_local = normalize_company_name(title_to_check) # Lokale Variable
|
||
title_similarity_to_original_local = fuzzy_similarity(normalized_option_title_local, original_search_name_for_validation)
|
||
|
||
if current_depth_val > 0 and title_similarity_to_original_local < 0.3:
|
||
self.logger.debug(f" -> Option '{title_to_check[:100]}' hat zu geringe Ähnlichkeit ({title_similarity_to_original_local:.2f}) zum Original-Suchbegriff '{original_search_name_for_validation}'. Übersprungen.")
|
||
return None
|
||
|
||
page = None
|
||
try:
|
||
page = wikipedia.page(title_to_check, auto_suggest=False, preload=False, redirect=True)
|
||
if self._validate_article(page, company_name, website): # Hier wird der company_name der äußeren Funktion verwendet
|
||
self.logger.info(f" -> Titel '{page.title[:100]}...' erfolgreich validiert!")
|
||
return page # Gibt das Page-Objekt zurück
|
||
else:
|
||
self.logger.debug(f" -> Titel '{page.title[:100]}...' nicht validiert.")
|
||
return None # Nicht validiert
|
||
|
||
except wikipedia.exceptions.DisambiguationError as e_disamb:
|
||
self.logger.info(
|
||
f" -> Begriffsklaerung '{e_disamb.title}' gefunden für '{title_to_check[:100]}...' (Tiefe {current_depth_val}). "
|
||
f"Pruefe Optionen (max 3 nach Filter): {str(e_disamb.options[:10])[:200]}..."
|
||
)
|
||
if current_depth_val >= max_recursion_depth:
|
||
self.logger.warning(f" -> Max Rekursionstiefe für Begriffsklärung '{e_disamb.title}' erreicht.")
|
||
return None
|
||
|
||
relevant_options = []
|
||
# ... (Filterlogik für Optionen)
|
||
for option_title_iter in e_disamb.options: # Iterationsvariable umbenannt
|
||
# ... (Ihre Filterlogik hier, die relevant_options füllt)
|
||
option_lower_iter = option_title_iter.lower() # Lokale Variable
|
||
if not any(ex in option_lower_iter for ex in [
|
||
"(person)", "(familienname)", # etc.
|
||
]) and len(option_title_iter) < 80:
|
||
normalized_option_iter = normalize_company_name(option_title_iter)
|
||
if fuzzy_similarity(normalized_option_iter, original_search_name_for_validation) > 0.3:
|
||
relevant_options.append(option_title_iter)
|
||
|
||
for option_to_check_further_iter in relevant_options[:3]:
|
||
# Wichtig: original_search_name_for_validation weitergeben
|
||
validated_page_from_option_iter = check_page_recursive(option_to_check_further_iter, original_search_name_for_validation, current_depth_val + 1)
|
||
if validated_page_from_option_iter:
|
||
return validated_page_from_option_iter
|
||
return None
|
||
# ... (andere except-Blöcke) ...
|
||
except Exception as e_page: # Allgemeinerer Fehler
|
||
if page and hasattr(page, 'title') and page.title: # Sicherstellen, dass page.title existiert
|
||
title_for_log = page.title[:100]
|
||
else:
|
||
title_for_log = title_to_check[:100]
|
||
|
||
if "extlinks" in str(e_page).lower():
|
||
self.logger.warning(f" -> KeyError ('extlinks') beim Verarbeiten von Titel '{title_for_log}...'. Übersprungen.")
|
||
return None
|
||
self.logger.error(
|
||
f" -> Unerwarteter Fehler bei Verarbeitung von Seite '{title_for_log}...': "
|
||
f"{type(e_page).__name__} - {e_page}"
|
||
)
|
||
self.logger.debug(traceback.format_exc())
|
||
return None
|
||
return None # Fallback, falls kein Pfad ein Ergebnis liefert
|
||
|
||
return page_object_to_return # Gibt validierte Seite oder None zurück
|
||
|
||
# --- Hauptlogik von search_company_article ---
|
||
# Direkter Match mit dem ursprünglichen Firmennamen
|
||
self.logger.debug(f" -> Versuche direkten Match fuer '{company_name[:100]}...' mit check_page_recursive...")
|
||
page_found = check_page_recursive(company_name, original_search_name_norm, 0)
|
||
page_found = check_page_recursive(term, original_search_name_norm, 0)
|
||
validated_option_page = check_page_recursive(option, original_search_name_norm, current_depth + 1)
|
||
if page_found:
|
||
return page_found
|
||
|
||
# Suche mit generierten Begriffen
|
||
self.logger.debug(f" -> Kein direkter Treffer/validiert. Starte Suche mit generierten Begriffen via check_page_recursive: {search_terms}")
|
||
for term in search_terms:
|
||
if term == company_name: continue # Bereits oben geprüft
|
||
self.logger.debug(f" -> Versuche Suchbegriff: '{term[:100]}...' mit check_page_recursive...")
|
||
page_found = check_page_recursive(term, original_search_name_norm, current_depth=0) # Starte Rekursion für den Term
|
||
if page_found:
|
||
return page_found
|
||
|
||
# Die wikipedia.search() wird hier nicht mehr primär genutzt,
|
||
# da check_page_recursive schon die wikipedia.page()-Suche macht.
|
||
# Falls check_page_recursive(term) nichts findet, könnten wir hier optional
|
||
# noch wikipedia.search() als Fallback nutzen, aber mit Vorsicht.
|
||
# Fürs Erste lassen wir es weg, um die API-Calls zu reduzieren.
|
||
#
|
||
# try:
|
||
# search_results = wikipedia.search(term, results=getattr(Config, 'WIKIPEDIA_SEARCH_RESULTS', 2)) # Weniger Ergebnisse
|
||
# if search_results:
|
||
# self.logger.debug(f" -> Fallback wikipedia.search für '{term[:100]}...' ergab: {search_results}")
|
||
# for title in search_results:
|
||
# page_found = check_page_recursive(title, original_search_name_norm, current_depth=0)
|
||
# if page_found:
|
||
# return page_found
|
||
# except Exception as e_search:
|
||
# self.logger.error(f"Fehler waehrend Fallback wikipedia.search() fuer '{term[:100]}...': {e_search}")
|
||
|
||
self.logger.warning(f"Kein passender & validierter Wikipedia-Artikel fuer '{company_name[:100]}...' gefunden nach allen Versuchen.")
|
||
return None
|
||
|
||
# Direkter Match mit dem ursprünglichen Firmennamen
|
||
self.logger.debug(f" -> Versuche direkten Match fuer '{company_name[:100]}...'...")
|
||
validated_page = check_page_recursive(company_name, current_depth=0) # Starte mit Tiefe 0
|
||
if validated_page:
|
||
return validated_page
|
||
|
||
# Suche mit generierten Begriffen
|
||
self.logger.debug(
|
||
f" -> Kein direkter Treffer/validiert. Starte Suche mit generierten Begriffen: {search_terms}"
|
||
)
|
||
for term in search_terms:
|
||
if term == company_name: continue # Bereits oben geprüft
|
||
|
||
try:
|
||
self.logger.debug(f" -> Suche mit Begriff: '{term[:100]}...'...")
|
||
search_results = wikipedia.search(term, results=getattr(Config, 'WIKIPEDIA_SEARCH_RESULTS', 3)) # Weniger Ergebnisse pro Term
|
||
self.logger.debug(f" -> Suchergebnisse fuer '{term[:100]}...': {search_results}")
|
||
if not search_results:
|
||
self.logger.debug(f" -> Keine Suchergebnisse fuer '{term[:100]}...'.")
|
||
continue
|
||
for title in search_results:
|
||
validated_page = check_page_recursive(title, current_depth=0) # Starte mit Tiefe 0 für jeden neuen Suchtreffer
|
||
if validated_page:
|
||
return validated_page
|
||
except Exception as e_search:
|
||
# Fehler von wikipedia.search() selbst, nicht von check_page_recursive
|
||
self.logger.error(
|
||
f"Fehler waehrend Wikipedia-Suche (wikipedia.search) fuer '{term[:100]}...': "
|
||
f"{type(e_search).__name__} - {e_search}"
|
||
)
|
||
# Hier nicht direkt 'raise e_search', da der Retry-Decorator auf search_company_article liegt.
|
||
# Wenn dies ein wiederholbarer Fehler der wikipedia-Bibliothek ist, sollte der Decorator greifen.
|
||
# Für den Moment loggen wir und machen mit dem nächsten Suchbegriff weiter.
|
||
# Bei einem persistenten API-Problem wird der äußere Decorator nach mehreren Versuchen abbrechen.
|
||
|
||
|
||
self.logger.warning(
|
||
f"Kein passender & validierter Wikipedia-Artikel fuer '{company_name[:100]}...' gefunden nach Pruefung aller Begriffe und Optionen."
|
||
)
|
||
return None
|
||
|
||
@retry_on_failure
|
||
def extract_company_data(self, page_url):
|
||
"""
|
||
Extrahiert Firmendaten (erster Absatz, Infobox-Werte, Kategorien)
|
||
von einer gegebenen Wikipedia-Artikel-URL.
|
||
"""
|
||
default_result = {
|
||
'url': page_url if page_url else 'k.A.',
|
||
'first_paragraph': 'k.A.',
|
||
'branche': 'k.A.',
|
||
'umsatz': 'k.A.',
|
||
'mitarbeiter': 'k.A.',
|
||
'categories': 'k.A.'
|
||
}
|
||
|
||
if not page_url or not isinstance(page_url, str) or "wikipedia.org/wiki/" not in page_url.lower():
|
||
self.logger.warning( # <<< GEÄNDERT
|
||
f"extract_company_data: Ungueltige oder keine Wikipedia-URL '{page_url[:100]}...'."
|
||
)
|
||
return default_result
|
||
|
||
self.logger.info(f"Extrahiere Daten fuer Wiki-URL: {page_url[:100]}...") # <<< GEÄNDERT
|
||
soup = self._get_page_soup(page_url)
|
||
if not soup:
|
||
self.logger.error(f" -> Fehler: Konnte Seite {page_url[:100]}... nicht laden oder parsen.") # <<< GEÄNDERT
|
||
return default_result
|
||
|
||
self.logger.debug(" -> Extrahiere erster Absatz...") # <<< GEÄNDERT
|
||
first_paragraph = self._extract_first_paragraph_from_soup(soup)
|
||
|
||
self.logger.debug(" -> Extrahiere Kategorien...") # <<< GEÄNDERT
|
||
categories_val = self.extract_categories(soup)
|
||
|
||
self.logger.debug(" -> Extrahiere Branche aus Infobox...") # <<< GEÄNDERT
|
||
branche_val = self._extract_infobox_value(soup, 'branche')
|
||
|
||
self.logger.debug(" -> Extrahiere Umsatz aus Infobox...") # <<< GEÄNDERT
|
||
umsatz_val = self._extract_infobox_value(soup, 'umsatz')
|
||
|
||
self.logger.debug(" -> Extrahiere Mitarbeiter aus Infobox...") # <<< GEÄNDERT
|
||
mitarbeiter_val = self._extract_infobox_value(soup, 'mitarbeiter')
|
||
|
||
self.logger.debug(" -> Extrahiere Sitz aus Infobox...")
|
||
raw_sitz_string = self._extract_infobox_value(soup, 'sitz') # Holt den gesamten Sitz-String
|
||
|
||
sitz_stadt_val = "k.A."
|
||
sitz_land_val = "k.A."
|
||
|
||
if raw_sitz_string and raw_sitz_string.lower() != "k.a.":
|
||
temp_sitz = raw_sitz_string.strip() # Am Anfang strippen
|
||
|
||
# Erweiterte Ländererkennung (Beispiele, muss ausgebaut werden)
|
||
# Key: Kleingeschriebene Variante, Value: Standardisierter Ländername
|
||
known_countries_detailed = {
|
||
# Deutschland
|
||
"deutschland": "Deutschland", "germany": "Deutschland", "de": "Deutschland", "brd": "Deutschland",
|
||
"bundesrepublik deutschland": "Deutschland", "d-": "Deutschland", # Für "D-PLZ"
|
||
# Österreich
|
||
"österreich": "Österreich", "austria": "Österreich", "at": "Österreich", "a-": "Österreich",
|
||
# Schweiz
|
||
"schweiz": "Schweiz", "switzerland": "Schweiz", "ch": "Schweiz", "suisse": "Schweiz",
|
||
"svizzera": "Schweiz", "ch-": "Schweiz", "confœderatio helvetica": "Schweiz",
|
||
# USA
|
||
"usa": "USA", "u.s.": "USA", "u.s.a.": "USA", "united states": "USA",
|
||
"vereinigte staaten": "USA", "vereinigte staaten von amerika": "USA",
|
||
# Vereinigtes Königreich
|
||
"vereinigtes königreich": "Vereinigtes Königreich", "united kingdom": "Vereinigtes Königreich",
|
||
"uk": "Vereinigtes Königreich", "gb": "Vereinigtes Königreich", "great britain": "Vereinigtes Königreich",
|
||
"england": "Vereinigtes Königreich", "schottland": "Vereinigtes Königreich", # Wenn spezifisch genannt
|
||
"wales": "Vereinigtes Königreich", "nordirland": "Vereinigtes Königreich",
|
||
# Frankreich
|
||
"frankreich": "Frankreich", "france": "Frankreich", "fr": "Frankreich", "f-": "Frankreich",
|
||
# Niederlande
|
||
"niederlande": "Niederlande", "netherlands": "Niederlande", "nl": "Niederlande", "holland": "Niederlande",
|
||
# Belgien
|
||
"belgien": "Belgien", "belgium": "Belgien", "be": "Belgien", "belgique": "Belgien", "belgië": "Belgien",
|
||
# Luxemburg
|
||
"luxemburg": "Luxemburg", "luxembourg": "Luxemburg", "lu": "Luxemburg",
|
||
# Italien
|
||
"italien": "Italien", "italy": "Italien", "it": "Italien", "i-": "Italien",
|
||
# Spanien
|
||
"spanien": "Spanien", "spain": "Spanien", "es": "Spanien", "españa": "Spanien",
|
||
# Polen
|
||
"polen": "Polen", "poland": "Polen", "pl": "Polen",
|
||
# Weitere häufige
|
||
"japan": "Japan", "jp": "Japan", "nippon": "Japan", "nihon": "Japan",
|
||
"kanada": "Kanada", "canada": "Kanada", "ca": "Kanada",
|
||
"china": "China", "cn": "China",
|
||
"indien": "Indien", "in": "Indien",
|
||
"russland": "Russland", "russian federation": "Russland", "ru": "Russland",
|
||
"brasilien": "Brasilien", "brazil": "Brasilien", "br": "Brasilien",
|
||
"australien": "Australien", "australia": "Australien", "au": "Australien",
|
||
"dänemark": "Dänemark", "denmark": "Dänemark", "dk": "Dänemark",
|
||
"schweden": "Schweden", "sweden": "Schweden", "se": "Schweden",
|
||
"norwegen": "Norwegen", "norway": "Norwegen", "no": "Norwegen",
|
||
"finnland": "Finnland", "finland": "Finnland", "fi": "Finnland",
|
||
"irland": "Irland", "ireland": "Irland", "ie": "Irland",
|
||
"taiwan": "Taiwan", "tw": "Taiwan",
|
||
"südkorea": "Südkorea", "south korea": "Südkorea", "kr": "Südkorea", "republic of korea": "Südkorea",
|
||
"tschechien": "Tschechien", "czech republic": "Tschechien", "cz": "Tschechien",
|
||
"ungarn": "Ungarn", "hungary": "Ungarn", "hu": "Ungarn",
|
||
"litauen": "Litauen", "lithuania": "Litauen", "lt": "Litauen",
|
||
# ... diese Liste kann und sollte basierend auf Ihren Daten erweitert werden ...
|
||
}
|
||
|
||
# Mapping von Bundesländern/Kantonen zu Ländern (Beispiele)
|
||
region_to_country = {
|
||
"nrw": "Deutschland", "nordrhein-westfalen": "Deutschland",
|
||
"bayern": "Deutschland", "bavaria": "Deutschland",
|
||
"hessen": "Deutschland",
|
||
"baden-württemberg": "Deutschland", "bw": "Deutschland",
|
||
"niedersachsen": "Deutschland",
|
||
"berlin": "Deutschland", # Berlin ist auch eine Stadt, aber wenn es isoliert steht als Region
|
||
"hamburg": "Deutschland", # dito
|
||
"sachsen": "Deutschland",
|
||
"sachsen-anhalt": "Deutschland",
|
||
"thüringen": "Deutschland",
|
||
"brandenburg": "Deutschland",
|
||
"mecklenburg-vorpommern": "Deutschland",
|
||
"rheinland-pfalz": "Deutschland", "rlp": "Deutschland",
|
||
"saarland": "Deutschland",
|
||
"schleswig-holstein": "Deutschland", "sh": "Deutschland",
|
||
"bremen": "Deutschland",
|
||
"zg": "Schweiz", "zug": "Schweiz", # Zug ist auch eine Stadt
|
||
"zh": "Schweiz", "zürich": "Schweiz", # Zürich ist auch eine Stadt
|
||
"be": "Schweiz", "bern": "Schweiz", # Bern ist auch eine Stadt
|
||
"ag": "Schweiz", "aargau": "Schweiz",
|
||
"sg": "Schweiz", "st. gallen": "Schweiz",
|
||
"va": "USA", "virginia": "USA",
|
||
"ca": "USA", "california": "USA", # Konflikt mit Kanada, wenn nur "CA"
|
||
"ny": "USA", "new york": "USA", # New York ist auch eine Stadt
|
||
"il": "USA", "illinois": "USA",
|
||
"tx": "USA", "texas": "USA",
|
||
"fl": "USA", "florida": "USA",
|
||
"pa": "USA", "pennsylvania": "USA",
|
||
"oh": "USA", "ohio": "USA",
|
||
"ma": "USA", "massachusetts": "USA",
|
||
# ... erweitern ...
|
||
}
|
||
|
||
extracted_country = ""
|
||
|
||
# Priorität 1: Land in Klammern am Ende: Stadt (Land)
|
||
klammer_match = re.search(r'\(([^)]+)\)$', temp_sitz)
|
||
if klammer_match:
|
||
potential_land_in_klammer = klammer_match.group(1).strip().lower()
|
||
if potential_land_in_klammer in known_countries_detailed:
|
||
extracted_country = known_countries_detailed[potential_land_in_klammer]
|
||
temp_sitz = temp_sitz[:klammer_match.start()].strip() # Rest ist Stadt
|
||
elif potential_land_in_klammer in region_to_country:
|
||
extracted_country = region_to_country[potential_land_in_klammer]
|
||
temp_sitz = temp_sitz[:klammer_match.start()].strip()
|
||
|
||
# Priorität 2: Ländercode-Präfix wie D-PLZ, CH-PLZ
|
||
if not extracted_country:
|
||
prefix_match = re.match(r'^([A-Za-z]{1,3})\s*-\s*\d{4,}', temp_sitz) # z.B. D-12345 oder CH-1234
|
||
if prefix_match:
|
||
potential_country_code = prefix_match.group(1).lower()
|
||
if potential_country_code in known_countries_detailed:
|
||
extracted_country = known_countries_detailed[potential_country_code]
|
||
# Entferne den Präfix und die PLZ vom temp_sitz
|
||
temp_sitz = re.sub(r'^[A-Za-z]{1,3}\s*-\s*\d{4,}\s*', '', temp_sitz).strip()
|
||
|
||
# Priorität 3: Komma-getrennte Liste, Land oder Region am Ende
|
||
if not extracted_country and ',' in temp_sitz:
|
||
parts = [p.strip() for p in temp_sitz.split(',')]
|
||
if len(parts) > 1:
|
||
# Prüfe die letzten Teile auf bekannte Länder oder Regionen
|
||
for k in range(len(parts) - 1, 0, -1): # Rückwärts prüfen für längere Übereinstimmungen
|
||
potential_suffix = ", ".join(parts[k:]).lower() # z.B. "New York, U.S." oder nur "U.S."
|
||
if potential_suffix in known_countries_detailed:
|
||
extracted_country = known_countries_detailed[potential_suffix]
|
||
temp_sitz = ", ".join(parts[:k]).strip()
|
||
break
|
||
elif potential_suffix in region_to_country:
|
||
extracted_country = region_to_country[potential_suffix]
|
||
temp_sitz = ", ".join(parts[:k]).strip()
|
||
break
|
||
# Wenn nur der letzte Teil geprüft wird (wie vorher)
|
||
if not extracted_country:
|
||
last_part = parts[-1].lower()
|
||
if last_part in known_countries_detailed:
|
||
extracted_country = known_countries_detailed[last_part]
|
||
temp_sitz = ", ".join(parts[:-1]).strip()
|
||
elif last_part in region_to_country:
|
||
extracted_country = region_to_country[last_part]
|
||
temp_sitz = ", ".join(parts[:-1]).strip()
|
||
|
||
# Priorität 4: Gesamter String ist ein bekanntes Land (selten, aber möglich)
|
||
if not extracted_country and temp_sitz.lower() in known_countries_detailed:
|
||
extracted_country = known_countries_detailed[temp_sitz.lower()]
|
||
temp_sitz = "" # Ganzer String war das Land
|
||
|
||
# Priorität 5: Teilstring am Ende ist ein Land (z.B. "München Deutschland")
|
||
if not extracted_country:
|
||
for country_key, country_name in known_countries_detailed.items():
|
||
# Suche nach dem Land am Ende des Strings (ganzes Wort)
|
||
if temp_sitz.lower().endswith(f" {country_key}") or temp_sitz.lower() == country_key:
|
||
extracted_country = country_name
|
||
# Entferne das Land vom Ende des temp_sitz
|
||
if temp_sitz.lower().endswith(f" {country_key}"):
|
||
temp_sitz = temp_sitz[:-len(f" {country_key}")].strip()
|
||
elif temp_sitz.lower() == country_key:
|
||
temp_sitz = ""
|
||
break
|
||
|
||
sitz_land_val = extracted_country if extracted_country else "k.A."
|
||
|
||
# Was übrig bleibt, ist die Stadt (nach Entfernung von PLZ, Kommas am Ende)
|
||
# Entferne führende/trailing Kommas und Leerzeichen, die durch die Länderentfernung entstehen könnten
|
||
temp_sitz = temp_sitz.strip(',').strip()
|
||
# Entferne erneut PLZ, falls sie jetzt am Anfang stehen
|
||
sitz_stadt_val = re.sub(r'^\d{4,8}\s*', '', temp_sitz).strip()
|
||
|
||
if not sitz_stadt_val and raw_sitz_string.lower() != "k.a." and sitz_land_val == "k.A.":
|
||
sitz_stadt_val = raw_sitz_string.strip()
|
||
elif not sitz_stadt_val:
|
||
sitz_stadt_val = "k.A."
|
||
|
||
# Spezifische Korrektur für Fälle wie "München Deutschland" wo Land schon weg ist
|
||
if sitz_stadt_val.lower() == sitz_land_val.lower() and sitz_land_val != "k.A.":
|
||
# Wenn Stadt und Land identisch sind (z.B. nach Entfernung von "Deutschland" aus "München Deutschland")
|
||
# und raw_sitz_string mehr enthielt
|
||
if raw_sitz_string.lower() != sitz_land_val.lower():
|
||
sitz_stadt_val = raw_sitz_string.replace(sitz_land_val, '').replace(sitz_land_val.lower(), '').strip(',').strip()
|
||
if not sitz_stadt_val: sitz_stadt_val = "k.A."
|
||
|
||
|
||
result = {
|
||
'url': page_url,
|
||
'sitz_stadt': sitz_stadt_val,
|
||
'sitz_land': sitz_land_val,
|
||
'first_paragraph': first_paragraph,
|
||
'branche': branche_val,
|
||
'umsatz': umsatz_val,
|
||
'mitarbeiter': mitarbeiter_val,
|
||
'categories': categories_val
|
||
}
|
||
|
||
self.logger.info(
|
||
f" -> Extrahierte Daten: Sitz Stadt='{sitz_stadt_val}', Sitz Land='{sitz_land_val}', P='{first_paragraph[:30]}...', "
|
||
f"B='{branche_val}', U='{umsatz_val}', M='{mitarbeiter_val}', "
|
||
f"C='{categories_val[:50]}...'"
|
||
)
|
||
return result
|
||
|
||
# ==============================================================================
|
||
# Ende Handler Klassen Block
|
||
# ==============================================================================
|
||
|
||
# ==============================================================================
|
||
# 5. DATA PROCESSOR CLASS (PART 1: Start & Init & Basis Status-Checker)
|
||
# ==============================================================================
|
||
|
||
class DataProcessor:
|
||
"""
|
||
Zentrale Klasse zur Orchestrierung und Verarbeitung von Unternehmensdaten
|
||
aus dem Google Sheet. Enthält die Logik fuer die Verarbeitung einzelner
|
||
Zeilen sowie die Steuerung verschiedener Batch-Modi und Dienstprogramme.
|
||
Nutzt Instanzen von Handler-Klassen (Sheet, Wiki etc.) als Worker.
|
||
"""
|
||
def __init__(self, sheet_handler, wiki_scraper):
|
||
"""
|
||
Initialisiert den DataProcessor mit Instanzen von Handler-Klassen.
|
||
|
||
Args:
|
||
sheet_handler (GoogleSheetHandler): Eine initialisierte Instanz.
|
||
wiki_scraper (WikipediaScraper): Eine initialisierte Instanz.
|
||
# Fuegen Sie hier weitere benoetigte Handler/Worker hinzu, falls noetig
|
||
# (z.B. OpenAIHandler, SerpAPIHandler), falls diese als eigene Klassen ausgelagert werden.
|
||
"""
|
||
# Erhalten Sie eine Logger-Instanz fuer diese Klasse
|
||
self.logger = logging.getLogger(__name__ + ".DataProcessor") # <<< HINZUGEFÜGT
|
||
self.logger.info("Initialisiere DataProcessor...") # <<< GEÄNDERT
|
||
|
||
# Ueberpruefen Sie, ob gueltige Handler-Instanzen uebergeben wurden
|
||
if not isinstance(sheet_handler, GoogleSheetHandler):
|
||
# Logge einen kritischen Fehler und werfe eine Exception
|
||
self.logger.critical("DataProcessor Init FEHLER: Kein gueltiger GoogleSheetHandler uebergeben!") # <<< GEÄNDERT
|
||
raise ValueError("DataProcessor benoetigt eine gueltige GoogleSheetHandler Instanz.")
|
||
if not isinstance(wiki_scraper, WikipediaScraper):
|
||
# Logge einen kritischen Fehler und werfe eine Exception
|
||
self.logger.critical("DataProcessor Init FEHLER: Kein gueltiger WikipediaScraper uebergeben!") # <<< GEÄNDERT
|
||
raise ValueError("DataProcessor benoetigt eine gueltige WikipediaScraper Instanz.")
|
||
|
||
# Speichern Sie die Handler-Instanzen als Attribute der Instanz
|
||
self.sheet_handler = sheet_handler
|
||
self.wiki_scraper = wiki_scraper
|
||
# self.openai_handler = openai_handler # Beispiel, falls ausgelagert
|
||
# self.serpapi_handler = serpapi_handler # Beispiel, falls ausgelagert
|
||
|
||
# Attribute fuer ML-Modellierung (werden beim ersten Bedarf geladen in _load_ml_model)
|
||
# Initialisieren Sie diese mit None
|
||
self.model = None
|
||
self.imputer = None
|
||
self._expected_features = None # Liste der erwarteten Feature-Spalten fuer Vorhersage
|
||
|
||
self.logger.info("DataProcessor initialisiert mit Handlern.") # <<< GEÄNDERT
|
||
|
||
# Definieren Sie hier (oder als Klassenattribut) die Zuordnung von Schritt-Typen
|
||
# zu den relevanten Spaltenschluesseln fuer die Statuspruefung.
|
||
# Diese werden von _should_run_based_on_status (Block 18) verwendet.
|
||
self._step_status_map = {
|
||
'wiki_verify': "Wiki Verif. Timestamp",
|
||
'website_scrape': "Website Scrape Timestamp",
|
||
'summarize_website': "Website Scrape Timestamp",
|
||
'branch_eval': "Timestamp letzte Pruefung",
|
||
'find_wiki_serp': "SerpAPI Wiki Search Timestamp",
|
||
'contact_search': "Contact Search Timestamp",
|
||
'wiki_updates_from_chatgpt': "Chat Wiki Konsistenzpruefung",
|
||
# 'wiki_extract': "Wikipedia Timestamp", # Speziell in _process_single_row behandelt
|
||
# 'ml_predict': "Geschaetzter Techniker Bucket" # Speziell in eigener Methode behandelt
|
||
}
|
||
|
||
# --- Interne Hilfsmethode zur Statuspruefung einer Zelle ---
|
||
# Dient zum sicheren Abrufen von Werten aus einer Zeile unter Verwendung von COLUMN_MAP.
|
||
# Nutzt globale Helfer: COLUMN_MAP, logger.
|
||
def _get_cell_value_safe(self, row, column_key):
|
||
"""
|
||
Hilfsfunktion fuer sicheren Zellenzugriff anhand des COLUMN_MAP Schluessels.
|
||
Gibt leeren String zurueck, wenn Index nicht existiert oder Zeile zu kurz ist.
|
||
|
||
Args:
|
||
row (list): Die Listendaten fuer die Zeile.
|
||
column_key (str): Der Schluessel in COLUMN_MAP fuer die zu pruefende Spalte.
|
||
|
||
Returns:
|
||
str: Der Wert der Zelle als String, oder '' wenn nicht verfuegbar.
|
||
"""
|
||
# Ermitteln Sie den Index der Spalte aus COLUMN_MAP (Block 1)
|
||
idx = COLUMN_MAP.get(column_key)
|
||
|
||
# Pruefen Sie, ob der Schluessel in COLUMN_MAP gefunden wurde
|
||
if idx is None:
|
||
# Logge einen Fehler, aber gebe einen leeren String zurueck
|
||
self.logger.error(f"_get_cell_value_safe: Schluessel '{column_key}' nicht in COLUMN_MAP gefunden.") # <<< GEÄNDERT
|
||
return '' # Gebe leeren String zurueck, wenn Schluessel fehlt
|
||
|
||
# Pruefen Sie, ob die Zeile lang genug ist, um auf diesen Index zuzugreifen
|
||
if len(row) > idx:
|
||
# Rueckgabe des Wertes, sicherstellen, dass es nicht None ist
|
||
return row[idx] if row[idx] is not None else ''
|
||
else:
|
||
# Logge auf Debug-Level, wenn der Index existiert, aber die Zeile zu kurz ist.
|
||
self.logger.debug( # <<< GEÄNDERT
|
||
f"_get_cell_value_safe: Index {idx} fuer '{column_key}' ist gueltig, "
|
||
f"aber Zeile ist zu kurz (Laenge {len(row)}). Gebe leeren String zurueck."
|
||
)
|
||
return '' # Gebe leeren String zurueck, wenn die Zeile zu kurz ist
|
||
|
||
# Der Code sollte niemals hierher gelangen.
|
||
# return '' # Fallback Rueckgabe
|
||
|
||
|
||
# ==============================================================================
|
||
# Ende DataProcessor Klasse Start & Init & Basis Status-Checker Block
|
||
# ==============================================================================
|
||
|
||
|
||
# --- Interne Hilfsmethoden zur Pruefung, ob ein Schritt ausgefuehrt werden soll ---
|
||
# Diese Methoden kapseln die Logik zur Entscheidung, ob ein Schritt basierend
|
||
# auf dem Zeilenstatus (Timestamps, Flags) und dem force_reeval Flag ausgefuehrt werden soll.
|
||
# Sie werden von _process_single_row (Block 19) aufgerufen.
|
||
# Nutzt interne Methode: _get_cell_value_safe.
|
||
# Nutzt globale Helfer: COLUMN_MAP, logger.
|
||
|
||
# HINWEIS: Die Logik hier bezieht sich auf die "Gruppen" von Schritten,
|
||
# die in _process_single_row zusammengefasst sind ('web', 'wiki', 'chat').
|
||
# Spezifischere Batch-Modi (Block 26-32) haben oft ihre eigene Zeilenauswahl-Logik,
|
||
# die nicht unbedingt diese Methoden verwendet.
|
||
|
||
|
||
def _needs_website_processing(self, row_data, force_reeval):
|
||
"""
|
||
Prueft, ob Website-Scraping/Summarization fuer diese Zeile noetig ist.
|
||
Nötig, wenn force_reeval True ist ODER wenn der Website Scrape Timestamp (AT) leer ist.
|
||
|
||
Args:
|
||
row_data (list): Die Listendaten fuer die Zeile.
|
||
force_reeval (bool): True, wenn eine Re-Evaluation erzwungen wird (ignoriert Timestamps).
|
||
|
||
Returns:
|
||
bool: True, wenn Website-Verarbeitung noetig ist.
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
if force_reeval:
|
||
# self.logger.debug(" -> Website-Schritt noetig (force_reeval=True)") # Zu viel Laerm im Debug
|
||
return True # Wenn Re-Eval erzwungen wird, ist der Schritt noetig
|
||
|
||
|
||
# Pruefe, ob der Website Scrape Timestamp (AT) leer ist (nutzt interne Helfer)
|
||
at_value = self._get_cell_value_safe(row_data, "Website Scrape Timestamp").strip()
|
||
# Wenn der Timestamp leer ist, ist der Schritt noetig
|
||
if not at_value:
|
||
# self.logger.debug(" -> Website-Schritt noetig (AT leer)") # Zu viel Laerm im Debug
|
||
return True
|
||
|
||
# Wenn der Timestamp gesetzt ist und kein Re-Eval erzwungen wird, ist der Schritt nicht noetig
|
||
# self.logger.debug(f" -> Website-Schritt nicht noetig (AT='{at_value}')") # Zu viel Laerm im Debug
|
||
return False
|
||
|
||
|
||
def _needs_wiki_processing(self, row_data, force_reeval):
|
||
"""
|
||
Prueft, ob Wikipedia-Suche/Extraktion fuer diese Zeile noetig ist.
|
||
Nötig, wenn force_reeval True ist ODER wenn der Wikipedia Timestamp (AN)
|
||
leer ist ODER wenn Status S 'X (URL Copied)' ist (signalisiert neue URL).
|
||
|
||
Args:
|
||
row_data (list): Die Listendaten fuer die Zeile.
|
||
force_reeval (bool): True, wenn eine Re-Evaluation erzwungen wird.
|
||
|
||
Returns:
|
||
bool: True, wenn Wiki-Suche/Extraktion noetig ist.
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
if force_reeval:
|
||
# self.logger.debug(" -> Wiki-Extraktion/-Suche noetig (force_reeval=True)") # Zu viel Laerm im Debug
|
||
return True # Wenn Re-Eval erzwungen wird, ist der Schritt noetig
|
||
|
||
|
||
# Pruefe, ob der Wikipedia Timestamp (AN) leer ist (nutzt interne Helfer)
|
||
an_value = self._get_cell_value_safe(row_data, "Wikipedia Timestamp").strip()
|
||
# Wenn der Timestamp leer ist, ist der Schritt noetig
|
||
if not an_value:
|
||
# self.logger.debug(" -> Wiki-Extraktion/-Suche noetig (AN leer)") # Zu viel Laerm im Debug
|
||
return True
|
||
|
||
# Pruefe, ob Status S 'X (URL Copied)' ist (signalisiert, dass eine neue URL durch den Update-Modus kopiert wurde und neu extrahiert werden muss)
|
||
s_value = self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzpruefung").strip().upper()
|
||
# Wenn Status S "X (URL COPIED)" ist, ist der Schritt noetig
|
||
if s_value == "X (URL COPIED)":
|
||
# self.logger.debug(" -> Wiki-Extraktion/-Suche noetig (S='X (URL Copied)')") # Zu viel Laerm im Debug
|
||
return True
|
||
|
||
|
||
# Wenn AN gesetzt ist, S nicht "X (URL COPIED)" ist und kein Re-Eval erzwungen wird, ist der Schritt nicht noetig
|
||
# self.logger.debug(f" -> Wiki-Extraktion/-Suche nicht noetig (AN='{an_value}', S='{s_value}')") # Zu viel Laerm im Debug
|
||
return False
|
||
|
||
|
||
def _needs_wiki_verification(self, row_data, force_reeval):
|
||
"""
|
||
Prueft, ob Wikipedia-Verifizierung (S-U) fuer diese Zeile noetig ist.
|
||
Nötig, wenn force_reeval True ist ODER wenn der Wiki Verif. Timestamp (AX) leer ist
|
||
UND eine Wiki URL (M) vorhanden ist.
|
||
|
||
Args:
|
||
row_data (list): Die Listendaten fuer die Zeile.
|
||
force_reeval (bool): True, wenn eine Re-Evaluation erzwungen wird.
|
||
|
||
Returns:
|
||
bool: True, wenn Wiki-Verifizierung noetig ist.
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
if force_reeval:
|
||
# self.logger.debug(" -> Wiki-Verifizierung noetig (force_reeval=True)") # Zu viel Laerm im Debug
|
||
return True # Wenn Re-Eval erzwungen wird, ist der Schritt noetig
|
||
|
||
|
||
# Pruefe, ob der Wiki Verif. Timestamp (AX) leer ist (nutzt interne Helfer)
|
||
ax_value = self._get_cell_value_safe(row_data, "Wiki Verif. Timestamp").strip()
|
||
# Wenn der Timestamp leer ist
|
||
if not ax_value:
|
||
# Pruefe ZUSAETZLICH, ob eine Wiki URL (M) vorhanden ist und gueltig aussieht, da Verifizierung sonst sinnlos ist.
|
||
# Nutzt interne Helfer
|
||
m_value = self._get_cell_value_safe(row_data, "Wiki URL").strip()
|
||
# Wenn M vorhanden ist und nicht "k.A." oder ein Fehlereintrag
|
||
if m_value and m_value.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"]: # Fuege "http:" hinzu
|
||
# self.logger.debug(" -> Wiki-Verifizierung noetig (AX leer UND M gueltig)") # Zu viel Laerm im Debug
|
||
return True # Wenn AX leer und M gueltig, ist der Schritt noetig
|
||
# else: self.logger.debug(" -> Wiki-Verifizierung nicht noetig (AX leer, aber M leer/k.A.)") # Zu viel Laerm im Debug
|
||
|
||
# Wenn AX gesetzt ist und kein Re-Eval erzwungen wird, ist der Schritt nicht noetig
|
||
# self.logger.debug(f" -> Wiki-Verifizierung nicht noetig (AX='{ax_value}')") # Zu viel Laerm im Debug
|
||
return False
|
||
|
||
|
||
def _needs_chat_evaluations(self, row_data, force_reeval, wiki_data_just_updated):
|
||
"""
|
||
Prueft, ob ChatGPT-Evaluationen (Branch, FSM etc.) fuer diese Zeile noetig sind.
|
||
Nötig, wenn force_reeval True ist ODER wenn der Timestamp letzte Pruefung (AO)
|
||
leer ist ODER wenn Wiki-Daten in diesem Lauf gerade aktualisiert wurden (Flag von _process_single_row).
|
||
|
||
Args:
|
||
row_data (list): Die Listendaten fuer die Zeile.
|
||
force_reeval (bool): True, wenn eine Re-Evaluation erzwungen wird.
|
||
wiki_data_just_updated (bool): True, wenn die Wiki-Daten (M-R, AN) gerade in diesem Lauf aktualisiert wurden.
|
||
|
||
Returns:
|
||
bool: True, wenn Chat-Evaluationen noetig sind.
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
if force_reeval:
|
||
# self.logger.debug(" -> Chat-Evaluationen noetig (force_reeval=True)") # Zu viel Laerm im Debug
|
||
return True # Wenn Re-Eval erzwungen wird, ist der Schritt noetig
|
||
|
||
|
||
# Pruefe, ob der Timestamp letzte Pruefung (AO) leer ist (nutzt interne Helfer)
|
||
ao_value = self._get_cell_value_safe(row_data, "Timestamp letzte Pruefung").strip()
|
||
# Wenn der Timestamp leer ist, ist der Schritt noetig
|
||
if not ao_value:
|
||
# self.logger.debug(" -> Chat-Evaluationen noetig (AO leer)") # Zu viel Laerm im Debug
|
||
return True
|
||
|
||
# Pruefe, ob Wiki-Daten in diesem Lauf gerade aktualisiert wurden (Flag aus _process_single_row)
|
||
# Wenn Wiki-Daten aktualisiert wurden, sollen die Chat-Evaluationen erneut laufen, auch wenn AO gesetzt ist.
|
||
if wiki_data_just_updated:
|
||
# self.logger.debug(" -> Chat-Evaluationen noetig (Wiki-Daten gerade aktualisiert)") # Zu viel Laerm im Debug
|
||
return True
|
||
|
||
|
||
# Wenn AO gesetzt ist, Wiki-Daten nicht aktualisiert wurden und kein Re-Eval erzwungen wird, ist der Schritt nicht noetig
|
||
# self.logger.debug(f" -> Chat-Evaluationen nicht noetig (AO='{ao_value}' und Wiki-Daten nicht aktualisiert)") # Zu viel Laerm im Debug
|
||
return False
|
||
|
||
|
||
def _needs_ml_prediction(self, row_data, force_reeval, chat_eval_just_ran):
|
||
"""
|
||
Prueft, ob die ML-Schaetzung (AU) fuer diese Zeile noetig ist.
|
||
Nötig, wenn force_reeval True ist ODER wenn der AU Bucket leer ist
|
||
UND (entweder AO gerade gesetzt wurde ODER konsolidierter Umsatz/Mitarbeiter vorhanden ist).
|
||
|
||
Args:
|
||
row_data (list): Die Listendaten fuer die Zeile.
|
||
force_reeval (bool): True, wenn eine Re-Evaluation erzwungen wird.
|
||
chat_eval_just_ran (bool): True, wenn die ChatGPT-Evaluationen (AO) gerade in diesem Lauf liefen.
|
||
|
||
Returns:
|
||
bool: True, wenn ML-Schaetzung noetig ist.
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
if force_reeval:
|
||
# self.logger.debug(" -> ML-Schaetzung noetig (force_reeval=True)") # Zu viel Laerm im Debug
|
||
return True # Wenn Re-Eval erzwungen wird, ist der Schritt noetig
|
||
|
||
|
||
# Pruefe, ob der Geschaetzte Techniker Bucket (AU) leer ist (nutzt interne Helfer)
|
||
au_value = self._get_cell_value_safe(row_data, "Geschaetzter Techniker Bucket").strip()
|
||
# Wenn der Bucket leer ist oder einen Fehlerwert enthaelt
|
||
if not au_value or au_value.lower() in ["k.a.", "fehler schaetzung"]:
|
||
# self.logger.debug(f" -> ML-Schaetzung noetig (AU leer/Fehler)") # Zu viel Laerm im Debug
|
||
|
||
# Pruefe ZUSAETZLICH, ob die Daten fuer die Schaetzung vorhanden/aktualisiert sind.
|
||
# Die Schaetzung ist sinnvoll, wenn A) Chat-Evaluationen gerade liefen (setzen AO und Konsolidierung AV/AW)
|
||
# ODER B) Konsolidierter Umsatz/Mitarbeiter (AV/AW) explizit vorhanden ist (auch wenn AO alt ist).
|
||
|
||
# Pruefe, ob Chat-Evaluationen gerade liefen
|
||
if chat_eval_just_ran:
|
||
# self.logger.debug(" -> ML-Schaetzung noetig (Chat-Evaluationen gerade liefen)") # Zu viel Laerm im Debug
|
||
return True # Wenn Chat-Evaluationen gerade liefen, ist der Schritt noetig
|
||
|
||
|
||
# Pruefe, ob Konsolidierter Umsatz/Mitarbeiter (AV/AW) vorhanden ist (nutzt interne Helfer)
|
||
# Diese Werte werden normalerweise von _process_single_row gesetzt, wenn Chat lief, oder manuell gepflegt.
|
||
av_value = self._get_cell_value_safe(row_data, "Finaler Umsatz (Wiki>CRM)").strip()
|
||
aw_value = self._get_cell_value_safe(row_data, "Finaler Mitarbeiter (Wiki>CRM)").strip()
|
||
|
||
# Wenn AV und AW vorhanden sind (nicht "k.A." oder Fehlerwerte)
|
||
if av_value != "k.A." and not av_value.startswith("FEHLER") and aw_value != "k.A." and not aw_value.startswith("FEHLER"):
|
||
# self.logger.debug(" -> ML-Schaetzung noetig (AV/AW vorhanden)") # Zu viel Laerm im Debug
|
||
return True # Wenn AV/AW vorhanden, ist der Schritt noetig
|
||
# else: self.logger.debug(" -> ML-Schaetzung nicht noetig (AV/AW fehlen)") # Zu viel Laerm im Debug
|
||
|
||
|
||
# Wenn AU gesetzt ist oder einen gueltigen Wert enthaelt, und kein Re-Eval erzwungen wird, ist der Schritt nicht noetig
|
||
# self.logger.debug(f" -> ML-Schaetzung nicht noetig (AU='{au_value}')") # Zu viel Laerm im Debug
|
||
return False
|
||
|
||
|
||
# ==============================================================================
|
||
# Ende DataProcessor Klasse Status-Checker Helpers Block
|
||
# ==============================================================================
|
||
|
||
# --- Methode: Verarbeitung einer einzelnen Zeile ---
|
||
# Diese Methode ist das Herzstueck der Zeilen-basierten Verarbeitung.
|
||
# Sie fuehrt die einzelnen Anreicherungs- und Evaluationsschritte durch,
|
||
# basierend auf ausgewaehlten Schritten, Timestamps/Status, und dem force_reeval Flag.
|
||
# Nutzt interne Helfer: _get_cell_value_safe, _needs_... Methoden.
|
||
# Nutzt globale Helfer: COLUMN_MAP, logger, datetime, time, traceback.
|
||
# @retry_on_failure # Nicht sinnvoll auf dieser Orchestrierungsebene. Retries sind in den einzelnen Schritten.
|
||
def _process_single_row(self, row_num_in_sheet, row_data,
|
||
steps_to_run, force_reeval=False, clear_x_flag=False):
|
||
"""
|
||
Verarbeitet die Daten fuer eine einzelne Zeile im Sheet. Fuehrt ausgewaehlte
|
||
Anreicherungs- und Analyseprozesse durch, basierend auf Timestamps/Status
|
||
oder dem force_reeval Flag. Sammelt und schreibt Ergebnisse zurueck.
|
||
|
||
Args:
|
||
row_num_in_sheet (int): Die 1-basierte Zeilennummer im Google Sheet.
|
||
row_data (list): Die rohen Listendaten fuer diese Zeile (Liste von Strings).
|
||
steps_to_run (set/list): Menge oder Liste von Schluesseln der Schritt-Gruppen,
|
||
die in diesem Lauf beruecksichtigt werden sollen
|
||
(z.B. {'wiki', 'web', 'chat', 'ml_predict'}).
|
||
force_reeval (bool, optional): Wenn True, werden Timestamps/Status ignoriert
|
||
und die Ausfuehrung der in steps_to_run
|
||
enthaltenen Schritte erzwungen. Defaults to False.
|
||
clear_x_flag (bool, optional): Wenn True UND force_reeval ist True, wird das 'x'-Flag
|
||
in Spalte A geloescht, wenn die Zeile verarbeitet wurde.
|
||
Defaults to False.
|
||
"""
|
||
self.logger.info(f"--- Starte Verarbeitung fuer Zeile {row_num_in_sheet} {'(Re-Eval)' if force_reeval else ''} (Schritte: {', '.join(steps_to_run) if steps_to_run else 'Keine ausgewählt'}) ---")
|
||
updates = []
|
||
now_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
any_processing_done = False
|
||
wiki_data_updated_in_this_run = False
|
||
chat_eval_just_ran = False
|
||
|
||
# --- Initiale Werte lesen ---
|
||
company_name = self._get_cell_value_safe(row_data, "CRM Name").strip()
|
||
website_url = self._get_cell_value_safe(row_data, "CRM Website").strip()
|
||
original_website_url_in_sheet = website_url
|
||
crm_kurzform = self._get_cell_value_safe(row_data, "CRM Kurzform").strip()
|
||
crm_branche = self._get_cell_value_safe(row_data, "CRM Branche").strip()
|
||
crm_beschreibung = self._get_cell_value_safe(row_data, "CRM Beschreibung").strip()
|
||
|
||
# NEU: Wert aus Spalte D (Parent Account Name) hier lesen
|
||
parent_account_name_d = self._get_cell_value_safe(row_data, "Parent Account Name").strip()
|
||
self.logger.debug(f" Zeile {row_num_in_sheet}: Gelesener Parent Account (D): '{parent_account_name_d}'")
|
||
|
||
# Vorgeschlagener Parent aus Spalte O (wird für Wiki-Suche und Parent-Vorschlagslogik benötigt)
|
||
system_suggested_parent_name_o = self._get_cell_value_safe(row_data, "System Vorschlag Parent Account").strip() # Spalte O
|
||
|
||
current_wiki_data = {
|
||
'url': self._get_cell_value_safe(row_data, "Wiki URL") or 'k.A.',
|
||
'sitz_stadt': self._get_cell_value_safe(row_data, "Wiki Sitz Stadt") or 'k.A.',
|
||
'sitz_land': self._get_cell_value_safe(row_data, "Wiki Sitz Land") or 'k.A.',
|
||
'first_paragraph': self._get_cell_value_safe(row_data, "Wiki Absatz") or 'k.A.',
|
||
'branche': self._get_cell_value_safe(row_data, "Wiki Branche") or 'k.A.',
|
||
'umsatz': self._get_cell_value_safe(row_data, "Wiki Umsatz") or 'k.A.',
|
||
'mitarbeiter': self._get_cell_value_safe(row_data, "Wiki Mitarbeiter") or 'k.A.',
|
||
'categories': self._get_cell_value_safe(row_data, "Wiki Kategorien") or 'k.A.'
|
||
}
|
||
final_wiki_data = current_wiki_data.copy()
|
||
|
||
website_raw = self._get_cell_value_safe(row_data, "Website Rohtext") or 'k.A.'
|
||
website_summary = self._get_cell_value_safe(row_data, "Website Zusammenfassung") or 'k.A.'
|
||
website_meta_details = self._get_cell_value_safe(row_data, "Website Meta-Details") or 'k.A.' # Spalte AI
|
||
url_pruefstatus = self._get_cell_value_safe(row_data, "URL Prüfstatus") or '' # Spalte AK
|
||
|
||
# --- 1. Website Handling (Lookup, Scraping, Summarization, Meta) ---
|
||
run_website_step = 'web' in steps_to_run
|
||
website_processing_needed_based_on_status = self._needs_website_processing(row_data, force_reeval)
|
||
|
||
if run_website_step and website_processing_needed_based_on_status:
|
||
any_processing_done = True
|
||
grund_message_parts_web = []
|
||
if force_reeval: grund_message_parts_web.append('Re-Eval')
|
||
if not self._get_cell_value_safe(row_data, "Website Scrape Timestamp").strip(): grund_message_parts_web.append('AJ (Website Scrape Timestamp) leer') # Angepasster Key
|
||
grund_message_web = ", ".join(filter(None, grund_message_parts_web)) or "Unbekannter Grund"
|
||
self.logger.info(f"Zeile {row_num_in_sheet}: Fuehre WEBSITE Schritte aus (Grund: {grund_message_web})...")
|
||
|
||
# a) Website Lookup (nur wenn D leer)
|
||
if not website_url or website_url.lower() == "k.a.":
|
||
self.logger.debug(" -> Website URL (E) leer oder k.A., suche ueber SERP...")
|
||
try:
|
||
new_website = serp_website_lookup(company_name)
|
||
if new_website and new_website.lower() != "k.a." and not new_website.startswith("k.A. (Fehler"):
|
||
website_url = new_website # Aktualisiere Arbeitskopie
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["CRM Website"] + 1)}{row_num_in_sheet}', 'values': [[website_url]]})
|
||
self.logger.info(f" -> Neue Website gefunden und für Update E:{row_num_in_sheet} vorgemerkt: {website_url[:100]}...")
|
||
url_pruefstatus = "URL_OK_SERP" # Status aktualisieren
|
||
else:
|
||
self.logger.warning(f" -> Keine neue Website ueber SERP gefunden für '{company_name[:100]}...'.")
|
||
url_pruefstatus = "URL_SERP_FAILED" if not website_url or website_url.lower() == "k.a." else url_pruefstatus # Nur setzen, wenn vorher keine URL da war
|
||
except Exception as e_serp_lookup_web:
|
||
self.logger.error(f"FEHLER bei SERP Website Lookup für '{company_name[:100]}...': {e_serp_lookup_web}")
|
||
url_pruefstatus = "URL_SERP_ERROR"
|
||
|
||
# b) Scrape Rohtext, Meta-Details und c) Zusammenfassung (nur wenn URL vorhanden)
|
||
if website_url and website_url.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"]:
|
||
self.logger.debug(f" -> Scrape Rohtext & Meta von {website_url[:100]}...")
|
||
new_website_raw_temp = "k.A." # Temporär für diesen Scope
|
||
try:
|
||
new_website_raw_temp = get_website_raw(website_url) # Nutzt globale Funktion
|
||
website_raw = new_website_raw_temp # Update der Hauptvariable
|
||
|
||
# Wenn get_website_raw einen URL_CHECK_MARKER zurückgibt
|
||
if website_raw == URL_CHECK_MARKER:
|
||
url_pruefstatus = URL_CHECK_MARKER
|
||
self.logger.warning(f" -> get_website_raw markierte URL {website_url} als '{URL_CHECK_MARKER}'. Überspringe weitere Web-Verarbeitung.")
|
||
website_summary = "k.A. (URL prüfen)" # Verhindere alte Zusammenfassung
|
||
website_meta_details = "k.A. (URL prüfen)"
|
||
elif website_raw and str(website_raw).strip().lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]:
|
||
url_pruefstatus = "URL_OK_SCRAPED" # Wenn Scraping (auch nur teilweise) erfolgreich war
|
||
|
||
# Meta-Details scrapen (experimentell)
|
||
try:
|
||
meta_temp = scrape_website_details(website_url)
|
||
website_meta_details = meta_temp if meta_temp else "k.A. (Keine Meta-Details)"
|
||
except Exception as e_meta:
|
||
self.logger.error(f"FEHLER bei Meta-Detail Scraping für '{website_url[:100]}...': {e_meta}")
|
||
website_meta_details = f"k.A. (Fehler Meta: {str(e_meta)[:50]})"
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Meta-Details"] + 1)}{row_num_in_sheet}', 'values': [[website_meta_details]]}) # Spalte AI
|
||
|
||
# Zusammenfassung
|
||
self.logger.debug(f" -> Fasse Rohtext zusammen (Laenge: {len(str(website_raw))})...")
|
||
try:
|
||
summary_temp = summarize_website_content(website_raw)
|
||
website_summary = summary_temp if summary_temp and summary_temp.strip() else "k.A. (Keine Zusammenfassung erhalten)"
|
||
except Exception as e_summary_web:
|
||
self.logger.error(f"FEHLER bei Website Zusammenfassung für '{company_name[:100]}...': {e_summary_web}")
|
||
website_summary = f"k.A. (Fehler Zusammenfassung: {str(e_summary_web)[:50]})"
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[website_summary]]}) # Spalte AH
|
||
else: # Fehler beim Scraping oder kein sinnvoller Text
|
||
if not website_raw.startswith("k.A. (Fehler"): # Wenn es kein expliziter Fehler war, sondern z.B. nur Cookie-Banner
|
||
url_pruefstatus = "URL_SCRAPE_EMPTY_OR_BANNER"
|
||
# website_raw enthält bereits den Fehler oder "k.A. (nur Cookie...)"
|
||
website_summary = "k.A." # Keine Zusammenfassung bei fehlerhaftem/leerem Rohtext
|
||
website_meta_details = "k.A."
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[website_summary]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Meta-Details"] + 1)}{row_num_in_sheet}', 'values': [[website_meta_details]]})
|
||
|
||
|
||
except Exception as e_scrape_web:
|
||
self.logger.error(f"FEHLER beim Website Scraping für '{company_name[:100]}' ({website_url[:100]}...): {e_scrape_web}")
|
||
website_raw = f"k.A. (Fehler Scraping: {str(e_scrape_web)[:50]})"
|
||
website_summary = "k.A. (Scraping fehlgeschlagen)"
|
||
website_meta_details = "k.A. (Scraping fehlgeschlagen)"
|
||
url_pruefstatus = "URL_SCRAPE_ERROR"
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[website_summary]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Meta-Details"] + 1)}{row_num_in_sheet}', 'values': [[website_meta_details]]})
|
||
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [[website_raw]]}) # Spalte AG
|
||
|
||
else: # Keine gültige URL für Scraping
|
||
self.logger.debug(f" -> Keine gültige Website URL für '{company_name[:100]}...' vorhanden. Web-Verarbeitung übersprungen.")
|
||
website_raw = "k.A. (Keine URL)"
|
||
website_summary = "k.A."
|
||
website_meta_details = "k.A."
|
||
# Wenn url_pruefstatus noch leer ist (weil keine URL-Suche stattfand), setze es.
|
||
if not url_pruefstatus : url_pruefstatus = "URL_MISSING"
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [[website_raw]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[website_summary]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Meta-Details"] + 1)}{row_num_in_sheet}', 'values': [[website_meta_details]]})
|
||
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["URL Prüfstatus"] + 1)}{row_num_in_sheet}', 'values': [[url_pruefstatus]]}) # Spalte AK
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Scrape Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # Spalte AJ
|
||
|
||
# ======================================================================
|
||
# === 2. Wikipedia Handling (Search, Extraction, Status Reset) ==========
|
||
# ======================================================================
|
||
run_wiki_step = 'wiki' in steps_to_run
|
||
wiki_processing_needed_based_on_status = self._needs_wiki_processing(row_data, force_reeval)
|
||
|
||
if run_wiki_step and wiki_processing_needed_based_on_status:
|
||
any_processing_done = True
|
||
|
||
grund_message_parts_wiki_list = []
|
||
if force_reeval:
|
||
grund_message_parts_wiki_list.append('Re-Eval')
|
||
if not self._get_cell_value_safe(row_data, "Wikipedia Timestamp").strip():
|
||
grund_message_parts_wiki_list.append('Z (Wikipedia Timestamp) leer')
|
||
if self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzpruefung").strip().upper() == "X (URL COPIED)":
|
||
grund_message_parts_wiki_list.append("AC (Chat Wiki Konsistenzpruefung)='X (URL COPIED)'")
|
||
|
||
grund_message_wiki_str = ", ".join(filter(None, grund_message_parts_wiki_list))
|
||
if not grund_message_wiki_str:
|
||
grund_message_wiki_str = "Bedingung für Wiki-Bearbeitung erfüllt (Standard)"
|
||
|
||
# ----- KORREKTUR HIER -----
|
||
self.logger.info(f"Zeile {row_num_in_sheet}: Fuehre WIKI Schritte aus (Grund: {grund_message_wiki_str})...")
|
||
# ----- ENDE KORREKTUR -----
|
||
|
||
current_wiki_url_r = self._get_cell_value_safe(row_data, "Wiki URL").strip()
|
||
system_suggested_parent_o = self._get_cell_value_safe(row_data, "System Vorschlag Parent Account").strip()
|
||
# parent_account_name_d ist bereits oben in _process_single_row gelesen worden
|
||
|
||
url_for_extraction = None
|
||
source_of_wiki_data_origin_log_msg = "Tochter (Initial)"
|
||
additional_info_for_af_col = ""
|
||
self.logger.debug(f" Zeile {row_num_in_sheet}: Inhalt von current_wiki_url_r (Spalte R) VOR Parent-Check: '{current_wiki_url_r}' (Typ: {type(current_wiki_url_r)})") # NEUE DEBUG-ZEILE
|
||
|
||
# WICHTIG: Wenn R leer ist, IMMER ZUERST versuchen, Parent-URLs (D dann O) zu finden
|
||
if not current_wiki_url_r or current_wiki_url_r.lower() == 'k.a.':
|
||
# Priorität 1: Manuell gepflegter Parent in D
|
||
if parent_account_name_d and parent_account_name_d.lower() != 'k.a.':
|
||
self.logger.info(f" Zeile {row_num_in_sheet}: R leer, D ('{parent_account_name_d}') gesetzt. Suche Wiki für Parent D via SerpAPI.")
|
||
try:
|
||
potential_url = serp_wikipedia_lookup(parent_account_name_d, website=None) # SERP API KEY BENÖTIGT!
|
||
if potential_url and potential_url.lower() not in ["k.a.", "kein artikel gefunden"] and not potential_url.startswith("FEHLER"):
|
||
url_for_extraction = potential_url
|
||
source_of_wiki_data_origin_log_msg = f"Parent D ('{parent_account_name_d}')"
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Wiki Konsistenzpruefung"] + 1)}{row_num_in_sheet}', 'values': [["INFO_PARENT_AUS_D"]]})
|
||
additional_info_for_af_col = f"INFO: Wiki-URL von Parent (aus D): {parent_account_name_d} via SerpAPI. "
|
||
else:
|
||
self.logger.warning(f" -> Kein Wiki-Artikel für Parent D '{parent_account_name_d}' gefunden.")
|
||
additional_info_for_af_col = f"WARN: Kein Wiki für Parent D '{parent_account_name_d}' gefunden. "
|
||
except Exception as e_d_lookup_re:
|
||
self.logger.error(f"Fehler bei Wiki-Suche für Parent D '{parent_account_name_d}': {e_d_lookup_re}")
|
||
additional_info_for_af_col = f"ERR: Suche Parent D '{parent_account_name_d}' fehlgeschlagen. "
|
||
|
||
# Priorität 2: Systemvorschlag Parent O (nur wenn D nicht erfolgreich war)
|
||
if url_for_extraction is None and \
|
||
(system_suggested_parent_o and system_suggested_parent_o.lower() != 'k.a.'):
|
||
self.logger.info(f" Zeile {row_num_in_sheet}: R leer, D nicht genutzt/erfolglos. O ('{system_suggested_parent_o}') gesetzt. Suche Wiki für Parent O via SerpAPI.")
|
||
try:
|
||
potential_url = serp_wikipedia_lookup(system_suggested_parent_o, website=None) # SERP API KEY BENÖTIGT!
|
||
if potential_url and potential_url.lower() not in ["k.a.", "kein artikel gefunden"] and not potential_url.startswith("FEHLER"):
|
||
url_for_extraction = potential_url
|
||
source_of_wiki_data_origin_log_msg = f"Parent O ('{system_suggested_parent_o}')"
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Wiki Konsistenzpruefung"] + 1)}{row_num_in_sheet}', 'values': [["INFO_PARENT_AUS_O"]]})
|
||
additional_info_for_af_col += f"INFO: Wiki-URL von Parent (aus O): {system_suggested_parent_o} via SerpAPI. "
|
||
else:
|
||
self.logger.warning(f" -> Kein Wiki-Artikel für Parent O '{system_suggested_parent_o}' gefunden.")
|
||
additional_info_for_af_col += f"WARN: Kein Wiki für Parent O '{system_suggested_parent_o}' gefunden. "
|
||
except Exception as e_o_lookup_re:
|
||
self.logger.error(f"Fehler bei Wiki-Suche für Parent O '{system_suggested_parent_o}': {e_o_lookup_re}")
|
||
additional_info_for_af_col += f"ERR: Suche Parent O '{system_suggested_parent_o}' fehlgeschlagen. "
|
||
|
||
# Priorität 3: Tochter-Wiki-Verarbeitung (wenn keine Parent-URL gefunden wurde oder R bereits gefüllt war und Re-Eval/Reparse ansteht)
|
||
if url_for_extraction is None: # Wenn nach Parent-Suchen immer noch keine URL da ist
|
||
search_for_daughter_wiki_needed_local = False
|
||
status_ac_indicates_reparse_local = self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzpruefung").strip().upper() == "X (URL COPIED)"
|
||
timestamp_z_is_empty_local = not self._get_cell_value_safe(row_data, "Wikipedia Timestamp").strip()
|
||
r_url_is_valid_looking_local = current_wiki_url_r and \
|
||
"wikipedia.org/wiki/" in current_wiki_url_r.lower() and \
|
||
current_wiki_url_r.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche"]
|
||
|
||
if status_ac_indicates_reparse_local:
|
||
self.logger.info(f" Zeile {row_num_in_sheet}: Status AC ist 'X (URL COPIED)'. Starte neue Suche für Tochter '{company_name}'.")
|
||
search_for_daughter_wiki_needed_local = True
|
||
elif force_reeval:
|
||
if r_url_is_valid_looking_local:
|
||
self.logger.info(f" Zeile {row_num_in_sheet}: Re-Eval Modus. Nutze vorhandene Tochter-URL (R): {current_wiki_url_r}")
|
||
url_for_extraction = current_wiki_url_r
|
||
source_of_wiki_data_origin_log_msg = "Tochter (aus R, Re-Eval)"
|
||
else:
|
||
self.logger.info(f" Zeile {row_num_in_sheet}: Re-Eval Modus. Tochter-URL (R) leer/ungültig. Starte neue Suche für Tochter '{company_name}'.")
|
||
search_for_daughter_wiki_needed_local = True
|
||
elif timestamp_z_is_empty_local:
|
||
if r_url_is_valid_looking_local:
|
||
self.logger.info(f" Zeile {row_num_in_sheet}: Wikipedia Timestamp (Z) fehlt. Nutze vorhandene Tochter-URL (R): {current_wiki_url_r}")
|
||
url_for_extraction = current_wiki_url_r
|
||
source_of_wiki_data_origin_log_msg = "Tochter (aus R, Z leer)"
|
||
else:
|
||
self.logger.info(f" Zeile {row_num_in_sheet}: Wikipedia Timestamp (Z) fehlt und Tochter-URL (R) leer/ungültig. Starte neue Suche für Tochter '{company_name}'.")
|
||
search_for_daughter_wiki_needed_local = True
|
||
elif not r_url_is_valid_looking_local: # Fallback, wenn Z nicht leer, aber R schlecht
|
||
self.logger.info(f" Zeile {row_num_in_sheet}: Tochter-URL (R) ist ungültig ('{current_wiki_url_r}'). Starte neue Suche für Tochter '{company_name}'.")
|
||
search_for_daughter_wiki_needed_local = True
|
||
else: # R ist vorhanden und Timestamp Z ist gesetzt, kein Re-Eval/Reparse -> keine Wiki-Aktion für Tochter nötig
|
||
self.logger.debug(f" Zeile {row_num_in_sheet}: Wiki-Verarbeitung für Tochter nicht nötig (Timestamp Z gesetzt, R gültig, kein Re-Eval/Reparse, kein Parent-Fall).")
|
||
|
||
if search_for_daughter_wiki_needed_local:
|
||
self.logger.info(f" -> Suche nach Wikipedia-Artikel für Tochter '{company_name}'...")
|
||
try:
|
||
page_obj_tochter = self.wiki_scraper.search_company_article(company_name, website_url) # SERP API KEY BENÖTIGT!
|
||
if page_obj_tochter:
|
||
url_for_extraction = page_obj_tochter.url
|
||
source_of_wiki_data_origin_log_msg = "Tochter (Suche erfolgreich)"
|
||
self.logger.info(f" -> Suche für Tochter '{company_name}' erfolgreich, URL: {url_for_extraction}")
|
||
else:
|
||
url_for_extraction = "Kein Artikel gefunden"
|
||
self.logger.warning(f" -> Kein Wiki-Artikel für Tochter '{company_name}' gefunden.")
|
||
except Exception as e_tochter_suche_psr:
|
||
self.logger.error(f"Fehler bei Wiki-Suche für Tochter '{company_name}': {e_tochter_suche_psr}")
|
||
url_for_extraction = f"Fehler Suche Tochter: {str(e_tochter_suche_psr)[:50]}"
|
||
|
||
elif current_wiki_url_r and current_wiki_url_r.lower() != 'k.a.' and not current_wiki_url_r.startswith("FEHLER") and \
|
||
(force_reeval or self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzpruefung").strip().upper() == "X (URL COPIED)"):
|
||
# Dieser Fall tritt ein, wenn R eine URL enthält, aber force_reeval oder "X (URL COPIED)" aktiv ist
|
||
# und url_for_extraction bereits durch eine Parent-Suche gesetzt wurde.
|
||
# In diesem Szenario hat die Parent-URL Vorrang, aber wir loggen, dass die Tochter-URL auch da war.
|
||
if url_for_extraction: # Wenn schon eine Parent-URL gefunden wurde
|
||
self.logger.info(f" Zeile {row_num_in_sheet}: Parent-URL '{url_for_extraction}' hat Vorrang. Tochter-URL in R ('{current_wiki_url_r}') wird nicht für Extraktion verwendet, obwohl Re-Eval/Reparse aktiv.")
|
||
else: # Sollte nicht passieren, wenn die obere Logik korrekt ist, aber als Sicherheitsnetz
|
||
self.logger.info(f" Zeile {row_num_in_sheet}: Re-Eval/Reparse aktiv. Nutze bestehende Tochter-URL aus R: {current_wiki_url_r}")
|
||
url_for_extraction = current_wiki_url_r
|
||
source_of_wiki_data_origin_log_msg = "Tochter (aus R, Re-Eval/Reparse)"
|
||
|
||
|
||
|
||
# --- DATENEXTRAKTION ---
|
||
if url_for_extraction and isinstance(url_for_extraction, str) and \
|
||
url_for_extraction.lower() not in ["k.a.", "kein artikel gefunden"] and \
|
||
not url_for_extraction.startswith("FEHLER"):
|
||
|
||
self.logger.info(f" -> Extrahiere Wiki-Daten von URL ({source_of_wiki_data_origin_log_msg}): {url_for_extraction[:100]}...")
|
||
try:
|
||
extracted_data = self.wiki_scraper.extract_company_data(url_for_extraction)
|
||
|
||
if extracted_data and isinstance(extracted_data, dict) and extracted_data.get('url') != 'k.A.':
|
||
final_wiki_data = extracted_data
|
||
wiki_data_updated_in_this_run = True
|
||
self.logger.info(f" -> Datenextraktion von {url_for_extraction[:100]}... erfolgreich.")
|
||
|
||
# AC und abhängige Spalten zurücksetzen/anpassen
|
||
current_ac_val = self._get_cell_value_safe(row_data, "Chat Wiki Konsistenzpruefung").strip()
|
||
|
||
if source_of_wiki_data_origin_log_msg.startswith("Parent"):
|
||
# AC wurde schon auf INFO_PARENT_AUS_D/O gesetzt
|
||
# Hier sicherstellen, dass abhängige Spalten geleert werden, falls AC neu ist
|
||
if current_ac_val not in ["INFO_PARENT_AUS_D", "INFO_PARENT_AUS_O"]:
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Verif. Timestamp"] + 1)}{row_num_in_sheet}', 'values': [['']]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung Wiki Inkonsistenz"] + 1)}{row_num_in_sheet}', 'values': [['']]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Wiki Artikel"] + 1)}{row_num_in_sheet}', 'values': [['']]})
|
||
elif force_reeval or current_wiki_url_r != url_for_extraction or current_ac_val.upper() == "X (URL COPIED)":
|
||
# Tochter-URL wurde neu gefunden oder reevaluiert -> AC auf '?' setzen für ChatGPT Verif.
|
||
self.logger.info(f" -> Setze AC auf '?' und leere AA, AD, AE für Tochter-Wiki-Update.")
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Wiki Konsistenzpruefung"] + 1)}{row_num_in_sheet}', 'values': [['?']]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Verif. Timestamp"] + 1)}{row_num_in_sheet}', 'values': [['']]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung Wiki Inkonsistenz"] + 1)}{row_num_in_sheet}', 'values': [['']]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Wiki Artikel"] + 1)}{row_num_in_sheet}', 'values': [['']]})
|
||
else:
|
||
self.logger.error(f" -> Fehler bei Datenextraktion von {url_for_extraction[:100]} (Extraktion leer/ungültig).")
|
||
final_wiki_data['url'] = url_for_extraction
|
||
for key_to_clear in ['sitz_stadt', 'sitz_land', 'first_paragraph', 'branche', 'umsatz', 'mitarbeiter', 'categories']:
|
||
final_wiki_data[key_to_clear] = 'k.A. (Extraktion fehlgeschlagen)'
|
||
wiki_data_updated_in_this_run = True
|
||
except Exception as e_wiki_extract_final_psr:
|
||
self.logger.error(f"FEHLER bei Wikipedia Datenextraktion von {url_for_extraction[:100]}...: {e_wiki_extract_final_psr}")
|
||
self.logger.debug(traceback.format_exc())
|
||
final_wiki_data['url'] = url_for_extraction
|
||
for key_to_clear in ['sitz_stadt', 'sitz_land', 'first_paragraph', 'branche', 'umsatz', 'mitarbeiter', 'categories']:
|
||
final_wiki_data[key_to_clear] = f'k.A. (FEHLER Extr.)'
|
||
wiki_data_updated_in_this_run = True
|
||
|
||
elif url_for_extraction:
|
||
self.logger.info(f" -> Keine valide Wiki-URL zur Extraktion vorhanden: '{url_for_extraction}'. Setze Wiki-Felder auf k.A.")
|
||
final_wiki_data['url'] = url_for_extraction
|
||
for key_to_clear in ['sitz_stadt', 'sitz_land', 'first_paragraph', 'branche', 'umsatz', 'mitarbeiter', 'categories']:
|
||
final_wiki_data[key_to_clear] = 'k.A.'
|
||
wiki_data_updated_in_this_run = True
|
||
|
||
if wiki_data_updated_in_this_run:
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki URL"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('url', 'k.A.')]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Sitz Stadt"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('sitz_stadt', 'k.A.')]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Sitz Land"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('sitz_land', 'k.A.')]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Absatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('first_paragraph', 'k.A.')]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Branche"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('branche', 'k.A.')]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('umsatz', 'k.A.')]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('mitarbeiter', 'k.A.')]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Kategorien"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('categories', 'k.A.')]]})
|
||
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wikipedia Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]})
|
||
|
||
if additional_info_for_af_col:
|
||
current_af_val = self._get_cell_value_safe(row_data, "Begruendung bei Abweichung").strip()
|
||
new_af_val = (current_af_val + "; " + additional_info_for_af_col if current_af_val else additional_info_for_af_col).strip()
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Begruendung bei Abweichung"] + 1)}{row_num_in_sheet}', 'values': [[new_af_val]]})
|
||
|
||
if source_of_wiki_data_origin_log_msg.startswith("Parent D") or source_of_wiki_data_origin_log_msg.startswith("Parent O"):
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["SerpAPI Wiki Search Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]})
|
||
|
||
|
||
|
||
# --- Ende des Wikipedia-Blocks ---
|
||
# Der nächste Block wäre ChatGPT-Evaluationen
|
||
|
||
# --- 3. ChatGPT Evaluationen (Branch, FSM, Emp, Umsatz Schaetzungen etc.) ---
|
||
run_chat_step = 'chat' in steps_to_run
|
||
# wiki_data_updated_in_this_run wurde im Wikipedia-Block oben gesetzt
|
||
chat_processing_needed_based_on_status = self._needs_chat_evaluations(row_data, force_reeval, wiki_data_updated_in_this_run)
|
||
|
||
if run_chat_step and chat_processing_needed_based_on_status:
|
||
any_processing_done = True
|
||
chat_eval_just_ran = True # Wichtig für ML-Schritt später
|
||
|
||
# ... (Grund Message für Log erstellen) ...
|
||
grund_message_parts_chat = []
|
||
if force_reeval: grund_message_parts_chat.append('Re-Eval')
|
||
if not self._get_cell_value_safe(row_data, "Timestamp letzte Pruefung").strip(): grund_message_parts_chat.append('BN (Timestamp letzte Pruefung) leer')
|
||
if wiki_data_updated_in_this_run: grund_message_parts_chat.append('Wiki Daten gerade aktualisiert')
|
||
grund_message_chat = ", ".join(filter(None, grund_message_parts_chat)) or "Unbekannter Grund (Chat)"
|
||
self.logger.info(f"Zeile {row_num_in_sheet}: Fuehre CHATGPT Evaluationen & Plausi aus (Grund: {grund_message_chat})...")
|
||
|
||
# --- 3a. Branchen-Einstufung (AL, AM, AN, AO) ---
|
||
# ... (Dieser Teil bleibt wie er war, nutzt final_wiki_data, website_summary) ...
|
||
# ... (Logik für evaluate_branche_chatgpt und Updates für AL, AM, AN, AO) ...
|
||
self.logger.info(f"Zeile {row_num_in_sheet}: Starte Branchen-Einstufung ueber ChatGPT...")
|
||
try:
|
||
# final_wiki_data enthält hier ggf. die Daten des Parents
|
||
branch_result = evaluate_branche_chatgpt(
|
||
crm_branche,
|
||
crm_beschreibung,
|
||
final_wiki_data.get('branche', 'k.A.'),
|
||
final_wiki_data.get('categories', 'k.A.'),
|
||
website_summary
|
||
)
|
||
# ... (Updates für Spalten AL, AM, AN, AO)
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get("branch", "FEHLER BRANCH")]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Branche Konfidenz"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get("confidence", "N/A CONF")]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenz Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get("consistency", "error CONS")]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung Abweichung Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get("justification", "No JUST")]]})
|
||
except Exception as e_branch_eval_row:
|
||
self.logger.error(f"FEHLER bei Branchen-Einstufung für Zeile {row_num_in_sheet}: {e_branch_eval_row}")
|
||
# ... (Fehlerwerte schreiben) ...
|
||
|
||
# --- 3b, 3c, 3d: Weitere ChatGPT Evaluationen (FSM, Mitarbeiter, Umsatz) ---
|
||
# ... (Diese bleiben strukturell gleich, nutzen aber das ggf. aktualisierte final_wiki_data) ...
|
||
# Zum Beispiel für Mitarbeiterschätzung:
|
||
# if not final_wiki_data.get('mitarbeiter') or final_wiki_data.get('mitarbeiter', 'k.A.').lower() == 'k.a.':
|
||
# # ... call_openai_chat für Mitarbeiterschätzung ...
|
||
# ... etc. für andere ChatGPT-Schritte ...
|
||
|
||
# HIER BEGINNT DIE WICHTIGE ÄNDERUNG FÜR KONSOLIDIERUNG UND PLAUSI
|
||
# --- 3e. Konsolidierung Umsatz/Mitarbeiter (BD, BE) ---
|
||
self.logger.debug(f" Zeile {row_num_in_sheet}: Konsolidiere Umsatz (BD) und Mitarbeiter (BE) unter Berücksichtigung von Parent (D)...")
|
||
final_umsatz_str_konsolidiert = "k.A."
|
||
final_ma_str_konsolidiert = "k.A."
|
||
try:
|
||
crm_umsatz_val_str = self._get_cell_value_safe(row_data, "CRM Umsatz") # Spalte L
|
||
wiki_umsatz_val_str = final_wiki_data.get('umsatz', 'k.A.') # Aus final_wiki_data (kann Parent sein), Spalte W
|
||
crm_ma_val_str = self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter") # Spalte M
|
||
wiki_ma_val_str = final_wiki_data.get('mitarbeiter', 'k.A.') # Aus final_wiki_data, Spalte X
|
||
|
||
# Numerische Werte für die Logik holen
|
||
num_crm_umsatz = get_numeric_filter_value(crm_umsatz_val_str, is_umsatz=True)
|
||
num_wiki_umsatz = get_numeric_filter_value(wiki_umsatz_val_str, is_umsatz=True)
|
||
num_crm_ma = get_numeric_filter_value(crm_ma_val_str, is_umsatz=False)
|
||
num_wiki_ma = get_numeric_filter_value(wiki_ma_val_str, is_umsatz=False)
|
||
|
||
wiki_source_log_info = "Unbekannt/Sheet (Wiki-Block nicht gelaufen)"
|
||
if 'source_of_wiki_data_origin_log_msg' in locals() and source_of_wiki_data_origin_log_msg:
|
||
wiki_source_log_info = source_of_wiki_data_origin_log_msg
|
||
elif 'source_of_wiki_data_origin' in locals() and source_of_wiki_data_origin: # Fallback, falls die Variable anders hieß
|
||
wiki_source_log_info = source_of_wiki_data_origin
|
||
|
||
self.logger.debug(f" Konsolidierung Input: CRM_U(L)='{num_crm_umsatz}', Wiki_U(W)='{num_wiki_umsatz}' (aus {wiki_source_log_info}), CRM_M(M)='{num_crm_ma}', Wiki_M(X)='{num_wiki_ma}' (aus {wiki_source_log_info}), Parent_D='{parent_account_name_d}'")
|
||
|
||
|
||
if parent_account_name_d and parent_account_name_d.lower() != 'k.a.':
|
||
# Parent-Account (D) ist GEFÜLLT: Primär CRM-Daten der Tochter verwenden.
|
||
self.logger.info(f" -> Parent D ('{parent_account_name_d}') ist gesetzt. Konsolidiere primär mit CRM-Daten der Tochter für BD/BE.")
|
||
final_num_umsatz = num_crm_umsatz if num_crm_umsatz > 0 else num_wiki_umsatz # Wiki als Fallback, falls CRM der Tochter fehlt/0 ist
|
||
final_num_ma = num_crm_ma if num_crm_ma > 0 else num_wiki_ma # Wiki als Fallback
|
||
else:
|
||
# Parent-Account (D) ist LEER: Standardlogik Wiki > CRM.
|
||
self.logger.debug(f" -> Parent D leer. Standardkonsolidierung Wiki > CRM für BD/BE.")
|
||
final_num_umsatz = num_wiki_umsatz if num_wiki_umsatz > 0 else num_crm_umsatz
|
||
final_num_ma = num_wiki_ma if num_wiki_ma > 0 else num_crm_ma
|
||
|
||
final_umsatz_str_konsolidiert = str(int(round(final_num_umsatz))) if final_num_umsatz > 0 else 'k.A.'
|
||
final_ma_str_konsolidiert = str(int(round(final_num_ma))) if final_num_ma > 0 else 'k.A.'
|
||
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Umsatz (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [[final_umsatz_str_konsolidiert]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Mitarbeiter (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [[final_ma_str_konsolidiert]]})
|
||
self.logger.debug(f" -> Konsolidiert für BD/BE (Sheet-Werte): Umsatz='{final_umsatz_str_konsolidiert}', MA='{final_ma_str_konsolidiert}'")
|
||
except Exception as e_consolidate_row_final:
|
||
self.logger.error(f"FEHLER bei Konsolidierung Umsatz/Mitarbeiter (BD/BE) für Zeile {row_num_in_sheet}: {e_consolidate_row_final}")
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Umsatz (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER_KONSO']]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Mitarbeiter (Wiki>CRM)"] + 1)}{row_num_in_sheet}', 'values': [['FEHLER_KONSO']]})
|
||
final_umsatz_str_konsolidiert = "FEHLER_KONSO" # Für Plausi-Input
|
||
final_ma_str_konsolidiert = "FEHLER_KONSO"
|
||
|
||
# --- 3f. Plausibilitäts-Checks durchführen (BG-BM) ---
|
||
# Der Aufruf von _check_financial_plausibility muss jetzt parent_account_name_d (Spalte D) übergeben bekommen.
|
||
self.logger.debug(f" Zeile {row_num_in_sheet}: Führe Plausibilitäts-Checks durch (Parent D: '{parent_account_name_d}')...")
|
||
try:
|
||
plausi_input_data = {
|
||
"Finaler Umsatz (Wiki>CRM)": final_umsatz_str_konsolidiert,
|
||
"Finaler Mitarbeiter (Wiki>CRM)": final_ma_str_konsolidiert,
|
||
"CRM Umsatz": self._get_cell_value_safe(row_data, "CRM Umsatz"),
|
||
"Wiki Umsatz": final_wiki_data.get('umsatz', 'k.A.'), # final_wiki_data ist hier verfügbar
|
||
"CRM Anzahl Mitarbeiter": self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter"),
|
||
"Wiki Mitarbeiter": final_wiki_data.get('mitarbeiter', 'k.A.'), # final_wiki_data ist hier verfügbar
|
||
"Parent Account Name": parent_account_name_d, # Ist am Anfang von _process_single_row gelesen
|
||
# NEU: Werte für O und P direkt aus row_data holen
|
||
"System Vorschlag Parent Account": self._get_cell_value_safe(row_data, "System Vorschlag Parent Account"),
|
||
"Parent Vorschlag Status": self._get_cell_value_safe(row_data, "Parent Vorschlag Status")
|
||
}
|
||
plausi_results = self._check_financial_plausibility(plausi_input_data)
|
||
|
||
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[plausi_results.get("plaus_umsatz_flag", "ERR_FLAG")]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[plausi_results.get("plaus_ma_flag", "ERR_FLAG")]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Umsatz/MA Ratio"] + 1)}{row_num_in_sheet}', 'values': [[plausi_results.get("plaus_ratio_flag", "ERR_FLAG")]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Abweichung Umsatz CRM/Wiki"] + 1)}{row_num_in_sheet}', 'values': [[plausi_results.get("abweichung_umsatz_flag", "ERR_FLAG")]]}) # BJ
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Abweichung MA CRM/Wiki"] + 1)}{row_num_in_sheet}', 'values': [[plausi_results.get("abweichung_ma_flag", "ERR_FLAG")]]}) # BK
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Begründung"] + 1)}{row_num_in_sheet}', 'values': [[plausi_results.get("plausi_begruendung_final", "Fehler Begr.")]]})
|
||
|
||
except Exception as e_plausi_in_single_row_chat:
|
||
self.logger.error(f"FEHLER bei Plausibilitäts-Checks in _process_single_row (Chat-Block) für Zeile {row_num_in_sheet}: {e_plausi_in_single_row_chat}")
|
||
# ... (Fehler-Flags für Plausi-Spalten setzen) ...
|
||
|
||
# Plausi-Timestamp (BM) und Haupt-Timestamp (BN) setzen
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Prüfdatum"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Timestamp letzte Pruefung"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]})
|
||
|
||
|
||
# --- Der Code fuer den naechsten Verarbeitungsschritt (ML Prediction) folgt im naechsten Block ---
|
||
# Definition der Methode _process_single_row wird in der naechsten Nachricht fortgesetzt.
|
||
|
||
# --- 4. Servicetechniker Schaetzung (ML Modell) (AU) ---
|
||
# Dieser Schritt wird ausgefuehrt, wenn 'ml_predict' in steps_to_run enthalten ist UND
|
||
# (_needs_ml_prediction True ist ODER force_reeval True ist).
|
||
# _needs_ml_prediction (Block 16) prueft AU, AO und Konsolidierte Werte (AV/AW).
|
||
# Nutzt interne Helfer: _needs_ml_prediction, _predict_technician_bucket, _get_cell_value_safe.
|
||
# Nutzt globale Helfer: COLUMN_MAP, logger.
|
||
# Nutzt lokale Variablen: force_reeval, chat_eval_just_ran.
|
||
|
||
# Pruefen Sie, ob der ML-Schritt im aktuellen Lauf angefordert wurde
|
||
run_ml_step = 'ml_predict' in steps_to_run
|
||
# Pruefen Sie, ob der ML-Schritt laut Status, Re-Eval oder Chat-Evaluation (Trigger) noetig ist.
|
||
# chat_eval_just_ran ist ein Flag aus dem vorherigen Chat-Schritt (Block 20).
|
||
ml_processing_needed_based_on_status = self._needs_ml_prediction(row_data, force_reeval, chat_eval_just_ran)
|
||
|
||
|
||
# Wenn der ML-Schritt angefordert wurde UND laut Status/Re-Eval noetig ist
|
||
if run_ml_step and ml_processing_needed_based_on_status:
|
||
any_processing_done = True # Markiere, dass in dieser Zeile etwas getan wird
|
||
|
||
# Bestimme den Grund fuer die Ausfuehrung dieses Schritts fuer das Logging
|
||
grund_message_parts = []
|
||
if force_reeval: grund_message_parts.append('Re-Eval')
|
||
# Wenn nicht Re-Eval, dann liegt es an _needs_ml_prediction. Logge den Grund von dort auf Debug.
|
||
if not force_reeval:
|
||
self.logger.debug(" -> ML-Schaetzung noetig (Grund laut _needs_ml_prediction).") # <<< GEÄNDERT
|
||
pass # Der spezifische Grund wird bereits in _needs_ml_prediction geloggt (auf Debug).
|
||
|
||
self.logger.info(f"Zeile {row_num_in_sheet}: Fuehre ML-Schaetzung aus...") # <<< GEÄNDERT
|
||
|
||
# Die ML-Schaetzung benoetigt die vorbereiteten Daten (konsolidierter Umsatz/Mitarbeiter und Branche).
|
||
# Diese Werte sind bereits in der Zeile im Sheet verfuegbar (Spalten AV, AW)
|
||
# oder wurden gerade in den vorherigen Schritten (Block 20) aktualisiert.
|
||
# Die _predict_technician_bucket Methode muss diese Werte aus den row_data holen.
|
||
|
||
try:
|
||
# Annahme: _predict_technician_bucket Methode existiert in DataProcessor (Block 31).
|
||
# Diese Methode muss das geladene Modell/Imputer nutzen (Attribute der Klasse),
|
||
# die benoetigten Features aus den row_data extrahieren, vorbereiten und vorhersagen.
|
||
# _predict_technician_bucket wirft Exception bei Fehlern.
|
||
predicted_bucket = self._predict_technician_bucket(row_data) # Nutzt row_data fuer Feature-Extraktion (Block 17)
|
||
|
||
# Wenn die Vorhersage erfolgreich war und ein Bucket-Label zurueckgegeben wurde
|
||
if predicted_bucket and isinstance(predicted_bucket, str) and not predicted_bucket.startswith("FEHLER"):
|
||
# Sammle Update fuer den AU Bucket (Geschaetzter Techniker Bucket) (nutzt interne Helfer)
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Geschaetzter Techniker Bucket"] + 1)}{row_num_in_sheet}', 'values': [[predicted_bucket]]}) # Block 1 Column Map
|
||
self.logger.info(f" -> ML-Schaetzung erfolgreich: Bucket '{predicted_bucket}'.") # <<< GEÄNDERT
|
||
else:
|
||
# Wenn die Vorhersage fehlschlug oder kein Ergebnis lieferte
|
||
self.logger.warning(f" -> ML-Schaetzung lieferte kein gueltiges Ergebnis: '{predicted_bucket}'.") # <<< GEÄNDERT
|
||
# Setze einen Fehlerwert im Sheet
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Geschaetzter Techniker Bucket"] + 1)}{row_num_in_sheet}', 'values': [['k.A. (Schaetzung fehlgeschlagen)']]}) # Block 1 Column Map
|
||
|
||
except Exception as e_ml:
|
||
# Wenn _predict_technician_bucket eine Exception wirft
|
||
self.logger.error(f"FEHLER bei ML-Schaetzung fuer Zeile {row_num_in_sheet}: {e_ml}") # <<< GEÄNDERT
|
||
# Logge den Traceback
|
||
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
|
||
# Fuege Fehler-Update hinzu, um den Fehler im Sheet zu dokumentieren
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Geschaetzter Techniker Bucket"] + 1)}{row_num_in_sheet}', 'values': [[f'FEHLER Schaetzung: {str(e_ml)[:50]}...']]}) # Block 1 Column Map
|
||
pass # Faert fort
|
||
|
||
|
||
# else if run_ml_step:
|
||
# Der ML-Schritt war angefordert, aber nicht noetig basierend auf Status/Re-Eval/Chat-Evaluation.
|
||
# self.logger.debug(f"Zeile {row_num_in_sheet}: Ueberspringe ML-Schaetzung (AU gesetzt oder Daten/Trigger fehlen).") # Zu viel Laerm im Debug
|
||
|
||
|
||
# ======================================================================
|
||
# === Abschluss der _process_single_row Verarbeitung ===================
|
||
# ======================================================================
|
||
|
||
# --- 5. Abschliessende Updates (Version, Tokens) ---
|
||
|
||
# Version (AP) wird gesetzt, wenn IRGENDEINE Verarbeitung in dieser Zeile stattgefunden hat.
|
||
if any_processing_done:
|
||
# Nutzt Config.VERSION (Block 1) und interne Helfer.
|
||
version_col_idx = COLUMN_MAP.get("Version") # Block 1 Column Map
|
||
if version_col_idx is not None:
|
||
# Fuege das Update fuer die Version zur Liste hinzu
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(version_col_idx + 1)}{row_num_in_sheet}', 'values': [[getattr(Config, 'VERSION', 'unknown')]]}) # Block 1 Column Map
|
||
else:
|
||
self.logger.error("FEHLER: Spaltenschluessel 'Version' nicht in COLUMN_MAP gefunden.") # <<< GEÄNDERT
|
||
|
||
# Tokens (AQ) - Hier ist die Zaehlung komplex, da mehrere OpenAI-Calls passiert sein koennten.
|
||
# Eine einfache Loesung ist, die Token-Zahl der letzten relevanten Antwort zu speichern
|
||
# oder die Token-Zahl der Prompts/Antworten waehrend des Laufs zu aggregieren.
|
||
# Eine Aggregation in den einzelnen Schritten (Web Summary, Branch Eval etc.) waere genauer.
|
||
# Wenn der Token-Count in den einzelnen OpenAI-Call-Methoden implementiert wird,
|
||
# muss er dort gesammelt und dann HIER in _process_single_row ins Update eingefuegt werden.
|
||
# Beispiel: Sie koennten ein Attribut self.current_row_token_count am Anfang von _process_single_row auf 0 setzen,
|
||
# und in jeder Methode (call_openai_chat, summarize_batch_openai), die Token nutzt, diesen Zaehler erhoehen.
|
||
# Dann hier:
|
||
# tokens_col_idx = COLUMN_MAP.get("Tokens") # Block 1 Column Map
|
||
# if tokens_col_idx is not None and hasattr(self, 'current_row_token_count') and self.current_row_token_count > 0:
|
||
# updates.append({'range': f'{self.sheet_handler._get_col_letter(tokens_col_idx + 1)}{row_num_in_sheet}', 'values': [[str(self.current_row_token_count)]]})
|
||
# else: # logger.debug("Keine Tokens zu speichern fuer diese Zeile."); # Zu viel Laerm im Debug
|
||
# if tokens_col_idx is None: self.logger.error("FEHLER: Spaltenschluessel 'Tokens' nicht in COLUMN_MAP gefunden."); # Block 1 Column Map
|
||
pass # Token-Zaehlung Implementierung erfordert Aenderungen in OpenAI Helpers und _process_single_row Init.
|
||
|
||
|
||
# --- 5b. ReEval Flag (A) loeschen (nur wenn im Re-Eval Modus und gewuenscht) ---
|
||
# Dieses Update wird am Ende der _process_single_row Methode hinzugefuegt,
|
||
# wenn der Aufruf aus process_reevaluation_rows (Block 25) mit clear_x_flag=True kam.
|
||
if force_reeval and clear_x_flag:
|
||
# Ermitteln Sie den Index der ReEval Flag Spalte
|
||
reeval_col_idx = COLUMN_MAP.get("ReEval Flag") # Block 1 Column Map
|
||
if reeval_col_idx is not None:
|
||
# Ermitteln Sie den Spaltenbuchstaben
|
||
flag_col_letter = self.sheet_handler._get_col_letter(reeval_col_idx + 1)
|
||
if flag_col_letter:
|
||
# Fuegen Sie das Update zum Loeschen des 'x'-Flags zur Liste hinzu.
|
||
# Es wird nur geloescht, wenn die Zeile ansonsten erfolgreich bis hierhin kam und Updates gesammelt wurden.
|
||
# Wenn eine schwere Exception in _process_single_row auftrat, wird dieser Block nicht erreicht.
|
||
updates.append({'range': f'{flag_col_letter}{row_num_in_sheet}', 'values': [['']]})
|
||
self.logger.debug(f" -> Update zum Loeschen des ReEval-Flags (A{row_num_in_sheet}) vorgemerkt.") # <<< GEÄNDERT
|
||
else:
|
||
# Logge Fehler, wenn Spaltenbuchstaben nicht ermittelt werden konnten
|
||
self.logger.error(f"FEHLER: Konnte Spaltenbuchstaben fuer 'ReEval Flag' ({reeval_col_idx+1}) nicht ermitteln. Flag kann nicht geloescht werden.") # <<< GEÄNDERT
|
||
else:
|
||
# Logge Fehler, wenn Spaltenindex fehlt
|
||
self.logger.error("FEHLER: 'ReEval Flag' Spaltenindex nicht in COLUMN_MAP gefunden. Flag kann nicht geloescht werden.") # <<< GEÄNDERT
|
||
|
||
|
||
# --- 6. Batch Update fuer diese Zeile ---
|
||
# Fuehren Sie das Batch-Update fuer ALLE gesammelten Aenderungen dieser EINEN Zeile durch.
|
||
if updates:
|
||
# Info-Log ueber die Anzahl der Updates fuer diese spezifische Zeile
|
||
self.logger.info(f"Zeile {row_num_in_sheet}: Sende Batch-Update mit {len(updates)} Operationen fuer diese Zeile...") # <<< GEÄNDERT
|
||
# Rufe die batch_update_cells Methode des Sheet Handlers auf.
|
||
# batch_update_cells ist mit retry_on_failure dekoriert und loggt intern.
|
||
success = self.sheet_handler.batch_update_cells(updates) # Nutzt die uebergeordnete Instanz
|
||
|
||
# Wenn der Batch-Update fehlschlaegt (nach Retries)
|
||
if not success:
|
||
# Logge einen Error
|
||
self.logger.error(f"Zeile {row_num_in_sheet}: ENDGUELTIGER FEHLER beim Batch-Update nach Retries.") # <<< GEÄNDERT
|
||
# Hier koennen Sie z.B. einen Fehlerindikator in eine spezielle Spalte schreiben
|
||
# (Dieses Update muesste separat oder im naechsten Lauf behandelt werden)
|
||
|
||
else:
|
||
# Info-Log, wenn nichts zu tun war in dieser Zeile
|
||
if not any_processing_done:
|
||
self.logger.debug(f"Zeile {row_num_in_sheet}: Keine Updates zum Schreiben (alle relevanten Schritte uebersprungen oder nicht angefordert).") # <<< GEÄNDERT
|
||
# else:
|
||
# Dieser Fall sollte nicht eintreten, wenn updates nicht leer ist, aber any_processing_done True ist.
|
||
# self.logger.warning(f"Zeile {row_num_in_sheet}: Updates Liste war leer, aber any_processing_done=True. Pruefen Sie die Logik.") # <<< GEÄNDERT
|
||
|
||
|
||
# Kleine Pause nach der Verarbeitung jeder Zeile, um API-Limits zu respektieren
|
||
# und die Belastung fuer das Google Sheet zu reduzieren.
|
||
# Der Wert sollte in Config (Block 1) angepasst werden. Eine kurze Pause ist auch bei Batch-Modi sinnvoll,
|
||
# wenn _process_single_row von dort aufgerufen wird (z.B. fuer Re-Eval).
|
||
# Nutzt Config.RETRY_DELAY (Block 1), ggf. kuerzer.
|
||
pause_duration = max(0.05, getattr(Config, 'RETRY_DELAY', 5) / 20.0) # Mindestens 50ms Wartezeit
|
||
# self.logger.debug(f"Wartezeit nach Zeile {row_num_in_sheet}: {pause_duration:.2f}s") # Zu viel Laerm im Debug
|
||
time.sleep(pause_duration)
|
||
|
||
# Logge den Abschluss der Verarbeitung fuer diese Zeile
|
||
self.logger.info(f"--- Verarbeitung fuer Zeile {row_num_in_sheet} abgeschlossen ---") # <<< GEÄNDERT
|
||
|
||
# --- Ende der _process_single_row Methode ---
|
||
|
||
|
||
# Die naechste Methode der DataProcessor Klasse folgt im naechsten Block.
|
||
# Dies ist die process_rows_sequentially Methode (Block 24).
|
||
|
||
# ==========================================================================
|
||
# === Prozess Methoden (Sequentiell) =======================================
|
||
# ==========================================================================
|
||
|
||
# --- Methode fuer sequentielle Verarbeitung (Modus full_run) ---
|
||
# Diese Methode verarbeitet Zeilen einzeln, nacheinander, im Gegensatz zu Batch-Modi.
|
||
# Sie ruft _process_single_row fuer jede Zeile im definierten Bereich auf.
|
||
# Nutzt interne Helfer: _process_single_row, _get_cell_value_safe.
|
||
# Nutzt globale Helfer: COLUMN_MAP, logger.
|
||
# Nutzt die uebergeordnete sheet_handler Instanz.
|
||
def process_rows_sequentially(self, start_sheet_row, num_to_process,
|
||
process_wiki_steps=True,
|
||
process_chatgpt_steps=True,
|
||
process_website_steps=True,
|
||
process_ml_steps=True, # Neues Flag fuer ML-Schritt
|
||
# Fuegen Sie hier ggf. weitere boolsche Flags fuer andere Schrittgruppen hinzu
|
||
force_reeval_in_single_row=False): # Optionale Steuerung fuer _process_single_row
|
||
"""
|
||
Verarbeitet eine feste Anzahl von Zeilen beginnend bei einer bestimmten
|
||
Sheet-Zeilennummer sequentiell, eine nach der anderen, unter Verwendung
|
||
von _process_single_row.
|
||
|
||
Args:
|
||
start_sheet_row (int): Die 1-basierte Zeilennummer im Sheet, ab der gestartet werden soll.
|
||
num_to_process (int): Die maximale Anzahl der zu verarbeitenden Zeilen.
|
||
process_wiki_steps (bool, optional): Soll der Wiki-Schritt in _process_single_row ausgefuehrt werden?. Defaults to True.
|
||
process_chatgpt_steps (bool, optional): Sollen ChatGPT-Schritte in _process_single_row ausgefuehrt werden?. Defaults to True.
|
||
process_website_steps (bool, optional): Soll der Website-Schritt in _process_single_row ausgefuehrt werden?. Defaults to True.
|
||
process_ml_steps (bool, optional): Soll der ML-Schritt in _process_single_row ausgefuehrt werden?. Defaults to True. # Neues Flag
|
||
# Fuegen Sie hier ggf. weitere boolsche Flags fuer andere Schrittgruppen hinzu.
|
||
force_reeval_in_single_row (bool, optional): Wenn True, wird force_reeval=True in _process_single_row
|
||
fuer alle verarbeiteten Zeilen in diesem Lauf gesetzt. Defaults to False.
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
# Annahme: header_rows ist als Attribut im SheetHandler verfuegbar.
|
||
header_rows = self.sheet_handler._header_rows
|
||
|
||
|
||
# Pruefen Sie, ob num_to_process gueltig ist
|
||
if num_to_process is None or not isinstance(num_to_process, int) or num_to_process <= 0:
|
||
self.logger.info("Sequentielle Verarbeitung uebersprungen: num_to_process ist ungueltig oder <= 0.") # <<< GEÄNDERT
|
||
return
|
||
|
||
# Logge die Konfiguration des sequentiellen Laufs
|
||
self.logger.info(f"Starte sequentielle Verarbeitung von {num_to_process} Zeilen ab Sheet-Zeile {start_sheet_row}...") # <<< GEÄNDERT
|
||
# Logge die ausgewaehlten Schritte fuer diesen Lauf
|
||
selected_steps_log = []
|
||
if process_wiki_steps: selected_steps_log.append("Wiki (wiki)")
|
||
if process_chatgpt_steps: selected_steps_log.append("ChatGPT (chat)")
|
||
if process_website_steps: selected_steps_log.append("Website (web)")
|
||
if process_ml_steps: selected_steps_log.append("ML Predict (ml_predict)") # Neues Flag
|
||
# Fuegen Sie hier weitere Schritte hinzu, wenn neue Flags existieren
|
||
self.logger.info(f" Ausgewaehlte Schritte fuer sequentiellen Lauf: {', '.join(selected_steps_log) if selected_steps_log else 'Keine ausgewählt!'}") # <<< GEÄNDERT
|
||
|
||
# Logge, ob force_reeval in _process_single_row gesetzt wird
|
||
if force_reeval_in_single_row:
|
||
self.logger.warning(" !!! force_reeval=True wird fuer alle Zeilen in _process_single_row gesetzt !!!") # <<< GEÄNDERT
|
||
|
||
# Erstelle das Set der Schluessel fuer die Schritte, die an _process_single_row uebergeben werden
|
||
steps_to_run_set = set()
|
||
if process_wiki_steps: steps_to_run_set.add('wiki')
|
||
if process_chatgpt_steps: steps_to_run_set.add('chat') # Annahme: 'chat' triggert alle ChatGPT Schritte in _process_single_row (Block 20)
|
||
if process_website_steps: steps_to_run_set.add('web')
|
||
if process_ml_steps: steps_to_run_set.add('ml_predict') # Neues Flag
|
||
# Fuegen Sie hier weitere Schluessel hinzu, wenn neue Flags verwendet werden
|
||
|
||
|
||
# Wenn keine Schritte ausgewaehlt wurden (trotz gueltigem num_to_process)
|
||
if not steps_to_run_set:
|
||
self.logger.warning("Keine Verarbeitungsschritte fuer sequentiellen Lauf ausgewaehlt (steps_to_run_set ist leer). Modus wird uebersprungen.") # <<< GEÄNDERT
|
||
return
|
||
|
||
|
||
# Lade Daten einmalig vor der Verarbeitung (nutzt die uebergeordnete Instanz)
|
||
# Der load_data Aufruf ist mit retry_on_failure dekoriert.
|
||
if not self.sheet_handler.load_data():
|
||
self.logger.error("Fehler beim Laden der Daten fuer sequentielle Verarbeitung.") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn das Laden fehlschlaegt
|
||
|
||
|
||
# Holen Sie die gesamte Datenliste (inklusive Header)
|
||
all_data = self.sheet_handler.get_all_data_with_headers()
|
||
total_sheet_rows = len(all_data)
|
||
|
||
|
||
# Berechnen Sie den tatsaechlichen Start-Index in der all_data Liste (0-basiert)
|
||
start_index_in_all_data = start_sheet_row - 1
|
||
|
||
# Pruefen Sie, ob der angegebene Startindex gueltig ist
|
||
if start_index_in_all_data >= total_sheet_rows:
|
||
self.logger.warning(f"Start-Sheet-Zeile {start_sheet_row} (Index {start_index_in_all_data}) liegt ausserhalb der verfuegbaren Daten ({total_sheet_rows} Zeilen insgesamt). Keine Verarbeitung.") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn der Startindex ungueltig ist
|
||
if start_index_in_all_data < header_rows:
|
||
# Wenn der Startindex innerhalb der Header liegt, beginnen Sie nach den Headern.
|
||
self.logger.warning(f"Start-Sheet-Zeile {start_sheet_row} liegt innerhalb der Header-Zeilen ({header_rows} Header). Verarbeitung startet ab Sheet-Zeile {header_rows + 1}.") # <<< GEÄNDERT
|
||
start_index_in_all_data = header_rows # Beginnen Sie direkt nach den Headern
|
||
|
||
|
||
# Berechne den tatsaechlichen End-Index in der all_data Liste (exklusiv)
|
||
# Der Endindex ist der Startindex + die Anzahl der zu verarbeitenden Zeilen.
|
||
# Stellen Sie sicher, dass der Endindex die Gesamtanzahl der Zeilen nicht ueberschreitet.
|
||
end_index_in_all_data = min(start_index_in_all_data + num_to_process, total_sheet_rows)
|
||
|
||
|
||
# Logge den Bereich der tatsaechlich zu verarbeitenden Zeilen
|
||
self.logger.info(f"Sequentielle Verarbeitung: Verarbeitungsbereich (0-basiert Index) [{start_index_in_all_data}, {end_index_in_all_data}). Entsprechende Sheet-Zeilen (1-basiert): {start_index_in_all_data + 1} bis {end_index_in_all_data}.") # <<< GEÄNDERT
|
||
|
||
# Pruefen Sie, ob es ueberhaupt Zeilen im berechneten Bereich gibt
|
||
if start_index_in_all_data >= end_index_in_all_data:
|
||
self.logger.info(f"Berechneter Startindex ({start_index_in_all_data}) liegt bei oder nach dem berechneten Endindex ({end_index_in_all_data}). Keine Zeilen im definierten Bereich zu verarbeiten.") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn der Bereich leer ist
|
||
|
||
|
||
processed_count = 0 # Zaehlt Zeilen, fuer die _process_single_row aufgerufen wurde.
|
||
# Iteriere ueber die Zeilen im definierten Bereich (0-basierter Index in all_data)
|
||
for i in range(start_index_in_all_data, end_index_in_all_data):
|
||
row_num_in_sheet = i + 1 # 1-basierte Zeilennummer im Sheet fuer _process_single_row
|
||
row_data = all_data[i] # Tatsaechliche Zeilendaten aus der Gesamtliste (0-basierter Index)
|
||
|
||
# Ueberspringen Sie Header-Zeilen explizit, falls der Startindex faelschlicherweise <= header_rows war
|
||
if row_num_in_sheet <= header_rows:
|
||
self.logger.debug(f"Ueberspringe Header-Zeile {row_num_in_sheet}.") # <<< GEÄNDERT
|
||
continue # Springe zur naechsten Iteration
|
||
|
||
|
||
# Stellen Sie sicher, dass die Zeile nicht leer ist oder nur aus leeren Strings besteht
|
||
# Nutzt die interne Helferfunktion _get_cell_value_safe implizit durch Iteration oder prueft direkt
|
||
# Eine einfache Pruefung: Ist irgendeine Zelle in der Zeile nicht leer oder None?
|
||
if not any(cell and isinstance(cell, str) and cell.strip() for cell in row_data):
|
||
self.logger.debug(f"Ueberspringe scheinbar leere Zeile {row_num_in_sheet}.") # <<< GEÄNDERT
|
||
continue # Springe zur naechsten Iteration
|
||
|
||
|
||
try:
|
||
# Rufe die Methode zur Verarbeitung einer einzelnen Zeile auf.
|
||
# _process_single_row wird intern die Timestamps pruefen (ausser wenn force_reeval=True).
|
||
# Uebergeben Sie die ausgewaehlten Schritte und das force_reeval Flag.
|
||
self._process_single_row(
|
||
row_num_in_sheet = row_num_in_sheet,
|
||
row_data = row_data, # Uebergibt die aktuellen Rohdaten der Zeile
|
||
steps_to_run = steps_to_run_set, # <-- Uebergibt die aus CLI/Menue ausgewaehlten Schritte
|
||
force_reeval = force_reeval_in_single_row, # <-- Steuert force_reeval in _process_single_row
|
||
clear_x_flag = False # Im sequentiellen Lauf wird das 'x'-Flag normalerweise NICHT geloescht
|
||
# (Dies wird nur im 'reeval' Modus (Block 25) benoetigt)
|
||
)
|
||
|
||
# Zaehlen, wenn _process_single_row erfolgreich aufgerufen wurde (unabhaengig von internen Ueberspringungen in _process_single_row).
|
||
processed_count += 1
|
||
|
||
except Exception as e_proc:
|
||
# Wenn _process_single_row einen Fehler wirft (nachdem interne Retries aufgaben),
|
||
# fangen wir ihn hier, loggen ihn und fahren mit der naechsten Zeile fort.
|
||
self.logger.exception(f"FEHLER bei sequentieller Verarbeitung von Zeile {row_num_in_sheet}: {e_proc}") # <<< GEÄNDERT
|
||
# Hier koennen Sie z.B. einen Fehlerindikator in eine spezielle Spalte im Sheet schreiben lassen.
|
||
# Dieses Update muesste dann separat oder im naechsten Lauf behandelt werden.
|
||
|
||
# _process_single_row beinhaltet bereits eine kleine Pause am Ende.
|
||
# Hier ist keine zusaetzliche Pause noetig, wenn _process_single_row erfolgreich war.
|
||
# Wenn _process_single_row eine Exception wirft, kann hier eine kurze Pause sinnvoll sein.
|
||
# time.sleep(0.1) # Optional: Kurze Pause bei Fehler
|
||
|
||
# Logge den Abschluss der sequentiellen Verarbeitung
|
||
self.logger.info(f"Sequentielle Verarbeitung abgeschlossen. {processed_count} Zeilen im Bereich [{start_sheet_row}, {end_index_in_all_data}] bearbeitet.") # <<< GEÄNDERT
|
||
|
||
|
||
# ==============================================================================
|
||
# Ende DataProcessor Klasse Prozess: Sequenziell Block
|
||
# ==============================================================================
|
||
|
||
# ==========================================================================
|
||
# === Prozess Methoden (Re-Evaluation) =====================================
|
||
# ==========================================================================
|
||
|
||
# --- Methode fuer den Re-Eval Modus (Spalte A = 'x') ---
|
||
# Diese Methode verarbeitet nur Zeilen, die in Spalte A mit 'x' markiert sind.
|
||
# Sie ruft _process_single_row fuer jede dieser Zeilen auf mit force_reeval=True
|
||
# und uebergibt die Auswahl der Schritte und das Flag zum Loeschen des 'x'-Flags.
|
||
# Nutzt interne Helfer: _process_single_row, _get_cell_value_safe.
|
||
# Nutzt globale Helfer: COLUMN_MAP, logger.
|
||
# Nutzt die uebergeordnete sheet_handler Instanz.
|
||
def process_reevaluation_rows(self, row_limit=None, clear_flag=True,
|
||
process_wiki_steps=True,
|
||
process_chatgpt_steps=True,
|
||
process_website_steps=True,
|
||
process_ml_steps=True # Neues Flag fuer ML-Schritt
|
||
# Fuegen Sie hier ggf. weitere boolsche Flags fuer andere Schrittgruppen hinzu
|
||
):
|
||
"""
|
||
Verarbeitet nur Zeilen, die in Spalte A mit 'x' markiert sind.
|
||
Ruft _process_single_row fuer jede dieser Zeilen auf mit force_reeval=True.
|
||
Verarbeitet maximal row_limit Zeilen.
|
||
Loescht optional das 'x'-Flag nach erfolgreicher Verarbeitung (innerhalb von _process_single_row).
|
||
Erlaubt die Auswahl spezifischer Verarbeitungsschritte.
|
||
|
||
Args:
|
||
row_limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None (Unbegrenzt).
|
||
clear_flag (bool, optional): Wenn True, wird das Flag 'x' in Spalte A
|
||
nach erfolgreicher Verarbeitung durch _process_single_row geloescht.
|
||
Defaults to True.
|
||
process_wiki_steps (bool, optional): Soll der Wiki-Schritt in _process_single_row ausgefuehrt werden?. Defaults to True.
|
||
process_chatgpt_steps (bool, optional): Sollen ChatGPT-Schritte in _process_single_row ausgefuehrt werden?. Defaults to True.
|
||
process_website_steps (bool, optional): Soll der Website-Schritt in _process_single_row ausgefuehrt werden?. Defaults to True.
|
||
process_ml_steps (bool, optional): Soll der ML-Schritt in _process_single_row ausgefuehrt werden?. Defaults to True. # Neues Flag
|
||
# Fuegen Sie hier ggf. weitere boolsche Flags fuer andere Schrittgruppen hinzu.
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
# Logge die Konfiguration des Re-Eval Laufs
|
||
self.logger.info(f"Starte Re-Evaluierungsmodus (Spalte A = 'x'). Max. Zeilen: {row_limit if row_limit is not None else 'Unbegrenzt'}") # <<< GEÄNDERT
|
||
# Logge die ausgewaehlten Schritte fuer diesen Lauf
|
||
selected_steps_log = []
|
||
if process_wiki_steps: selected_steps_log.append("Wiki (wiki)")
|
||
if process_chatgpt_steps: selected_steps_log.append("ChatGPT (chat)")
|
||
if process_website_steps: selected_steps_log.append("Website (web)")
|
||
if process_ml_steps: selected_steps_log.append("ML Predict (ml_predict)") # Neues Flag
|
||
# Fuegen Sie hier weitere Schritte hinzu, wenn neue Flags existieren
|
||
self.logger.info(f"Ausgewaehlte Schritte fuer Re-Eval: {', '.join(selected_steps_log) if selected_steps_log else 'Keine ausgewählt!'}") # <<< GEÄNDERT
|
||
|
||
|
||
# Erstelle das Set der Schluessel fuer die Schritte, die an _process_single_row uebergeben werden
|
||
steps_to_run_set = set()
|
||
if process_wiki_steps: steps_to_run_set.add('wiki')
|
||
if process_chatgpt_steps: steps_to_run_set.add('chat') # Annahme: 'chat' triggert alle ChatGPT Schritte in _process_single_row (Block 20)
|
||
if process_website_steps: steps_to_run_set.add('web')
|
||
if process_ml_steps: steps_to_run_set.add('ml_predict') # Neues Flag
|
||
# Fuegen Sie hier weitere Schluessel hinzu, wenn neue Flags verwendet werden
|
||
|
||
|
||
# Wenn keine Schritte ausgewaehlt wurden
|
||
if not steps_to_run_set:
|
||
self.logger.warning("Keine Verarbeitungsschritte fuer Re-Eval ausgewaehlt (steps_to_run_set ist leer). Modus wird uebersprungen.") # <<< GEÄNDERT
|
||
return
|
||
|
||
|
||
# Daten neu laden vor der Verarbeitung (nutzt die uebergeordnete Instanz)
|
||
# Der load_data Aufruf ist mit retry_on_failure dekoriert.
|
||
if not self.sheet_handler.load_data():
|
||
self.logger.error("Fehler beim Laden der Daten fuer Re-Evaluation.") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn das Laden fehlschlaegt
|
||
|
||
|
||
# Holen Sie die gesamte Datenliste (inklusive Header)
|
||
all_data = self.sheet_handler.get_all_data_with_headers()
|
||
# Annahme: header_rows ist als Attribut im SheetHandler verfuegbar.
|
||
header_rows = self.sheet_handler._header_rows
|
||
# Wenn keine Daten da sind oder nur Header
|
||
if not all_data or len(all_data) <= header_rows:
|
||
self.logger.warning("Keine Datenzeilen fuer Re-Evaluation gefunden.") # <<< GEÄNDERT
|
||
return # Beende die Methode
|
||
|
||
|
||
# Ermitteln Sie den Index der ReEval Flag Spalte aus COLUMN_MAP (Block 1)
|
||
reeval_col_idx = COLUMN_MAP.get("ReEval Flag")
|
||
if reeval_col_idx is None:
|
||
self.logger.critical("FEHLER: 'ReEval Flag' Spaltenindex nicht in COLUMN_MAP gefunden. Kann Zeilen mit 'x' nicht finden. Breche ab.") # <<< GEÄNDERT
|
||
return # Beende die Methode bei kritischem Fehler
|
||
|
||
|
||
# Sammeln Sie die Zeilen, die in Spalte A mit 'x' markiert sind.
|
||
# Sammeln Sie die Zeilen, die in Spalte A mit 'x' markiert sind.
|
||
rows_to_process = []
|
||
for idx_in_list in range(header_rows, len(all_data)):
|
||
row_data = all_data[idx_in_list]
|
||
row_num_in_sheet = idx_in_list + 1
|
||
cell_a_value = self._get_cell_value_safe(row_data, "ReEval Flag").strip().lower()
|
||
if cell_a_value == "x":
|
||
rows_to_process.append({'row_num': row_num_in_sheet, 'data': row_data})
|
||
|
||
found_count = len(rows_to_process)
|
||
self.logger.info(f"{found_count} Zeilen mit ReEval-Flag 'x' gefunden.")
|
||
if found_count == 0:
|
||
self.logger.info("Keine Zeilen zur Re-Evaluation markiert.")
|
||
return
|
||
|
||
# ----- KORREKTUR DER EINRÜCKUNG HIER -----
|
||
processed_count_actual = 0 # Neuer Zähler für tatsächlich verarbeitete Zeilen
|
||
# ----- ENDE KORREKTUR -----
|
||
|
||
for task in rows_to_process:
|
||
row_num = task['row_num']
|
||
row_data = task['data']
|
||
|
||
# Limit-Prüfung direkt hier, bevor eine Zeile bearbeitet wird
|
||
if row_limit is not None and isinstance(row_limit, int) and row_limit > 0 and processed_count_actual >= row_limit:
|
||
self.logger.info(f"Zeilenlimit ({row_limit}) fuer Re-Evaluation erreicht. Breche weitere Verarbeitung ab. Bereits verarbeitet: {processed_count_actual}")
|
||
break
|
||
|
||
self.logger.info(f"Bearbeite Re-Eval Zeile {row_num}...")
|
||
processed_count_actual += 1 # Inkrementiere, da diese Zeile jetzt verarbeitet wird
|
||
try:
|
||
self._process_single_row(
|
||
row_num_in_sheet = row_num,
|
||
row_data = row_data,
|
||
steps_to_run = steps_to_run_set,
|
||
force_reeval = True,
|
||
clear_x_flag = clear_flag
|
||
)
|
||
except Exception as e_proc_reval_main:
|
||
self.logger.exception(f"FEHLER bei Re-Evaluation von Zeile {row_num} in process_reevaluation_rows: {e_proc_reval_main}")
|
||
|
||
self.logger.info(f"Re-Evaluierung abgeschlossen. {processed_count_actual} Zeilen verarbeitet (Gefunden mit 'x': {found_count}, CLI-Limit war: {row_limit}).")
|
||
|
||
|
||
# ==============================================================================
|
||
# Ende DataProcessor Klasse Prozess: Re-Evaluation Block
|
||
# ==============================================================================
|
||
|
||
# ==========================================================================
|
||
# === Batch Processing Methods =============================================
|
||
# ==========================================================================
|
||
|
||
# --- Interne Hilfsfunktion fuer Wiki-Verifizierungs-Batch (OpenAI Call) ---
|
||
# Diese Funktion verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen ueber OpenAI.
|
||
# Sie wird von process_verification_batch (dieselben Block) aufgerufen.
|
||
# Nutzt globale Helfer: call_openai_chat (Block 8), logger, token_count (optional Block 3), retry_on_failure (Block 2), re.
|
||
@retry_on_failure # Wende den Decorator auf den gesamten Batch-API Call an
|
||
def _process_verification_openai_batch(self, batch_data):
|
||
"""
|
||
Verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen ueber OpenAI.
|
||
Sammelt die Ergebnisse und gibt sie zurueck. Aktualisiert NICHT das Sheet direkt.
|
||
|
||
Args:
|
||
batch_data (list): Liste von Dictionaries, jedes enthaelt:
|
||
{'row_num': int, 'company_name': str, 'crm_desc': str,
|
||
'wiki_url': str, 'wiki_paragraph': str, 'wiki_categories': str}
|
||
|
||
Returns:
|
||
dict: Ein Dictionary, das Zeilennummern auf die rohe ChatGPT-Antwort mappt.
|
||
z.B. {2122: "OK", 2123: "X | ..."}
|
||
Bei Fehlern oder fehlenden Antworten wird ein Fehlerstring verwendet.
|
||
Wirft Exception bei endgueltigen API-Fehlern nach Retries.
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
if not batch_data:
|
||
return {} # Gebe leeres Dictionary zurueck, wenn keine Tasks da sind
|
||
|
||
self.logger.debug(f"Sende OpenAI-Batch fuer Wiki-Verifizierung ({len(batch_data)} Zeilen, Start: {batch_data[0]['row_num'] if batch_data else 'N/A'})...") # <<< GEÄNDERT
|
||
|
||
# --- Prompt Erstellung ---
|
||
# Verwenden Sie klare Anweisungen und das definierte Antwortformat.
|
||
# Vermeiden Sie Umlaute im Prompt, um Encoding-Probleme zu minimieren.
|
||
aggregated_prompt = (
|
||
"Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln fuer Unternehmen. "
|
||
"Fuer jeden der folgenden Eintraege pruefe, ob der vorhandene Wikipedia-Artikel (URL, Absatz, Kategorien) plausibel zum Firmennamen und zur Beschreibung passt. "
|
||
"Gib das Ergebnis fuer jeden Eintrag ausschliesslich im folgenden Format auf einer neuen Zeile aus:\n"
|
||
"Eintrag <Zeilennummer>: <Antwort>\n\n"
|
||
"Moegliche Antworten:\n"
|
||
"- 'OK' (wenn der Artikel gut passt)\n"
|
||
"- 'X | Alternativer Artikel: <URL> | Begruendung: <Kurze Begruendung>' (wenn der Artikel nicht passt, aber ein besserer gefunden wurde)\n"
|
||
"- 'X | Kein passender Artikel gefunden | Begruendung: <Kurze Begruendung>' (wenn der Artikel nicht passt und kein besserer gefunden wurde)\n"
|
||
# Der Fall "Kein Wikipedia-Eintrag vorhanden" wird vom Skript VOR diesem Call behandelt
|
||
# und sollte hier nicht vom KI-Modell generiert werden.
|
||
"Stelle sicher, dass du nur EINE Zeile pro Eintrag im Format 'Eintrag X: Antwort' ausgibst.\n\n"
|
||
"Eintraege zur Pruefung:\n"
|
||
"--------------------\n"
|
||
)
|
||
|
||
# Fuegen Sie die Daten fuer jeden Eintrag im Batch hinzu.
|
||
# Kuerzen Sie die Beschreibungen und Kategorien, um das Prompt-Limit zu reduzieren.
|
||
# Stellen Sie sicher, dass die Werte Strings sind und "k.A." richtig behandelt wird.
|
||
max_desc_length = 200 # Maximale Laenge fuer Beschreibungsteile im Prompt
|
||
for item in batch_data:
|
||
row_num = item['row_num']
|
||
# Holen und Kuerzen Sie die Werte sicher. Ersetzen Sie None durch "k.A.".
|
||
company_name = str(item.get('company_name', 'k.A.'))
|
||
crm_desc = str(item.get('crm_desc', 'k.A.'))
|
||
wiki_url = str(item.get('wiki_url', 'k.A.'))
|
||
wiki_paragraph = str(item.get('wiki_paragraph', 'k.A.'))
|
||
wiki_categories = str(item.get('wiki_categories', 'k.A.'))
|
||
|
||
# Kuerzen Sie die Laengen und fuegen Sie "..." hinzu, wenn gekuerzt wurde.
|
||
crm_desc_short = crm_desc[:max_desc_length] + '...' if len(crm_desc) > max_desc_length else crm_desc
|
||
wiki_paragraph_short = wiki_paragraph[:max_desc_length] + '...' if len(wiki_paragraph) > max_desc_length else wiki_paragraph
|
||
wiki_categories_short = wiki_categories[:max_desc_length] + '...' if len(wiki_categories) > max_desc_length else wiki_categories
|
||
|
||
|
||
entry_text = (
|
||
f"Eintrag {row_num}:\n"
|
||
f" Firmenname: {company_name}\n"
|
||
f" CRM-Beschreibung: {crm_desc_short}\n"
|
||
f" Wikipedia-URL: {wiki_url}\n"
|
||
f" Wiki-Absatz: {wiki_paragraph_short}\n"
|
||
f" Wiki-Kategorien: {wiki_categories_short}\n"
|
||
f"----\n"
|
||
)
|
||
aggregated_prompt += entry_text
|
||
|
||
|
||
# Fuegen Sie den Abschluss des Prompts hinzu.
|
||
aggregated_prompt += "--------------------\nBitte nur die 'Eintrag X: Antwort'-Zeilen ausgeben."
|
||
|
||
# Optional: Token zaehlen fuer den Prompt.
|
||
# try: prompt_tokens = token_count(aggregated_prompt, model=getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo')); self.logger.debug(f"Geschaetzt Prompt-Tokens fuer Batch: {prompt_tokens}.");
|
||
# except Exception as e_tc: self.logger.debug(f"Fehler beim Token-Zaehlen: {e_tc}");
|
||
|
||
|
||
# --- ChatGPT Aufruf ---
|
||
# call_openai_chat (Block 8) nutzt den retry_on_failure Decorator und wirft bei endgueltigem Fehler eine Exception.
|
||
# Der retry_on_failure Decorator auf dieser Funktion faengt die Exception
|
||
# von call_openai_chat und fuehrt die Retries fuer die GESAMTE Batch-Funktion durch.
|
||
chat_response = None
|
||
try:
|
||
# Rufe die zentrale OpenAI Chat API Funktion auf (Block 8).
|
||
# Standard Temperatur 0.0 fuer Klassifizierung/Verifizierung.
|
||
chat_response = call_openai_chat(aggregated_prompt, temperature=0.0)
|
||
# Wenn call_openai_chat erfolgreich ist, gibt es den String zurueck.
|
||
# Exceptions werden nach Retries von call_openai_chat geworfen und vom aeusseren retry_on_failure dieser Funktion gefangen.
|
||
|
||
if not chat_response:
|
||
# Dieser Fall sollte nach der Aenderung in call_openai_chat (wirft Exception) nicht mehr auftreten.
|
||
self.logger.error("call_openai_chat gab unerwarteterweise None zurueck fuer Wiki-Verifizierungs-Batch.") # <<< GEÄNDERT
|
||
# Werfen Sie eine spezifische Exception, damit der aeussere Decorator sie faengt.
|
||
raise openai.error.APIError("Keine Antwort von OpenAI erhalten fuer Wiki-Verifizierungs-Batch.")
|
||
|
||
|
||
except Exception as e:
|
||
# Wenn call_openai_chat oder der aeussere retry_on_failure eine Exception wirft (nach Retries)
|
||
# Die Exception wird hier gefangen, bevor sie an den Aufrufer (process_verification_batch) weitergeleitet wird.
|
||
self.logger.error(f"Endgueltiger FEHLER beim OpenAI-Batch-Aufruf fuer Wiki-Verifizierung (innerhalb Batch Decorator): {e}") # <<< GEÄNDERT
|
||
# Logge den Traceback
|
||
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
|
||
# Geben Sie ein Dictionary zurueck, das signalisiert, dass fuer alle Zeilen im Batch ein Fehler aufgetreten ist
|
||
return {item['row_num']: f"FEHLER API: {str(e)[:100]}" for item in batch_data}
|
||
|
||
|
||
# --- Antwort parsen ---
|
||
answers = {} # Initialisieren Sie das Ergebnis-Dictionary
|
||
# Liste der Zeilennummern, die im ursprünglichen Batch angefragt wurden
|
||
original_batch_row_nums = {item['row_num'] for item in batch_data}
|
||
lines = chat_response.strip().split('\n')
|
||
parsed_count = 0
|
||
for line in lines:
|
||
# Matcht "Eintrag <Zeilennummer>:" und den Rest der Zeile
|
||
match = re.match(r"Eintrag (\d+): (.*)", line.strip())
|
||
if match:
|
||
row_num = int(match.group(1))
|
||
answer_text = match.group(2).strip()
|
||
# Pruefen Sie, ob die Zeilennummer im urspruenglichen Batch angefragt wurde
|
||
if row_num in original_batch_row_nums:
|
||
answers[row_num] = answer_text
|
||
parsed_count += 1
|
||
# else: self.logger.debug(f"Warnung: Antwort fuer unerwartete Zeilennummer {row_num} im Batch erhalten: {answer_text[:100]}...") # Zu viel Laerm (gekuerzt loggen)
|
||
|
||
# Logge das Ergebnis des Parsens
|
||
self.logger.debug(f"OpenAI-Batch-Antwort geparst: {parsed_count} von {len(original_batch_row_nums)} Zeilen erfolgreich zugeordnet.") # <<< GEÄNDERT
|
||
|
||
# Fuegen Sie einen Fehlerwert fuer Zeilen hinzu, die nicht geparst werden konnten (z.B. falsches Antwortformat)
|
||
if parsed_count < len(original_batch_row_nums):
|
||
self.logger.warning(f"Nicht alle Zeilen aus dem Batch ({len(original_batch_row_nums)}) konnten in der OpenAI-Antwort ({len(lines)} Zeilen) geparst werden.") # <<< GEÄNDERT
|
||
# Logge den Anfang der unvollstaendigen Antwort auf Debug
|
||
self.logger.debug(f"Unerwartete Antwortteile (erste 500 Zeichen): {chat_response[:500]}") # <<< GEÄNDERT
|
||
for row_num in original_batch_row_nums:
|
||
if row_num not in answers:
|
||
answers[row_num] = "FEHLER: Antwort nicht geparst"
|
||
|
||
|
||
# Die 'answers' Dictionary enthaelt nun Ergebnisse fuer alle Zeilen, entweder geparst oder mit einem Fehlerstring.
|
||
return answers # Rueckgabe des Dictionarys mit Ergebnissen oder Fehlern
|
||
|
||
|
||
# --- Methode fuer den Wiki-Verifizierungs-Batchmodus (AX) ---
|
||
# Diese Methode koordiniert die Auswahl der Zeilen, die Batch-Verarbeitung durch OpenAI,
|
||
# und das Schreiben der Ergebnisse (S, T, U, V-Y, AX, AP) ins Sheet.
|
||
# Basierend auf process_verification_only und _process_batch aus Teil 8.
|
||
# Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter, _process_verification_openai_batch (derselben Block).
|
||
# Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), datetime, time.
|
||
# Nutzt die uebergeordnete sheet_handler Instanz (Block 14).
|
||
def process_verification_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None):
|
||
"""
|
||
Batch-Prozess nur fuer Wikipedia-Verifizierung (Spalten S-U, V-Y werden geleert).
|
||
Laedt Daten neu, prueft fuer jede Zeile im Bereich, ob Timestamp AX (Wiki Verif.)
|
||
bereits gesetzt ist, ob eine Wiki URL (M) vorhanden ist und ob Status S
|
||
nicht bereits 'OK', 'X (URL Copied)' oder 'X (Invalid Suggestion)' ist.
|
||
Setzt AX + AP fuer bearbeitete Zeilen und schreibt S-U in Batches.
|
||
|
||
Args:
|
||
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AX).
|
||
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
|
||
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht uebersprungener) Zeilen. Defaults to None (Unbegrenzt).
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
# Logge die Konfiguration des Batch-Laufs
|
||
self.logger.info(f"Starte Wikipedia-Verifizierungsmodus (Batch S-U, AX). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT
|
||
|
||
|
||
# --- Daten laden und Startzeile ermitteln ---
|
||
# Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt
|
||
if start_sheet_row is None:
|
||
self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AX...") # <<< GEÄNDERT
|
||
# Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AX (Block 1 Column Map).
|
||
# Standardmaessig ab Zeile 7
|
||
start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Wiki Verif. Timestamp", min_sheet_row=7)
|
||
|
||
# Wenn get_start_row_index -1 zurueckgibt (Fehler)
|
||
if start_data_index_no_header == -1:
|
||
self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche Batch ab.") # <<< GEÄNDERT
|
||
return # Beende die Methode
|
||
|
||
# Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten Daten-Index
|
||
start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut
|
||
self.logger.info(f"Automatisch ermittelte Startzeile (erste leere AX Zelle): {start_sheet_row}") # <<< GEÄNDERT
|
||
else:
|
||
# Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein.
|
||
# Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2).
|
||
if not self.sheet_handler.load_data():
|
||
self.logger.error("FEHLER beim Laden der Daten fuer process_verification_batch.") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn das Laden fehlschlaegt
|
||
|
||
|
||
# Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler.
|
||
all_data = self.sheet_handler.get_all_data_with_headers()
|
||
# Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14).
|
||
header_rows = self.sheet_handler._header_rows
|
||
total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet
|
||
|
||
# Berechne Endzeile, wenn nicht manuell gesetzt
|
||
if end_sheet_row is None:
|
||
end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
|
||
|
||
# Logge den verarbeitungsbereich
|
||
self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT
|
||
|
||
# Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen)
|
||
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
|
||
self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn der Bereich leer ist
|
||
|
||
|
||
# --- Indizes und Buchstaben ---
|
||
# Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind
|
||
required_keys = [
|
||
"Wiki Verif. Timestamp", "Wiki URL", "Chat Wiki Konsistenzpruefung", # Pruefkriterien / Timestamp (AX, M, S)
|
||
"CRM Name", "CRM Beschreibung", "Wiki Absatz", "Wiki Kategorien", # Daten fuer Prompt (B, F, N, R)
|
||
"Chat Begruendung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", # Ergebnisspalten (T, U)
|
||
"Begruendung bei Abweichung", "Chat Begruendung Abweichung Branche", # Spalten V-Y zum Leeren
|
||
"Wikipedia Timestamp", "Timestamp letzte Pruefung", # Spalten AN, AO zum Leeren
|
||
"Version", "SerpAPI Wiki Search Timestamp" # Spalten AP, AY zum Leeren
|
||
]
|
||
# Erstellen Sie ein Dictionary mit Schluesseln und Indizes
|
||
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
|
||
|
||
# Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden
|
||
if None in col_indices.values():
|
||
missing = [k for k, v in col_indices.items() if v is None]
|
||
self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_verification_batch: {missing}. Breche ab.") # <<< GEÄNDERT
|
||
return # Beende die Methode bei kritischem Fehler
|
||
|
||
|
||
# Ermitteln Sie die Spaltenbuchstaben fuer Updates und Leerung (nutzt interne Helfer _get_col_letter Block 14)
|
||
ts_ax_letter = self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"] + 1) # Timestamp zu setzen (AX)
|
||
s_letter = self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzpruefung"] + 1) # Status S
|
||
t_letter = self.sheet_handler._get_col_letter(col_indices["Chat Begruendung Wiki Inkonsistenz"] + 1) # Begruendung T
|
||
u_letter = self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"] + 1) # Vorschlag U
|
||
|
||
# Spalten V-Y leeren (werden in diesem Modus nicht neu befuellt).
|
||
# V ist Begruendung bei Abweichung (von Wiki-URL Pruefung CRM vs Wiki).
|
||
# Y ist Begruendung Abweichung Branche (von Chat).
|
||
v_idx = col_indices["Begruendung bei Abweichung"]
|
||
y_idx = col_indices["Chat Begruendung Abweichung Branche"] # Block 1 Column Map
|
||
# Erstellen Sie den Bereichsnamen (z.B. "V:Y")
|
||
v_letter = self.sheet_handler._get_col_letter(v_idx + 1)
|
||
y_letter = self.sheet_handler._get_col_letter(y_idx + 1)
|
||
v_y_range_letter = f'{v_letter}:{y_letter}' # z.B. V:Y
|
||
# Erstellen Sie eine Liste von leeren Strings fuer diesen Bereich
|
||
empty_vy_values = [''] * (y_idx - v_idx + 1) # Anzahl der Spalten = Y_Index - V_Index + 1
|
||
|
||
|
||
# Timestamps AN, AO, AP, AY leeren.
|
||
# Diese werden von anderen Schritten gesetzt und sollen hier zurueckgesetzt werden,
|
||
# um sicherzustellen, dass die Zeile bei Bedarf von diesen anderen Schritten erneut bearbeitet wird.
|
||
an_letter = self.sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"] + 1) # AN (Wiki Extraction TS)
|
||
ao_letter = self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Pruefung"] + 1) # AO (Chat Evaluation TS)
|
||
ap_letter = self.sheet_handler._get_col_letter(col_indices["Version"] + 1) # AP (Version)
|
||
ay_letter = self.sheet_handler._get_col_letter(col_indices["SerpAPI Wiki Search Timestamp"] + 1) # AY (SerpAPI Wiki TS)
|
||
|
||
|
||
# --- Verarbeitung ---
|
||
# Holen Sie die Batch-Groesse fuer OpenAI-Aufrufe aus Config (Block 1)
|
||
openai_batch_size = getattr(Config, 'PROCESSING_BATCH_SIZE', 20) # Nutzt dieselbe Batch-Groesse wie Scraping/Summarization
|
||
# Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1)
|
||
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)
|
||
|
||
|
||
current_openai_batch_data = [] # Daten fuer den aktuellen OpenAI Batch (Liste von Dicts)
|
||
rows_in_current_openai_batch = [] # 1-basierte Zeilennummern im aktuellen OpenAI Batch
|
||
all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts)
|
||
|
||
|
||
processed_count = 0 # Zaehlt Zeilen, die fuer die Verarbeitung in Frage kommen und in den Batch aufgenommen werden (im Rahmen des Limits).
|
||
skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (wegen Status, fehlender Daten etc.).
|
||
skipped_no_wiki_url = 0 # Zaehlt Zeilen, die speziell wegen fehlender M-URL uebersprungen wurden.
|
||
|
||
|
||
# Iteriere ueber die Sheet-Zeilen im definierten Bereich (1-basierte Sheet-Zeilennummer)
|
||
for i in range(start_sheet_row, end_sheet_row + 1):
|
||
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
|
||
# Pruefen Sie, ob das Ende des Sheets erreicht wurde
|
||
if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht
|
||
|
||
|
||
row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile
|
||
|
||
|
||
# Stellen Sie sicher, dass die Zeile nicht leer ist (mindestens Name vorhanden)
|
||
# Nutzt interne Helfer _get_cell_value_safe
|
||
company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map
|
||
if not company_name:
|
||
self.logger.debug(f"Zeile {i}: Uebersprungen (Kein Firmenname in Spalte B).") # <<< GEÄNDERT
|
||
skipped_count += 1 # Zaehlen als uebersprungen
|
||
continue # Springe zur naechsten Zeile
|
||
|
||
# --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
|
||
# Kriterium: Wiki Verif. Timestamp (AX) ist leer
|
||
# UND Wiki URL (M) ist gefuellt und gueltig aussehend (nicht k.A., Fehler etc.)
|
||
# UND Status S ist NICHT bereits in einem Endzustand (OK, X (UPDATED/COPIED/INVALID)).
|
||
|
||
# Holen Sie die Werte aus den entsprechenden Spalten (nutzt interne Helfer)
|
||
ax_value = self._get_cell_value_safe(row, "Wiki Verif. Timestamp").strip() # Block 1 Column Map
|
||
m_value = self._get_cell_value_safe(row, "Wiki URL").strip() # Block 1 Column Map
|
||
s_value_upper = self._get_cell_value_safe(row, "Chat Wiki Konsistenzpruefung").strip().upper() # Block 1 Column Map
|
||
|
||
# Pruefen Sie, ob die Wiki URL (M) gueltig aussieht
|
||
is_wiki_url_valid_looking = m_value and isinstance(m_value, str) and "wikipedia.org/wiki/" in m_value.lower() and m_value.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"] # Fuege "http:" hinzu basierend auf Log
|
||
|
||
|
||
# Definieren Sie die Endzustaende von Status S (Grossbuchstaben)
|
||
s_end_states = ["OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)"]
|
||
# Pruefen Sie, ob Status S in einem Endzustand ist
|
||
is_s_in_endstate = s_value_upper in s_end_states # Bugfix: Korrekte Zuweisung
|
||
|
||
|
||
# Verarbeitung ist noetig, wenn AX leer UND M gefuellt/gueltig aussieht UND S NICHT im Endzustand ist.
|
||
processing_needed_for_row = not ax_value and is_wiki_url_valid_looking and not is_s_in_endstate
|
||
|
||
|
||
# Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
|
||
log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
|
||
if log_check:
|
||
self.logger.debug(f"Zeile {i} ({company_name[:50]}... Wiki Verif. Check): AX leer? {not ax_value}, M gueltig? {is_wiki_url_valid_looking}, S ('{s_value_upper}') Endzustand? {is_s_in_endstate}. Benötigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT
|
||
|
||
|
||
# Wenn die Verarbeitung fuer diese Zeile nicht noetig ist
|
||
if not processing_needed_for_row:
|
||
skipped_count += 1 # Zaehlen als uebersprungene Zeile
|
||
# Zaehlen Sie separat, wenn die Zeile speziell wegen fehlender M-URL uebersprungen wurde
|
||
if not is_wiki_url_valid_looking: skipped_no_wiki_url += 1
|
||
continue # Springe zur naechsten Zeile
|
||
|
||
|
||
# --- Wenn Verarbeitung noetig: Fuege zur Batch-Liste fuer OpenAI hinzu ---
|
||
processed_count += 1 # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im Rahmen des Limits zaehlen)
|
||
|
||
# Pruefe das Limit fuer verarbeitete Zeilen
|
||
if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit:
|
||
# Wenn das Limit erreicht ist und es ein positives Limit gibt
|
||
self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_verification_batch erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
|
||
break # Brich die Schleife ab
|
||
|
||
|
||
# Sammle die benoetigten Daten fuer den OpenAI Prompt (Block 26 - _process_verification_openai_batch).
|
||
# Diese Daten werden in einem Dictionary fuer den Batch gesammelt.
|
||
crm_desc = self._get_cell_value_safe(row, "CRM Beschreibung") # Block 1 Column Map
|
||
wiki_paragraph = self._get_cell_value_safe(row, "Wiki Absatz") # Block 1 Column Map
|
||
wiki_categories = self._get_cell_value_safe(row, "Wiki Kategorien") # Block 1 Column Map
|
||
|
||
|
||
# Fuege die Daten dieser Zeile zur aktuellen Batch-Liste fuer OpenAI hinzu
|
||
current_openai_batch_data.append({
|
||
'row_num': i, # Die 1-basierte Sheet-Zeilennummer
|
||
'company_name': company_name, # Nutzt den initial geladenen Namen
|
||
'crm_desc': crm_desc,
|
||
'wiki_url': m_value, # Nutzt die M-URL aus dem Sheet
|
||
'wiki_paragraph': wiki_paragraph,
|
||
'wiki_categories': wiki_categories
|
||
})
|
||
# Fuege die Zeilennummer zur Liste der Zeilennummern im Batch hinzu
|
||
rows_in_current_openai_batch.append(i)
|
||
|
||
|
||
# --- Verarbeite den Batch, wenn voll ---
|
||
# Pruefe, ob die aktuelle Batch-Liste die definierte Groesse erreicht hat.
|
||
# openai_batch_size wird aus Config geholt (Block 1).
|
||
if len(current_openai_batch_data) >= openai_batch_size:
|
||
# Logge den Start der Batch-Verarbeitung
|
||
batch_start_row = current_openai_batch_data[0]['row_num']
|
||
batch_end_row = current_openai_batch_data[-1]['row_num']
|
||
self.logger.debug(f"\n--- Starte Wiki-Verifizierungs-Batch ({len(current_openai_batch_data)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") # <<< GEÄNDERT
|
||
|
||
|
||
# Rufe die interne Methode auf, die den OpenAI Call fuer den Batch macht.
|
||
# _process_verification_openai_batch (Block 26) ist mit retry_on_failure dekoriert.
|
||
# Wenn _process_verification_openai_batch eine Exception wirft (nach Retries), wird diese hier gefangen.
|
||
batch_results = self._process_verification_openai_batch(current_openai_batch_data)
|
||
# Ergebnisse sollten ein Dictionary {row_num: raw_chatgpt_answer} sein, auch bei Fehlern.
|
||
|
||
|
||
# Sammle Sheet Updates basierend auf den Batch-Ergebnissen.
|
||
# Setze immer den Timestamp AX und die Werte in S, T, U und V-Y.
|
||
# Der aktuelle Zeitstempel fuer den Batch
|
||
current_batch_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
batch_sheet_updates = [] # Updates fuer DIESEN spezifischen Batch von Zeilen
|
||
|
||
|
||
# Iteriere ueber die Zeilennummern, die in DIESEM OpenAI Batch waren
|
||
for row_num in rows_in_current_openai_batch:
|
||
# Hole das Ergebnis fuer diese Zeile aus dem Ergebnis-Dictionary.
|
||
# Fallback auf einen Fehlerstring, wenn das Ergebnis fehlt (sollte nicht passieren, wenn _process_verification_openai_batch korrekt ist).
|
||
answer = batch_results.get(row_num, "FEHLER: Batch-Ergebnis fehlt")
|
||
# self.logger.debug(f"Zeile {row_num} Verifizierungsantwort: '{answer[:100]}...'") # Zu viel Laerm (gekuerzt)
|
||
|
||
|
||
# Logik zur Bestimmung der Werte fuer S, T, U basierend auf 'answer' (aehnlich wie in altem _process_batch)
|
||
wiki_confirm, alt_article, wiki_explanation = "", "", "" # Initialisiere mit leeren Strings
|
||
|
||
# Pruefe auf Standard-Antworten und Fehler-Antworten
|
||
if isinstance(answer, str) and answer.upper() == "OK":
|
||
wiki_confirm = "OK"
|
||
wiki_explanation = "Passt laut KI zur Firma." # Standard Begruendung bei OK
|
||
elif isinstance(answer, str) and answer.startswith("X |"):
|
||
# Parse die Antwort im Format "X | <Detail> | <Begruendung>"
|
||
parts = answer.split("|", 2) # Teile maximal in 3 Teile
|
||
wiki_confirm = "X" # Status ist X
|
||
if len(parts) > 1:
|
||
detail = parts[1].strip() # Zweiter Teil ist Detail (Alternative URL oder "Kein passender Artikel gefunden")
|
||
if detail.lower().startswith("alternativer artikel:"):
|
||
alt_article = detail.split(":", 1)[1].strip() # Extrahiere URL
|
||
elif detail.lower() == "kein passender artikel gefunden":
|
||
alt_article = detail # Text "Kein passender Artikel gefunden"
|
||
else:
|
||
alt_article = detail # Unbekanntes Detail
|
||
|
||
if len(parts) > 2:
|
||
reason_part = parts[2].strip() # Dritter Teil ist Begruendung
|
||
if reason_part.lower().startswith("begruendung:"):
|
||
wiki_explanation = reason_part.split(":", 1)[1].strip() # Extrahiere Begruendungstext
|
||
else:
|
||
wiki_explanation = reason_part # Unbekannte Begruendung
|
||
|
||
# Fuege ggf. den rohen Antworttext zur Begruendung hinzu, wenn Parsing unvollstaendig war
|
||
if not alt_article or not wiki_explanation:
|
||
wiki_explanation += f" (Rohantwort: {answer[:100]}...)"
|
||
|
||
|
||
elif isinstance(answer, str) and answer.startswith("FEHLER"):
|
||
# Wenn die Batch-Verarbeitung einen Fehler zurueckgegeben hat
|
||
wiki_confirm = "FEHLER"
|
||
wiki_explanation = answer # Fehlermeldung in Begruendung schreiben
|
||
alt_article = "Siehe Begruendung" # Verweis auf Begruendung
|
||
|
||
else: # Unerwartetes Format der Antwort (weder OK noch X | noch FEHLER)
|
||
wiki_confirm = "?" # Setze Status auf unbekannt
|
||
wiki_explanation = f"Unerwartetes Format: {str(answer)[:100]}..." # Speichere Anfang der Antwort in Begruendung (gekuerzt)
|
||
alt_article = "Siehe Begruendung" # Verweis auf Begruendung
|
||
|
||
# Spalten V-Y (Begruendung bei Abweichung etc.) werden in diesem Modus geleert
|
||
# Fuer jede Zeile im Batch fuegen wir das Update hinzu.
|
||
# empty_vy_values wurde oben vorbereitet.
|
||
v_y_values = empty_vy_values # Liste von leeren Strings
|
||
# Fuege Update zum Leeren von V-Y hinzu, falls Index gefunden wurde
|
||
if v_y_range_letter: # Pruefe, ob der Bereichsname ermittelt werden konnte
|
||
batch_sheet_updates.append({'range': f'{v_letter}{row_num}:{y_letter}{row_num}', 'values': [v_y_values]}) # Block 1 Column Map, interne Helfer
|
||
|
||
|
||
# Fuege Updates fuer S, T, U und AX hinzu (nutzt interne Helfer)
|
||
batch_sheet_updates.append({'range': f'{s_letter}{row_num}', 'values': [[wiki_confirm]]}) # Block 1 Column Map
|
||
batch_sheet_updates.append({'range': f'{t_letter}{row_num}', 'values': [[alt_article]]}) # Block 1 Column Map
|
||
batch_sheet_updates.append({'range': f'{u_letter}{row_num}', 'values': [[wiki_explanation]]}) # Block 1 Column Map
|
||
# Setze AX Timestamp fuer diese Zeile
|
||
batch_sheet_updates.append({'range': f'{ts_ax_letter}{row_num}', 'values': [[current_batch_timestamp]]}) # Block 1 Column Map
|
||
|
||
|
||
# --- Sende gesammelte Updates fuer diesen Batch ---
|
||
# Sammle die Updates fuer diesen Batch in der globalen Liste.
|
||
# all_sheet_updates.extend(batch_sheet_updates) # Nicht hier sammeln, sondern direkt senden
|
||
|
||
# Sende die gesammelten Updates fuer DIESEN Batch sofort.
|
||
if batch_sheet_updates:
|
||
self.logger.debug(f" Sende Sheet-Update fuer {len(rows_in_current_openai_batch)} Zeilen ({len(batch_sheet_updates)} Zellen) dieses Batches...") # <<< GEÄNDERT
|
||
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
|
||
# Wenn es fehlschlaegt, wird es intern geloggt.
|
||
success = self.sheet_handler.batch_update_cells(batch_sheet_updates)
|
||
if success:
|
||
self.logger.info(f" Sheet-Update fuer Wiki-Verifizierungs-Batch Zeilen {batch_start_row}-{batch_end_row} erfolgreich.") # <<< GEÄNDERT
|
||
# Der Fehlerfall wird von batch_update_cells geloggt
|
||
|
||
# Setze Batch-Listen zurueck fuer die naechste Iteration
|
||
current_openai_batch_data = []
|
||
rows_in_current_openai_batch = []
|
||
|
||
# Pause nach jedem OpenAI Batch (nutzt Config Block 1).
|
||
# Dies ist wichtig, um Rate Limits zu vermeiden.
|
||
# Nutze Config.RETRY_DELAY, ggf. kuerzer, da es ein Batch war
|
||
pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.5 # 50% der Retry-Wartezeit
|
||
self.logger.debug(f"--- Batch Zeilen {batch_start_row}-{batch_end_row} abgeschlossen. Warte {pause_duration:.2f}s vor naechstem Batch ---") # <<< GEÄNDERT
|
||
time.sleep(pause_duration)
|
||
|
||
|
||
# --- Verarbeitung des letzten unvollstaendigen Batches nach der Schleife ---
|
||
# Wenn nach der Hauptschleife noch Tasks in der Batch-Liste sind
|
||
if current_openai_batch_data:
|
||
# Logge den Start des finalen Batches
|
||
batch_start_row = current_openai_batch_data[0]['row_num']
|
||
batch_end_row = current_openai_batch_data[-1]['row_num']
|
||
self.logger.debug(f"\n--- Starte FINALEN Wiki-Verifizierungs-Batch ({len(current_openai_batch_data)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") # <<< GEÄNDERT
|
||
|
||
# Rufe die interne Methode auf, die den OpenAI Call macht
|
||
batch_results = self._process_verification_openai_batch(current_openai_batch_data)
|
||
# Ergebnisse sollten ein Dictionary {row_num: raw_chatgpt_answer} sein, auch bei Fehlern.
|
||
|
||
# Sammle Sheet Updates (S, T, U, V-Y, AX) fuer diesen finalen Batch
|
||
current_batch_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
batch_sheet_updates = [] # Updates fuer DIESEN spezifischen Batch von Zeilen
|
||
|
||
# Iteriere ueber die Zeilennummern, die in DIESEM finalen OpenAI Batch waren
|
||
for row_num in rows_in_current_openai_batch:
|
||
# Hole das Ergebnis fuer diese Zeile aus dem Ergebnis-Dictionary.
|
||
answer = batch_results.get(row_num, "FEHLER: Batch-Ergebnis fehlt") # Fallback
|
||
|
||
# Logik zur Bestimmung der Werte fuer S, T, U basierend auf 'answer'
|
||
wiki_confirm, alt_article, wiki_explanation = "", "", ""
|
||
# Leere V-Y Spalten
|
||
v_y_values = empty_vy_values # Liste von leeren Strings
|
||
if isinstance(answer, str) and answer.upper() == "OK": wiki_confirm = "OK"; wiki_explanation = "Passt laut KI zur Firma."
|
||
elif isinstance(answer, str) and answer.startswith("X |"):
|
||
parts = answer.split("|", 2); wiki_confirm = "X"
|
||
if len(parts) > 1: detail = parts[1].strip(); alt_article = detail.split(":", 1)[1].strip() if detail.lower().startswith("alternativer artikel:") else detail
|
||
if len(parts) > 2: reason_part = parts[2].strip(); wiki_explanation = reason_part.split(":", 1)[1].strip() if reason_part.lower().startswith("begruendung:") else reason_part
|
||
if not alt_article or not wiki_explanation: wiki_explanation += f" (Rohantwort: {answer[:100]}...)"
|
||
elif isinstance(answer, str) and answer.startswith("FEHLER"): wiki_confirm = "FEHLER"; wiki_explanation = answer; alt_article = "Siehe Begruendung"
|
||
else: wiki_confirm = "?"; wiki_explanation = f"Unerwartetes Format: {str(answer)[:100]}..."; alt_article = "Siehe Begruendung"
|
||
|
||
|
||
# Fuege Updates fuer S, T, U und AX hinzu
|
||
batch_sheet_updates.append({'range': f'{s_letter}{row_num}', 'values': [[wiki_confirm]]}) # Block 1 Column Map
|
||
batch_sheet_updates.append({'range': f'{t_letter}{row_num}', 'values': [[alt_article]]}) # Block 1 Column Map
|
||
batch_sheet_updates.append({'range': f'{u_letter}{row_num}', 'values': [[wiki_explanation]]}) # Block 1 Column Map
|
||
# Setze AX Timestamp
|
||
batch_sheet_updates.append({'range': f'{ts_ax_letter}{row_num}', 'values': [[current_batch_timestamp]]}) # Block 1 Column Map
|
||
|
||
# Fuege Update zum Leeren von V-Y hinzu, falls Index gefunden wurde
|
||
if v_y_range_letter:
|
||
batch_sheet_updates.append({'range': f'{v_letter}{row_num}:{y_letter}{row_num}', 'values': [v_y_values]}) # Block 1 Column Map, interne Helfer
|
||
|
||
|
||
# Sende die gesammelten Updates fuer DIESEN finalen Batch.
|
||
if batch_sheet_updates:
|
||
self.logger.debug(f" Sende FINALES Sheet-Update fuer {len(rows_in_current_openai_batch)} Zeilen ({len(batch_sheet_updates)} Zellen)...") # <<< GEÄNDERT
|
||
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
|
||
success = self.sheet_handler.batch_update_cells(batch_sheet_updates)
|
||
if success:
|
||
self.logger.info(f" FINALES Sheet-Update fuer Wiki-Verifizierungs-Batch erfolgreich.") # <<< GEÄNDERT
|
||
# Der Fehlerfall wird von batch_update_cells geloggt
|
||
|
||
|
||
# Logge den Abschluss des Modus
|
||
self.logger.info(f"Wikipedia-Verifizierungs-Batch abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {skipped_count} Zeilen uebersprungen ({skipped_no_wiki_url} wegen fehlender M-URL).") # <<< GEÄNDERT
|
||
# Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt.
|
||
|
||
|
||
# ==============================================================================
|
||
# Ende DataProcessor Klasse Batch: Wiki Verification Block
|
||
# ==============================================================================
|
||
|
||
# ==========================================================================
|
||
# === Batch Processing Methods =============================================
|
||
# ==========================================================================
|
||
|
||
# --- Worker Funktion für paralleles Website Scraping (intern) ---
|
||
# Wird von process_website_scraping_batch aufgerufen
|
||
def _scrape_raw_text_task(self, task_info, get_website_raw_func):
|
||
"""
|
||
Scrapt den Rohtext einer Website in einem separaten Thread.
|
||
Wird vom ThreadPoolExecutor in process_website_scraping_batch aufgerufen.
|
||
Nutzt die uebergebene Funktion zum Abrufen des Rohtexts.
|
||
|
||
Args:
|
||
task_info (dict): Enthält {'row_num': int, 'url': str}.
|
||
get_website_raw_func (function): Die Funktion zum Abrufen des Website-Rohtexts (sollte die globale get_website_raw sein).
|
||
|
||
Returns:
|
||
dict: Enthält {'row_num': int, 'raw_text': str, 'error': str}.
|
||
"""
|
||
# Logger für diese Funktion holen (da sie in einem Thread läuft)
|
||
logger = logging.getLogger(__name__ + ".scrape_worker")
|
||
|
||
row_num = task_info['row_num']
|
||
url = task_info['url']
|
||
raw_text = "k.A."
|
||
error = None
|
||
|
||
try:
|
||
# RUFT die uebergebene Funktion zum Abrufen des Rohtexts auf.
|
||
# Der retry_on_failure Decorator auf get_website_raw_func (der hoffentlich get_website_raw ist)
|
||
# behandelt Retries und die meisten Fehler.
|
||
raw_text = get_website_raw_func(url) # <<< Ruft die uebergebene Funktion auf
|
||
|
||
# Wenn die Funktion einen Fehler loggt und einen Fehlerstring im Ergebnis zurueckgibt,
|
||
# wird dies hier als Fehler im Task markiert.
|
||
if isinstance(raw_text, str) and (raw_text.startswith("k.A. (Fehler") or raw_text.startswith("FEHLER:")):
|
||
error = f"Scraping Fehler (Details im Rohtext): {raw_text[:100]}..."
|
||
# Der Fehler wurde bereits in get_website_raw geloggt, kein weiteres Logging hier noetig.
|
||
# Das raw_text selbst enthaelt den Fehlerstring.
|
||
|
||
elif not isinstance(raw_text, str) or not raw_text.strip():
|
||
# Wenn die Funktion keinen String oder einen leeren String zurueckgibt
|
||
error = "Scraping Task Fehler: Funktion gab keinen gueltigen String zurueck."
|
||
raw_text = "k.A. (Extraktion fehlgeschlagen)" # Standard-Fehlerwert
|
||
|
||
except Exception as e:
|
||
# Dieser Block sollte jetzt sehr selten erreicht werden, da die uebergegebene Funktion
|
||
# mit retry_on_failure die meisten Fehler abfangen sollte.
|
||
# Wenn eine Exception hier durchkommt, ist es ein sehr unerwarteter Fehler im Task-Handling selbst.
|
||
error = f"Unerwarteter Fehler im Scraping Task Zeile {row_num} ({url[:100]}): {type(e).__name__} - {e}" # Gekuerzt loggen
|
||
logger.error(error) # Loggen Sie diesen unerwarteten Fehler
|
||
raw_text = "k.A. (Unerwarteter Fehler Task)" # Setze einen spezifischen Fehlerwert
|
||
|
||
|
||
# logger.debug(f"Scraping Task Zeile {row_num} abgeschlossen. Textlaenge: {len(str(raw_text))}.") # Zu viel Laerm im Debug
|
||
return {"row_num": row_num, "raw_text": raw_text, "error": error}
|
||
|
||
# --- Methode fuer den Website-Scraping-Batchmodus (AR) ---
|
||
# Diese Methode verarbeitet Zeilen, bei denen AR leer ist, um den Rohtext zu scrapen.
|
||
# Sie nutzt einen ThreadPoolExecutor und ruft die interne Worker-Funktion auf.
|
||
# Basierend auf process_website_batch aus Teil 9.
|
||
# Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter, _scrape_raw_text_task.
|
||
# Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), datetime, time,
|
||
# concurrent.futures, get_website_raw (Block 11).
|
||
# Nutzt die uebergeordnete sheet_handler Instanz (Block 14).
|
||
def process_website_scraping_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None):
|
||
"""
|
||
Batch-Prozess NUR fuer Website-Scraping (Rohtext AR).
|
||
Laedt Daten neu, prueft Spalte AR auf Inhalt ('', 'k.A.', etc.) und ueberspringt Zeilen mit Inhalt.
|
||
Setzt AR + AT + AP fuer bearbeitete Zeilen. Sendet Updates gebuendelt.
|
||
|
||
Args:
|
||
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AT).
|
||
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
|
||
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht uebersprungener) Zeilen. Defaults to None (Unbegrenzt).
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
# Logge die Konfiguration des Batch-Laufs
|
||
self.logger.info(f"Starte Website-Scraping (Batch AR, AT, AP). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT
|
||
|
||
|
||
# --- Daten laden und Startzeile ermitteln ---
|
||
# Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt
|
||
if start_sheet_row is None:
|
||
self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AT...") # <<< GEÄNDERT
|
||
# Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AT (Block 1 Column Map).
|
||
# Standardmaessig ab Zeile 7
|
||
start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Website Scrape Timestamp", min_sheet_row=7)
|
||
|
||
# Wenn get_start_row_index -1 zurueckgibt (Fehler)
|
||
if start_data_index_no_header == -1:
|
||
self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche Batch ab.") # <<< GEÄNDERT
|
||
return # Beende die Methode
|
||
|
||
# Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten Daten-Index
|
||
start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut
|
||
self.logger.info(f"Automatisch ermittelte Startzeile (erste leere AT Zelle): {start_sheet_row}") # <<< GEÄNDERT
|
||
else:
|
||
# Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein.
|
||
# Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2).
|
||
if not self.sheet_handler.load_data():
|
||
self.logger.error("FEHLER beim Laden der Daten fuer process_website_scraping_batch.") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn das Laden fehlschlaegt
|
||
|
||
|
||
# Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler.
|
||
all_data = self.sheet_handler.get_all_data_with_headers()
|
||
# Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14).
|
||
header_rows = self.sheet_handler._header_rows
|
||
total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet
|
||
|
||
|
||
# Berechne Endzeile, wenn nicht manuell gesetzt
|
||
if end_sheet_row is None:
|
||
end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
|
||
|
||
# Logge den verarbeitungsbereich
|
||
self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT
|
||
|
||
# Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen)
|
||
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
|
||
self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn der Bereich leer ist
|
||
|
||
|
||
# --- Indizes und Buchstaben ---
|
||
# Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind
|
||
required_keys = [
|
||
"Website Rohtext", "CRM Website", "Version", "Website Scrape Timestamp", "CRM Name" # AR, D, AP, AT, B
|
||
]
|
||
# Erstellen Sie ein Dictionary mit Schluesseln und Indizes
|
||
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
|
||
|
||
# Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden
|
||
if None in col_indices.values():
|
||
missing = [k for k, v in col_indices.items() if v is None]
|
||
self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_website_scraping_batch: {missing}. Breche ab.") # <<< GEÄNDERT
|
||
return # Beende die Methode bei kritischem Fehler
|
||
|
||
# Ermitteln Sie die Indizes und Buchstaben fuer Updates (AR, AT, AP)
|
||
rohtext_col_idx = col_indices["Website Rohtext"]
|
||
website_col_idx = col_indices["CRM Website"]
|
||
version_col_idx = col_indices["Version"]
|
||
timestamp_col_idx = col_indices["Website Scrape Timestamp"]
|
||
name_col_idx = col_indices["CRM Name"]
|
||
|
||
rohtext_col_letter = self.sheet_handler._get_col_letter(rohtext_col_idx + 1) # Block 14 _get_col_letter
|
||
version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1)
|
||
timestamp_col_letter = self.sheet_handler._get_col_letter(timestamp_col_idx + 1)
|
||
|
||
|
||
# --- Hauptlogik: Iteriere und sammle Batches ---
|
||
# Holen Sie die Batch-Groesse fuer Verarbeitung (Threading) aus Config (Block 1)
|
||
processing_batch_size = getattr(Config, 'PROCESSING_BATCH_SIZE', 20)
|
||
# Holen Sie die maximale Anzahl Worker aus Config (Block 1)
|
||
max_scraping_workers = getattr(Config, 'MAX_SCRAPING_WORKERS', 10)
|
||
# Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1)
|
||
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)
|
||
|
||
|
||
tasks_for_processing_batch = [] # Tasks fuer den aktuellen Scraping-Batch (Liste von Dicts)
|
||
rows_in_current_scraping_batch = [] # 1-basierte Zeilennummern im aktuellen Batch
|
||
all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts)
|
||
|
||
|
||
processed_count = 0 # Zaehlt Zeilen, die fuer die Verarbeitung in Frage kommen und in den Batch aufgenommen werden (im Rahmen des Limits).
|
||
skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (wegen Inhalt oder fehlender URL).
|
||
skipped_no_url = 0 # Zaehlt Zeilen, die speziell wegen fehlender URL uebersprungen wurden.
|
||
|
||
|
||
# Iteriere ueber die Sheet-Zeilen im definierten Bereich (1-basierte Sheet-Zeilennummer)
|
||
for i in range(start_sheet_row, end_sheet_row + 1):
|
||
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
|
||
# Pruefen Sie, ob das Ende des Sheets erreicht wurde
|
||
if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht
|
||
|
||
|
||
row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile
|
||
|
||
|
||
# Stellen Sie sicher, dass die Zeile nicht leer ist
|
||
if not any(cell and isinstance(cell, str) and cell.strip() for cell in row):
|
||
#self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug
|
||
skipped_count += 1 # Zaehlen als uebersprungen
|
||
continue # Springe zur naechsten Zeile
|
||
|
||
|
||
# --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
|
||
# Kriterium: Website Rohtext (AR) ist leer oder ein Standard-Fehlerwert.
|
||
# UND Website URL (D) ist vorhanden und gueltig aussehend.
|
||
|
||
# Holen Sie den Wert aus Spalte AR (Website Rohtext) (nutzt interne Helfer _get_cell_value_safe)
|
||
cell_value_ar = self._get_cell_value_safe(row, "Website Rohtext") # Block 1 Column Map
|
||
# Pruefen Sie, ob AR leer ist oder einen Standard-Fehlerwert enthaelt.
|
||
ar_is_empty_or_default = not cell_value_ar or (isinstance(cell_value_ar, str) and str(cell_value_ar).strip().lower() in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"])
|
||
|
||
|
||
# Holen Sie den Wert aus Spalte D (CRM Website) (nutzt interne Helfer _get_cell_value_safe)
|
||
website_url = self._get_cell_value_safe(row, "CRM Website").strip() # Block 1 Column Map
|
||
# Pruefen Sie, ob die Website URL (D) vorhanden und gueltig aussehend ist.
|
||
website_url_is_valid_looking = website_url and isinstance(website_url, str) and website_url.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"] # Fuege "http:" hinzu basierend auf Log
|
||
|
||
|
||
# Verarbeitung ist noetig, wenn AR leer/default ist UND D gefuellt/gueltig aussieht.
|
||
processing_needed_for_row = ar_is_empty_or_default and website_url_is_valid_looking
|
||
|
||
|
||
# Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
|
||
log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
|
||
if log_check:
|
||
company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map
|
||
self.logger.debug(f"Zeile {i} ({company_name[:50]}... Website Scraping Check): AR leer/default? {ar_is_empty_or_default}, D gueltig? {website_url_is_valid_looking}. Benötigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT
|
||
|
||
|
||
# Wenn die Verarbeitung fuer diese Zeile nicht noetig ist
|
||
if not processing_needed_for_row:
|
||
skipped_count += 1 # Zaehlen als uebersprungene Zeile
|
||
# Zaehlen Sie speziell, wenn die Zeile wegen fehlender gueltiger URL uebersprungen wurde.
|
||
if not website_url_is_valid_looking: skipped_no_url += 1
|
||
continue # Springe zur naechsten Zeile
|
||
|
||
|
||
# --- Wenn Verarbeitung noetig: Fuege zur Batch-Liste hinzu ---
|
||
processed_count += 1 # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im Rahmen des Limits zaehlen)
|
||
|
||
# Pruefe das Limit fuer verarbeitete Zeilen
|
||
if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit:
|
||
# Wenn das Limit erreicht ist und es ein positives Limit gibt
|
||
self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_website_scraping_batch erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
|
||
break # Brich die Schleife ab
|
||
|
||
|
||
# Fuege die benoetigten Daten fuer den Task hinzu (Zeilennummer und URL)
|
||
tasks_for_processing_batch.append({"row_num": i, "url": website_url})
|
||
# Fuege die Zeilennummer zur Liste der Zeilennummern im Batch hinzu
|
||
rows_in_current_scraping_batch.append(i)
|
||
|
||
|
||
# --- Verarbeite den Batch, wenn voll ---
|
||
# Pruefe, ob die aktuelle Batch-Liste die definierte Groesse erreicht hat.
|
||
# scraping_batch_size wird aus Config geholt (Block 1).
|
||
if len(tasks_for_processing_batch) >= processing_batch_size:
|
||
# Logge den Start der Batch-Verarbeitung
|
||
batch_start_row = tasks_for_processing_batch[0]['row_num']
|
||
batch_end_row = tasks_for_processing_batch[-1]['row_num']
|
||
self.logger.debug(f"\n--- Starte Website-Scraping Batch ({len(tasks_for_processing_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") # <<< GEÄNDERT
|
||
|
||
scraping_results = {} # Dictionary zum Speichern der Ergebnisse {row_num: raw_text}
|
||
batch_error_count = 0 # Fehlerzaehler fuer diesen spezifischen Batch
|
||
|
||
self.logger.debug(f" Scrape {len(tasks_for_processing_batch)} Websites parallel (max {max_scraping_workers} worker)...") # <<< GEÄNDERT
|
||
# Nutzt concurrent.futures.ThreadPoolExecutor fuer paralleles Scraping.
|
||
# max_workers wird aus Config geholt (Block 1).
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_scraping_workers) as executor:
|
||
# Map tasks to futures. Ruft die INTERNE Worker-Funktion auf.
|
||
# Uebergibt das task_info Dictionary und die globale Funktion get_website_raw (Block 11) als Argument.
|
||
future_to_task = {executor.submit(self._scrape_raw_text_task, task, get_website_raw): task for task in tasks_for_processing_batch} # <<< Korrigiert: interne Methode
|
||
|
||
# Verarbeite die Ergebnisse, sobald sie fertig sind.
|
||
for future in concurrent.futures.as_completed(future_to_task):
|
||
task = future_to_task[future] # Holen Sie die urspruenglichen Task-Daten (Dict)
|
||
try:
|
||
# Holen Sie das Ergebnis vom Future. Wenn die Worker-Funktion eine Exception wirft, wird diese hier gefangen.
|
||
result = future.result() # Ergebnis ist ein Dictionary {'row_num': ..., 'raw_text': ..., 'error': ...}
|
||
# Speichere das Ergebnis im scraping_results Dictionary
|
||
scraping_results[result['row_num']] = result['raw_text']
|
||
# Wenn der Worker einen Fehler gemeldet hat (z.B. durch Fehlerstring im raw_text oder error-Feld)
|
||
if result.get('error'):
|
||
batch_error_count += 1 # Erhoehe den Fehlerzaehler fuer diesen Batch
|
||
|
||
except Exception as exc:
|
||
# Dieser Block faengt unerwartete Fehler ab, die waehrend der Future-Ergebnis-Abfrage auftreten.
|
||
# Die meisten Fehler sollten von get_website_raws retry/logging behandelt werden.
|
||
row_num = task['row_num'] # Zeilennummer aus den Task-Daten
|
||
err_msg = f"Unerwarteter Fehler bei Ergebnisabfrage Scraping Task Zeile {row_num} ({task['url'][:100]}): {type(exc).__name__} - {exc}" # Gekuerzt loggen
|
||
self.logger.error(err_msg) # <<< GEÄNDERT
|
||
# Setze einen Standard-Fehlerwert fuer diese Zeile im Ergebnis
|
||
scraping_results[row_num] = "k.A. (Unerwarteter Fehler Task)"
|
||
batch_error_count += 1 # Erhoehe den Fehlerzaehler
|
||
|
||
|
||
self.logger.debug(f" Scraping fuer Batch beendet. {len(scraping_results)} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).") # <<< GEÄNDERT
|
||
|
||
|
||
# Sammle Sheet Updates (AR, AT, AP) fuer diesen Batch.
|
||
# Dies geschieht jetzt nach der parallelen Verarbeitung.
|
||
if scraping_results:
|
||
# Aktueller Zeitstempel und Version
|
||
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
current_version = getattr(Config, 'VERSION', 'unknown') # Block 1 Config Attribut
|
||
batch_sheet_updates = [] # Updates fuer DIESEN spezifischen Batch von Zeilen
|
||
|
||
|
||
# Iteriere ueber die Zeilennummern im Batch, fuer die Ergebnisse vorliegen.
|
||
# Ergebnisse koennen Fehlerwerte enthalten.
|
||
for row_num, raw_text_res in scraping_results.items():
|
||
# Fuege Updates fuer AR, AT und AP hinzu (nutzt interne Helfer)
|
||
# AR: Roh extrahierter Text (kann auch Fehlerwert sein)
|
||
batch_sheet_updates.append({'range': f'{rohtext_col_letter}{row_num}', 'values': [[raw_text_res]]}) # Block 1 Column Map
|
||
# AT: Timestamp des Scraping-Versuchs (immer setzen, wenn versucht wurde)
|
||
batch_sheet_updates.append({'range': f'{timestamp_col_letter}{row_num}', 'values': [[current_timestamp]]}) # Block 1 Column Map
|
||
# AP: Version des Skripts
|
||
batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Block 1 Column Map
|
||
|
||
|
||
# Sammle diese Batch-Updates fuer das groessere Batch-Update am Ende oder bei Limit.
|
||
# update_batch_row_limit wird aus Config geholt (Block 1).
|
||
all_sheet_updates.extend(batch_sheet_updates)
|
||
|
||
|
||
# Leere den Scraping-Batch fuer die naechste Iteration
|
||
tasks_for_processing_batch = []
|
||
rows_in_current_scraping_batch = []
|
||
|
||
|
||
# Sende gesammelte Sheet Updates, wenn das Update-Batch-Limit erreicht ist.
|
||
# Updates pro Zeile sind 3 (AR, AT, AP). Anzahl der Zeilen = len(all_sheet_updates) / 3.
|
||
rows_in_update_batch = len(all_sheet_updates) // 3 # Ganzzahl-Division
|
||
|
||
if rows_in_update_batch >= update_batch_row_limit:
|
||
self.logger.debug(f" Sende gesammelte Sheet-Updates ({rows_in_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
|
||
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
|
||
# Wenn es fehlschlaegt, wird es intern geloggt.
|
||
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
|
||
if success:
|
||
self.logger.info(f" Sheet-Update fuer {rows_in_update_batch} Zeilen erfolgreich.") # <<< GEÄNDERT
|
||
# Der Fehlerfall wird von batch_update_cells geloggt
|
||
|
||
# Leere die gesammelten Updates nach dem Senden.
|
||
all_sheet_updates = []
|
||
# rows_in_update_batch muss nicht explizit zurueckgesetzt werden, da es aus len(all_sheet_updates) berechnet wird.
|
||
|
||
|
||
# Keine Pause hier nach jedem kleinen Scraping-Batch, da wir auf batch_update warten.
|
||
# Die Pause kommt erst nach dem Batch-Update (oder am Ende des Modus).
|
||
# time.sleep(0.1) # Optionale kurze Pause
|
||
|
||
|
||
# --- Verarbeitung des letzten unvollstaendigen Scraping-Batches nach der Schleife ---
|
||
# Fuehre den letzten Batch aus, wenn nach der Hauptschleife noch Tasks in der Batch-Liste sind.
|
||
if tasks_for_processing_batch:
|
||
# Logge den Start des finalen Batches
|
||
batch_start_row = tasks_for_processing_batch[0]['row_num']
|
||
batch_end_row = tasks_for_processing_batch[-1]['row_num']
|
||
self.logger.debug(f"\n--- Starte FINALEN Website-Scraping Batch ({len(tasks_for_processing_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") # <<< GEÄNDERT
|
||
|
||
scraping_results = {} # Dictionary fuer die Ergebnisse
|
||
batch_error_count = 0 # Fehlerzaehler
|
||
|
||
self.logger.debug(f" Scrape {len(tasks_for_processing_batch)} Websites parallel (max {max_scraping_workers} worker)...") # <<< GEÄNDERT
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_scraping_workers) as executor:
|
||
# Map tasks to futures. Ruft die INTERNE Worker-Funktion auf.
|
||
future_to_task = {executor.submit(self._scrape_raw_text_task, task, get_website_raw): task for task in tasks_for_processing_batch} # <<< Korrigiert: interne Methode
|
||
|
||
# Verarbeite die Ergebnisse
|
||
for future in concurrent.futures.as_completed(future_to_task):
|
||
task = future_to_task[future] # Holen Sie die urspruenglichen Task-Daten
|
||
try:
|
||
result = future.result() # Holen Sie das Ergebnis
|
||
scraping_results[result['row_num']] = result['raw_text']
|
||
# Pruefe, ob der Worker einen Fehler gemeldet hat
|
||
if result.get('error'): batch_error_count += 1
|
||
except Exception as exc:
|
||
# Faengt unerwartete Fehler bei der Ergebnisabfrage ab
|
||
row_num = task['row_num']
|
||
err_msg = f"Unerwarteter Fehler bei Ergebnisabfrage Scraping Task Zeile {row_num} ({task['url'][:100]}): {type(exc).__name__} - {exc}" # Gekuerzt loggen
|
||
self.logger.error(err_msg) # <<< GEÄNDERT
|
||
# Setze einen Standard-Fehlerwert
|
||
scraping_results[row_num] = "k.A. (Unerwarteter Fehler Task)"
|
||
batch_error_count += 1
|
||
|
||
|
||
self.logger.debug(f" FINALER Scraping Batch beendet. {len(scraping_results)} Ergebnisse erhalten ({batch_error_count} Fehler).") # <<< GEÄNDERT
|
||
|
||
# Sammle Sheet Updates (AR, AT, AP) fuer diesen finalen Batch.
|
||
if scraping_results:
|
||
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
current_version = getattr(Config, 'VERSION', 'unknown') # Block 1 Config Attribut
|
||
batch_sheet_updates = [] # Updates fuer diesen spezifischen Batch
|
||
# Iteriere ueber die Zeilennummern im Batch, fuer die Ergebnisse vorliegen.
|
||
for row_num, raw_text_res in scraping_results.items():
|
||
# Fuege Updates fuer AR, AT und AP hinzu
|
||
batch_sheet_updates.append({'range': f'{rohtext_col_letter}{row_num}', 'values': [[raw_text_res]]}) # Block 1 Column Map
|
||
batch_sheet_updates.append({'range': f'{timestamp_col_letter}{row_num}', 'values': [[current_timestamp]]}) # Block 1 Column Map
|
||
batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Block 1 Column Map
|
||
# Fuege diese Updates zur globalen Liste hinzu (wird dann nur noch einmal gesendet)
|
||
all_sheet_updates.extend(batch_sheet_updates)
|
||
|
||
|
||
# --- Finale Sheet Updates senden ---
|
||
# Sende alle verbleibenden gesammelten Updates in einem letzten Batch-Update.
|
||
if all_sheet_updates:
|
||
rows_in_final_update_batch = len(all_sheet_updates) // 3 # Updates pro Zeile ist 3
|
||
self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({rows_in_final_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
|
||
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
|
||
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
|
||
if success:
|
||
self.logger.info(f"FINALES Sheet-Update erfolgreich.") # <<< GEÄNDERT
|
||
# Der Fehlerfall wird von batch_update_cells geloggt
|
||
|
||
|
||
# Logge den Abschluss des Modus
|
||
self.logger.info(f"Website-Scraping (Batch) abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {skipped_count} Zeilen uebersprungen.") # <<< GEÄNDERT
|
||
# Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt.
|
||
|
||
|
||
# ==============================================================================
|
||
# Ende DataProcessor Klasse Batch: Website Scraping Block
|
||
# ==============================================================================
|
||
|
||
# ==========================================================================
|
||
# === Batch Processing Methods =============================================
|
||
# ==========================================================================
|
||
|
||
# --- Methode fuer den Website-Summarization-Batchmodus (AS) ---
|
||
# Diese Methode verarbeitet Zeilen, bei denen AR gefuellt und AS leer ist, um die Zusammenfassung zu erstellen.
|
||
# Sie nutzt die globale Batch-Zusammenfassungsfunktion fuer den OpenAI Call.
|
||
# Basierend auf process_website_summarization_batch aus Teil 9.
|
||
# Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter.
|
||
# Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), datetime, time,
|
||
# summarize_batch_openai (Block 9).
|
||
# Nutzt die uebergeordnete sheet_handler Instanz (Block 14).
|
||
def process_summarization_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None):
|
||
"""
|
||
Batch-Prozess NUR fuer Website-Zusammenfassung (AS).
|
||
Laedt Daten neu, prueft, ob Rohtext (AR) vorhanden und Zusammenfassung (AS) fehlt.
|
||
Fasst Rohtexte im Batch ueber OpenAI zusammen und setzt AS + AP.
|
||
|
||
Args:
|
||
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AS).
|
||
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
|
||
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht uebersprungener) Zeilen. Defaults to None (Unbegrenzt).
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
# Logge die Konfiguration des Batch-Laufs
|
||
self.logger.info(f"Starte Website-Zusammenfassung (Batch AS, AP). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT
|
||
|
||
|
||
# --- Daten laden und Startzeile ermitteln ---
|
||
# Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt
|
||
if start_sheet_row is None:
|
||
self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AS...") # <<< GEÄNDERT
|
||
# Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AS (Block 1 Column Map).
|
||
# Standardmaessig ab Zeile 7
|
||
start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Website Zusammenfassung", min_sheet_row=7)
|
||
|
||
# Wenn get_start_row_index -1 zurueckgibt (Fehler)
|
||
if start_data_index_no_header == -1:
|
||
self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche Batch ab.") # <<< GEÄNDERT
|
||
return # Beende die Methode
|
||
|
||
# Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten Daten-Index
|
||
start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut
|
||
self.logger.info(f"Automatisch ermittelte Startzeile (erste leere AS Zelle): {start_sheet_row}") # <<< GEÄNDERT
|
||
else:
|
||
# Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein.
|
||
# Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2).
|
||
if not self.sheet_handler.load_data():
|
||
self.logger.error("FEHLER beim Laden der Daten fuer process_summarization_batch.") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn das Laden fehlschlaegt
|
||
|
||
|
||
# Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler.
|
||
all_data = self.sheet_handler.get_all_data_with_headers();
|
||
# Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14).
|
||
header_rows = self.sheet_handler._header_rows;
|
||
total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet
|
||
|
||
|
||
# Berechne Endzeile, wenn nicht manuell gesetzt
|
||
if end_sheet_row is None:
|
||
end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
|
||
|
||
# Logge den verarbeitungsbereich
|
||
self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT
|
||
|
||
# Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen)
|
||
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
|
||
self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn der Bereich leer ist
|
||
|
||
|
||
# --- Indizes und Buchstaben ---
|
||
# Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind
|
||
required_keys = [
|
||
"Website Rohtext", "Website Zusammenfassung", "Version", "CRM Name" # AR, AS, AP, B
|
||
]
|
||
# Erstellen Sie ein Dictionary mit Schluesseln und Indizes
|
||
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
|
||
|
||
# Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden
|
||
if None in col_indices.values():
|
||
missing = [k for k, v in col_indices.items() if v is None]
|
||
self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_summarization_batch: {missing}. Breche ab.") # <<< GEÄNDERT
|
||
return # Beende die Methode bei kritischem Fehler
|
||
|
||
# Ermitteln Sie die Indizes und Buchstaben fuer Updates (AS, AP)
|
||
rohtext_col_idx = col_indices["Website Rohtext"]
|
||
summary_col_idx = col_indices["Website Zusammenfassung"]
|
||
version_col_idx = col_indices["Version"]
|
||
name_col_idx = col_indices["CRM Name"] # Benoetigt fuer Logging
|
||
|
||
summary_col_letter = self.sheet_handler._get_col_letter(summary_col_idx + 1) # Block 14 _get_col_letter
|
||
version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1)
|
||
|
||
|
||
# --- Verarbeitung ---
|
||
# Holen Sie die Batch-Groesse fuer OpenAI-Aufrufe aus Config (Block 1)
|
||
openai_batch_size = getattr(Config, 'OPENAI_BATCH_SIZE_LIMIT', 4)
|
||
# Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1)
|
||
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)
|
||
|
||
|
||
tasks_for_openai_batch = [] # Tasks fuer den aktuellen OpenAI Batch (Liste von Dicts)
|
||
rows_in_current_openai_batch = [] # 1-basierte Zeilennummern im aktuellen OpenAI Batch
|
||
all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts)
|
||
|
||
|
||
processed_count = 0 # Zaehlt Zeilen, die fuer die Verarbeitung in Frage kommen und in den Batch aufgenommen werden (im Rahmen des Limits).
|
||
skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (wegen fehlendem Rohtext oder vorhandener Zusammenfassung).
|
||
|
||
|
||
# Iteriere ueber die Sheet-Zeilen im definierten Bereich (1-basierte Sheet-Zeilennummer)
|
||
for i in range(start_sheet_row, end_sheet_row + 1):
|
||
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
|
||
# Pruefen Sie, ob das Ende des Sheets erreicht wurde
|
||
if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht
|
||
|
||
|
||
row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile
|
||
|
||
|
||
# Stellen Sie sicher, dass die Zeile nicht leer ist
|
||
if not any(cell and isinstance(cell, str) and cell.strip() for cell in row):
|
||
#self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug
|
||
skipped_count += 1 # Zaehlen als uebersprungen
|
||
continue # Springe zur naechsten Zeile
|
||
|
||
|
||
# --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
|
||
# Kriterium: Website Rohtext (AR) ist vorhanden und gueltig (nicht k.A. oder Fehlerwerte).
|
||
# UND Website Zusammenfassung (AS) ist leer oder ein Standard-Fehlerwert.
|
||
|
||
# Holen Sie den Wert aus Spalte AR (Website Rohtext) (nutzt interne Helfer _get_cell_value_safe)
|
||
raw_text = self._get_cell_value_safe(row, "Website Rohtext") # Block 1 Column Map
|
||
# Pruefen Sie, ob AR gefuellt und gueltig ist.
|
||
raw_text_is_valid = raw_text and isinstance(raw_text, str) and str(raw_text).strip().lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]
|
||
|
||
|
||
# Holen Sie den Wert aus Spalte AS (Website Zusammenfassung) (nutzt interne Helfer _get_cell_value_safe)
|
||
summary_value = self._get_cell_value_safe(row, "Website Zusammenfassung") # Block 1 Column Map
|
||
# Pruefen Sie, ob AS leer ist oder einen Standard-Fehlerwert enthaelt.
|
||
summary_is_empty_or_default = not summary_value or (isinstance(summary_value, str) and str(summary_value).strip().lower() in ["k.a.", "k.a. (keine zusammenfassung erhalten)"])
|
||
|
||
|
||
# Verarbeitung ist noetig, wenn AR gueltig ist UND AS leer/default ist.
|
||
processing_needed_for_row = raw_text_is_valid and summary_is_empty_or_default
|
||
|
||
|
||
# Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
|
||
log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
|
||
if log_check:
|
||
company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map
|
||
self.logger.debug(f"Zeile {i} ({company_name[:50]}... Website Summarization Check): AR gueltig? {raw_text_is_valid} (len={len(str(raw_text))}), AS leer/default? {summary_is_empty_or_default}. Benötigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT
|
||
|
||
|
||
# Wenn die Verarbeitung fuer diese Zeile nicht noetig ist
|
||
if not processing_needed_for_row:
|
||
skipped_count += 1 # Zaehlen als uebersprungene Zeile
|
||
continue # Springe zur naechsten Zeile
|
||
|
||
|
||
# --- Wenn Verarbeitung noetig: Fuege zur Batch-Liste fuer OpenAI hinzu ---
|
||
processed_count += 1 # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im Rahmen des Limits zaehlen)
|
||
|
||
# Pruefe das Limit fuer verarbeitete Zeilen
|
||
if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit:
|
||
# Wenn das Limit erreicht ist und es ein positives Limit gibt
|
||
self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_summarization_batch erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
|
||
break # Brich die Schleife ab
|
||
|
||
|
||
# Fuege die benoetigten Daten fuer den OpenAI Batch hinzu (Zeilennummer und Rohtext)
|
||
tasks_for_openai_batch.append({'row_num': i, 'raw_text': raw_text})
|
||
# Fuege die Zeilennummer zur Liste der Zeilennummern im Batch hinzu
|
||
rows_in_current_openai_batch.append(i)
|
||
|
||
|
||
# --- Verarbeite den Batch, wenn voll ---
|
||
# Pruefe, ob die aktuelle Batch-Liste die definierte Groesse erreicht hat.
|
||
# openai_batch_size wird aus Config geholt (Block 1).
|
||
if len(tasks_for_openai_batch) >= openai_batch_size:
|
||
# Logge den Start der Batch-Verarbeitung
|
||
batch_start_row = tasks_for_openai_batch[0]['row_num']
|
||
batch_end_row = tasks_for_openai_batch[-1]['row_num']
|
||
self.logger.debug(f"\n--- Starte Website-Summarization Batch ({len(tasks_for_openai_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") # <<< GEÄNDERT
|
||
|
||
# Rufe die globale Funktion auf, die den OpenAI Call fuer den Batch macht (Block 9).
|
||
# summarize_batch_openai ist mit retry_on_failure dekoriert (Block 2).
|
||
# Wenn summarize_batch_openai eine Exception wirft (nach Retries), wird diese hier gefangen.
|
||
# !!! KORRIGIERTER AUFRUF !!!
|
||
try:
|
||
# Rufen Sie die korrekte globale Funktion auf
|
||
batch_results = summarize_batch_openai(tasks_for_openai_batch) # <<< Korrigierter Aufruf (vorher war fälschlicherweise _process_verification_openai_batch)
|
||
# Ergebnisse sollten ein Dictionary {row_num: summary_text} sein, auch bei Fehlern.
|
||
|
||
# Sammle Sheet Updates (AS, AP) fuer diesen Batch
|
||
current_version = getattr(Config, 'VERSION', 'unknown') # Block 1 Config Attribut
|
||
batch_sheet_updates = [] # Updates fuer DIESEN spezifischen Batch von Zeilen
|
||
|
||
# Iteriere ueber die Zeilennummern, die in DIESEM OpenAI Batch waren
|
||
for row_num in rows_in_current_openai_batch:
|
||
# Hole das Ergebnis fuer diese Zeile aus dem Ergebnis-Dictionary.
|
||
# Fallback auf einen Fehlerstring, wenn das Ergebnis fehlt (sollte nicht passieren, wenn summarize_batch_openai korrekt ist).
|
||
summary = batch_results.get(row_num, "k.A. (Batch Ergebnis fehlte)")
|
||
# Stelle sicher, dass 'k.A.' bei leeren/kurzen Summaries gesetzt wird
|
||
if not summary or (isinstance(summary, str) and summary.strip().lower() in ["k.a.", "k.a. (keine zusammenfassung erhalten)"]):
|
||
summary = "k.A. (Keine Zusammenfassung erhalten)"
|
||
# Fuege "k.A." oder Fehler an, wenn der Wert von summarize_batch_openai ein Fehlerstring ist
|
||
elif isinstance(summary, str) and (summary.startswith("k.A. (Fehler") or summary.startswith("FEHLER:")):
|
||
pass # Behalte den Fehlerstring von summarize_batch_openai
|
||
|
||
# Fuege Updates fuer AS und AP hinzu (nutzt interne Helfer)
|
||
batch_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[summary]]}) # Block 1 Column Map
|
||
batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Block 1 Column Map
|
||
|
||
|
||
# Sammle diese Batch-Updates fuer das groessere Batch-Update
|
||
all_sheet_updates.extend(batch_sheet_updates)
|
||
|
||
|
||
except Exception as e_openai_batch:
|
||
# Wenn summarize_batch_openai eine Exception wirft (nach Retries)
|
||
# Der Fehler wird bereits vom retry_on_failure Decorator auf summarize_batch_openai geloggt.
|
||
self.logger.error(f"Endgueltiger FEHLER beim OpenAI-Batch-Aufruf fuer Zusammenfassung: {e_openai_batch}") # <<< GEÄNDERT
|
||
# Logge den Traceback
|
||
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
|
||
# Fügen Sie Fehlerwerte für alle Zeilen im Batch hinzu
|
||
current_version = getattr(Config, 'VERSION', 'unknown') # Block 1 Config Attribut
|
||
for row_num in rows_in_current_openai_batch:
|
||
error_summary = f"FEHLER OpenAI Batch: {str(e_openai_batch)[:100]}..." # Gekuerzt
|
||
# Fuege Updates mit Fehlerwerten fuer AS und AP hinzu
|
||
all_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[error_summary]]}) # Block 1 Column Map
|
||
all_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Block 1 Column Map
|
||
|
||
|
||
# Leere den OpenAI-Batch zurueck
|
||
tasks_for_openai_batch = []
|
||
rows_in_current_openai_batch = []
|
||
|
||
|
||
# Sende gesammelte Sheet Updates, wenn das Update-Batch-Limit erreicht ist.
|
||
# Updates pro Zeile sind 2 (AS, AP). Anzahl der Zeilen = len(all_sheet_updates) / 2.
|
||
rows_in_update_batch = len(all_sheet_updates) // 2
|
||
|
||
if rows_in_update_batch >= update_batch_row_limit:
|
||
self.logger.debug(f" Sende gesammelte Sheet-Updates ({rows_in_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
|
||
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
|
||
# Wenn es fehlschlaegt, wird es intern geloggt.
|
||
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
|
||
if success:
|
||
self.logger.info(f" Sheet-Update fuer {rows_in_update_batch} Zeilen erfolgreich.") # <<< GEÄNDERT
|
||
# Der Fehlerfall wird von batch_update_cells geloggt
|
||
|
||
# Leere die gesammelten Updates nach dem Senden.
|
||
all_sheet_updates = []
|
||
|
||
# Kurze Pause nach jedem OpenAI Batch (nutzt Config Block 1).
|
||
# Dies ist wichtig, um Rate Limits zu vermeiden.
|
||
pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.5 # 50% der Retry-Wartezeit
|
||
self.logger.debug(f"Warte {pause_duration:.2f}s vor naechstem Batch...") # <<< GEÄNDERT
|
||
time.sleep(pause_duration)
|
||
|
||
|
||
# --- Verarbeitung des letzten unvollstaendigen OpenAI Batches nach der Schleife ---
|
||
# Wenn nach der Hauptschleife noch Tasks in der Batch-Liste sind.
|
||
if tasks_for_openai_batch: # Korrektur: War vorher `current_openai_batch_data`
|
||
# Logge den Start des finalen Batches
|
||
batch_start_row = tasks_for_openai_batch[0]['row_num'] # Korrektur: War vorher `current_openai_batch_data`
|
||
batch_end_row = tasks_for_openai_batch[-1]['row_num'] # Korrektur: War vorher `current_openai_batch_data`
|
||
self.logger.debug(f"\n--- Starte FINALEN Website-Summarization Batch ({len(tasks_for_openai_batch)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") # <<< GEÄNDERT
|
||
|
||
# Rufe die globale Funktion auf, die den OpenAI Call fuer den Batch macht (Block 9).
|
||
# summarize_batch_openai ist mit retry_on_failure dekoriert (Block 2).
|
||
# Wenn summarize_batch_openai eine Exception wirft (nach Retries), wird diese hier gefangen.
|
||
batch_results = None
|
||
try:
|
||
batch_results = summarize_batch_openai(tasks_for_openai_batch) # <<< Korrekter Aufruf Block 9, Korrektur: War vorher `current_openai_batch_data`
|
||
# Ergebnisse sollten ein Dictionary {row_num: summary_text} sein, auch bei Fehlern.
|
||
|
||
# Sammle Sheet Updates (AS, AP) fuer diesen finalen Batch
|
||
current_version = getattr(Config, 'VERSION', 'unknown') # Block 1 Config Attribut
|
||
batch_sheet_updates = [] # Updates fuer diesen spezifischen Batch
|
||
# Iteriere ueber die Zeilennummern im Batch, fuer die Ergebnisse vorliegen.
|
||
for row_num in rows_in_current_openai_batch:
|
||
# Hole das Ergebnis fuer diese Zeile aus dem Ergebnis-Dictionary.
|
||
summary = batch_results.get(row_num, "k.A. (Batch Ergebnis fehlte)") # Fallback
|
||
|
||
# Stelle sicher, dass 'k.A.' bei leeren/kurzen Summaries gesetzt wird
|
||
if not summary or (isinstance(summary, str) and summary.strip().lower() in ["k.a.", "k.a. (keine zusammenfassung erhalten)"]):
|
||
summary = "k.A. (Keine Zusammenfassung erhalten)"
|
||
# Fuege "k.A." oder Fehler an, wenn der Wert von summarize_batch_openai ein Fehlerstring ist
|
||
elif isinstance(summary, str) and (summary.startswith("k.A. (Fehler") or summary.startswith("FEHLER:")):
|
||
pass # Behalte den Fehlerstring von summarize_batch_openai
|
||
|
||
|
||
# Fuege Updates fuer AS und AP hinzu
|
||
batch_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[summary]]}) # Block 1 Column Map
|
||
batch_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Block 1 Column Map
|
||
|
||
|
||
# Fuege diese Updates zur globalen Liste hinzu (wird dann nur noch einmal gesendet)
|
||
all_sheet_updates.extend(batch_sheet_updates)
|
||
|
||
|
||
except Exception as e_openai_batch:
|
||
# Wenn summarize_batch_openai eine Exception wirft (nach Retries)
|
||
# Der Fehler wird bereits vom retry_on_failure Decorator auf summarize_batch_openai geloggt.
|
||
self.logger.error(f"Endgueltiger FEHLER beim FINALEN OpenAI-Batch-Aufruf fuer Zusammenfassung: {e_openai_batch}") # <<< GEÄNDERT
|
||
# Logge den Traceback
|
||
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
|
||
# Fügen Sie Fehlerwerte für alle Zeilen im Batch hinzu
|
||
current_version = getattr(Config, 'VERSION', 'unknown') # Block 1 Config Attribut
|
||
for row_num in rows_in_current_openai_batch:
|
||
error_summary = f"FEHLER OpenAI Batch: {str(e_openai_batch)[:100]}..." # Gekuerzt
|
||
# Fuege Updates mit Fehlerwerten fuer AS und AP hinzu
|
||
all_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[error_summary]]}) # Block 1 Column Map
|
||
all_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) # Block 1 Column Map
|
||
|
||
|
||
# --- Finale Sheet Updates senden ---
|
||
# Sende alle verbleibenden gesammelten Updates in einem letzten Batch-Update.
|
||
if all_sheet_updates:
|
||
rows_in_final_update_batch = len(all_sheet_updates) // 2 # Updates pro Zeile ist 2
|
||
self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({rows_in_final_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
|
||
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
|
||
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
|
||
if success:
|
||
self.logger.info(f"FINALES Sheet-Update erfolgreich.") # <<< GEÄNDERT
|
||
# Der Fehlerfall wird von batch_update_cells geloggt
|
||
|
||
|
||
# Logge den Abschluss des Modus
|
||
self.logger.info(f"Website-Zusammenfassung (Batch) abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {skipped_count} Zeilen uebersprungen.") # <<< GEÄNDERT
|
||
# Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt.
|
||
|
||
# ==============================================================================
|
||
# Ende DataProcessor Klasse Batch: Summarization Block
|
||
# ==============================================================================
|
||
|
||
# ==========================================================================
|
||
# === Batch Processing Methods =============================================
|
||
# ==========================================================================
|
||
|
||
# --- Interne Hilfsfunktion fuer Branchen-Batch (OpenAI Call) ---
|
||
# Diese Funktion laeuft in einem separaten Thread fuer parallele Verarbeitung.
|
||
# Sie wird von process_branch_batch (dieser Block) aufgerufen.
|
||
# Sie nutzt die globale Funktion evaluate_branche_chatgpt und erhaelt sie als Argument uebergeben.
|
||
# Nutzt globale Helfer: evaluate_branche_chatgpt (Block 10), logger, time, threading.Semaphore, traceback.
|
||
# Nutzt lokale Semaphore Instanz, die von process_branch_batch uebergeben wird.
|
||
def evaluate_branch_task(self, task_data, openai_semaphore):
|
||
"""
|
||
Führt die Branchenevaluation fuer eine einzelne Zeile aus.
|
||
Läuft in einem separaten Thread fuer den Branchen-Batch.
|
||
|
||
Args:
|
||
task_data (dict): Enthält die Daten fuer die Zeile.
|
||
openai_semaphore (threading.Semaphore): Semaphore zur Begrenzung gleichzeitiger OpenAI-Calls.
|
||
|
||
Returns:
|
||
dict: Ergebnis von evaluate_branche_chatgpt (Block 10) plus row_num und error.
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
logger = logging.getLogger(__name__ + ".evaluate_branch_task") # Eigener Logger für den Task
|
||
row_num = task_data['row_num']
|
||
# Initialisiere Ergebnis mit Fehlerwerten, falls der Task fehlschlaegt
|
||
result = {"branch": "k.A. (Fehler Task)", "consistency": "error", "justification": "Fehler in Worker-Task"}
|
||
error = None
|
||
|
||
try:
|
||
# Acquire the semaphore before making the OpenAI call (indirekt ueber evaluate_branche_chatgpt)
|
||
with openai_semaphore:
|
||
# Kleine künstliche Pause reduziert manchmal Race Conditions bei hoher Last oder schnellen APIs
|
||
# time.sleep(0.05) # Optional
|
||
|
||
# Rufe die globale Funktion zur Branchenevaluation auf (Block 10).
|
||
# evaluate_branche_chatgpt nutzt call_openai_chat (Block 8), der den retry_on_failure Decorator (Block 2) nutzt.
|
||
# Wenn evaluate_branche_chatgpt eine Exception wirft (nach Retries), wird diese hier gefangen.
|
||
result = evaluate_branche_chatgpt(
|
||
task_data['crm_branche'],
|
||
task_data['beschreibung'],
|
||
task_data['wiki_branche'],
|
||
task_data['wiki_kategorien'],
|
||
task_data['website_summary']
|
||
)
|
||
except Exception as e:
|
||
# Wenn evaluate_branche_chatgpt eine Exception wirft (nach Retries)
|
||
# Der Fehler wird bereits vom retry_on_failure Decorator oder evaluate_branche_chatgpt geloggt.
|
||
error = f"Fehler bei Branchenevaluation Zeile {row_num}: {type(e).__name__} - {e}"
|
||
logger.error(error) # Logge den Fehler
|
||
# Logge den Traceback
|
||
logger.debug(traceback.format_exc())
|
||
# Stellen Sie sicher, dass das Ergebnis-Dict im Fehlerfall spezifische Fehlerwerte enthaelt
|
||
result = {"branch": "FEHLER", "consistency": "error_task", "justification": error[:500]} # Kuerze Begruendung
|
||
|
||
# logger.debug(f"Branch Task Zeile {row_num} abgeschlossen.") # Zu viel Laerm im Debug
|
||
return {"row_num": row_num, "result": result, "error": error}
|
||
|
||
|
||
# --- Methode fuer den Branchen-Batchmodus (AO) ---
|
||
# Diese Methode verarbeitet Zeilen, bei denen AO leer ist, um die Branche ueber ChatGPT zu bewerten.
|
||
# Sie nutzt einen ThreadPoolExecutor und ruft die interne Worker-Funktion evaluate_branch_task auf.
|
||
# Basierend auf process_branch_batch aus Teil 9.
|
||
# Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter, evaluate_branch_task (derselbe Block).
|
||
# Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), datetime, time,
|
||
# concurrent.futures, threading.Semaphore, load_target_schema (Block 6),
|
||
# ALLOWED_TARGET_BRANCHES (Block 6).
|
||
# Nutzt die uebergeordnete sheet_handler Instanz (Block 14).
|
||
def process_branch_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None):
|
||
self.logger.info(f"Starte Brancheneinschaetzung (Parallel Batch). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...")
|
||
|
||
# --- Daten laden und Startzeile ermitteln ---
|
||
if start_sheet_row is None:
|
||
self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren Timestamp letzte Pruefung (BC)...")
|
||
start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Timestamp letzte Pruefung", min_sheet_row=7)
|
||
if start_data_index_no_header == -1:
|
||
self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche Batch ab.")
|
||
return
|
||
start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1
|
||
self.logger.info(f"Automatisch ermittelte Startzeile (erste leere BC Zelle): {start_sheet_row}")
|
||
else:
|
||
if not self.sheet_handler.load_data():
|
||
self.logger.error("FEHLER beim Laden der Daten fuer process_branch_batch.")
|
||
return
|
||
|
||
all_data = self.sheet_handler.get_all_data_with_headers()
|
||
header_rows = self.sheet_handler._header_rows
|
||
total_sheet_rows = len(all_data)
|
||
|
||
if end_sheet_row is None:
|
||
end_sheet_row = total_sheet_rows
|
||
self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}")
|
||
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
|
||
self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.")
|
||
return
|
||
|
||
# --- Indizes und Buchstaben ---
|
||
required_keys = [
|
||
"Timestamp letzte Pruefung", "CRM Branche", "CRM Beschreibung", "Wiki Branche",
|
||
"Wiki Kategorien", "Website Zusammenfassung", "Version", "Chat Vorschlag Branche",
|
||
"Chat Branche Konfidenz", "Chat Konsistenz Branche", "Chat Begruendung Abweichung Branche",
|
||
"CRM Name"
|
||
]
|
||
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
|
||
if None in col_indices.values():
|
||
missing = [k for k, v in col_indices.items() if v is None]
|
||
self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_branch_batch: {missing}. Breche ab.")
|
||
return
|
||
|
||
MAX_BRANCH_WORKERS = getattr(Config, 'MAX_BRANCH_WORKERS', 10)
|
||
OPENAI_CONCURRENCY_LIMIT = getattr(Config, 'OPENAI_CONCURRENCY_LIMIT', 3)
|
||
processing_batch_size = getattr(Config, 'PROCESSING_BRANCH_BATCH_SIZE', 20)
|
||
|
||
tasks_for_current_batch = []
|
||
processed_tasks_count = 0 # Zählt Tasks, die tatsächlich verarbeitet wurden
|
||
skipped_count = 0
|
||
|
||
global ALLOWED_TARGET_BRANCHES
|
||
if not ALLOWED_TARGET_BRANCHES:
|
||
load_target_schema()
|
||
if not ALLOWED_TARGET_BRANCHES:
|
||
self.logger.critical("FEHLER: Ziel-Branchenschema konnte nicht geladen werden. Breche Batch ab.")
|
||
return
|
||
|
||
# Funktion zum Verarbeiten eines einzelnen Batches (um Code-Duplikation zu reduzieren)
|
||
def _execute_and_write_batch(batch_tasks_to_run):
|
||
nonlocal processed_tasks_count # Zugriff auf die äußere Variable
|
||
if not batch_tasks_to_run:
|
||
return
|
||
|
||
batch_start_log = batch_tasks_to_run[0]['row_num']
|
||
batch_end_log = batch_tasks_to_run[-1]['row_num']
|
||
self.logger.debug(f"\n--- Verarbeite Branch-Evaluation Batch ({len(batch_tasks_to_run)} Tasks, Zeilen {batch_start_log}-{batch_end_log}) ---")
|
||
|
||
current_batch_results = []
|
||
current_batch_errors = 0
|
||
openai_sem = threading.Semaphore(OPENAI_CONCURRENCY_LIMIT)
|
||
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_BRANCH_WORKERS) as executor:
|
||
future_map = {executor.submit(self.evaluate_branch_task, task, openai_sem): task for task in batch_tasks_to_run}
|
||
for future in concurrent.futures.as_completed(future_map):
|
||
task_info = future_map[future]
|
||
try:
|
||
res_data = future.result()
|
||
current_batch_results.append(res_data)
|
||
if res_data.get('error'): current_batch_errors +=1
|
||
except Exception as exc_future:
|
||
self.logger.error(f"Exception im Future für Zeile {task_info['row_num']}: {exc_future}")
|
||
current_batch_results.append({"row_num": task_info['row_num'], "result": {"branch": "FEHLER FUTURE", "consistency": "error_task", "justification": str(exc_future)[:100]}, "error": str(exc_future)})
|
||
current_batch_errors += 1
|
||
|
||
self.logger.debug(f" Batch ({batch_start_log}-{batch_end_log}) beendet. {len(current_batch_results)} Ergebnisse, {current_batch_errors} Fehler.")
|
||
|
||
if current_batch_results:
|
||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
ver = getattr(Config, 'VERSION', 'unknown')
|
||
updates_this_batch = []
|
||
current_batch_results.sort(key=lambda x: x['row_num'])
|
||
for item in current_batch_results:
|
||
rn, res = item['row_num'], item['result']
|
||
self.logger.debug(f" Zeile {rn} (Ergebnis): {res}")
|
||
updates_this_batch.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"] + 1)}{rn}', 'values': [[res.get("branch", "ERR BR")]]})
|
||
updates_this_batch.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Branche Konfidenz"] + 1)}{rn}', 'values': [[res.get("confidence", "N/A CO")]]})
|
||
updates_this_batch.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenz Branche"] + 1)}{rn}', 'values': [[res.get("consistency", "err CO")]]})
|
||
updates_this_batch.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung Abweichung Branche"] + 1)}{rn}', 'values': [[res.get("justification", "No JU")]]})
|
||
updates_this_batch.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Timestamp letzte Pruefung"] + 1)}{rn}', 'values': [[ts]]})
|
||
updates_this_batch.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Version"] + 1)}{rn}', 'values': [[ver]]})
|
||
|
||
if updates_this_batch:
|
||
self.logger.debug(f" Sende Sheet-Update für {len(current_batch_results)} Zeilen dieses Batches...")
|
||
s_upd = self.sheet_handler.batch_update_cells(updates_this_batch)
|
||
if s_upd: self.logger.info(f" Sheet-Update für Batch Zeilen {batch_start_log}-{batch_end_log} erfolgreich.")
|
||
|
||
processed_tasks_count += len(batch_tasks_to_run) # Zähle verarbeitete Tasks
|
||
|
||
pause_dur = getattr(Config, 'RETRY_DELAY', 5) * 0.8
|
||
self.logger.debug(f"--- Batch ({batch_start_log}-{batch_end_log}) abgeschlossen. Warte {pause_dur:.2f}s ---")
|
||
time.sleep(pause_dur)
|
||
# Ende der Hilfsfunktion _execute_and_write_batch
|
||
|
||
# Hauptschleife über die Zeilen
|
||
for i in range(start_sheet_row, end_sheet_row + 1):
|
||
if limit is not None and processed_tasks_count >= limit: # Prüfe Limit für *tatsächlich verarbeitete* Tasks
|
||
self.logger.info(f"Verarbeitungslimit ({limit}) erreicht. Stoppe weitere Zeilenprüfung.")
|
||
break
|
||
|
||
row_index_in_list = i - 1
|
||
if row_index_in_list >= total_sheet_rows: break
|
||
|
||
row = all_data[row_index_in_list]
|
||
if not any(cell and isinstance(cell, str) and cell.strip() for cell in row):
|
||
skipped_count += 1
|
||
continue
|
||
|
||
company_name_log = self._get_cell_value_safe(row, "CRM Name").strip()
|
||
ao_value = self._get_cell_value_safe(row, "Timestamp letzte Pruefung").strip()
|
||
if ao_value: # Wenn Timestamp gesetzt ist, überspringen
|
||
skipped_count += 1
|
||
continue
|
||
|
||
# --- DEBUG BLOCK für info_sources_count (wie gehabt) ---
|
||
crm_branche_val = self._get_cell_value_safe(row, "CRM Branche").strip(); crm_beschreibung_val = self._get_cell_value_safe(row, "CRM Beschreibung").strip()
|
||
wiki_branche_val = self._get_cell_value_safe(row, "Wiki Branche").strip(); wiki_kategorien_val = self._get_cell_value_safe(row, "Wiki Kategorien").strip()
|
||
website_summary_val = self._get_cell_value_safe(row, "Website Zusammenfassung").strip()
|
||
# ... (kompletter detaillierter Debug-Block für info_sources_count hier einfügen) ...
|
||
self.logger.debug(f"Zeile {i} ({company_name_log[:30]}...) - Rohwerte für Info-Quellen:")
|
||
sources_to_check = {"CRM Branche": crm_branche_val, "CRM Beschreibung": crm_beschreibung_val, "Wiki Branche": wiki_branche_val, "Wiki Kategorien": wiki_kategorien_val, "Website Zusammenfassung": website_summary_val}
|
||
info_sources_count = 0; counted_sources = []
|
||
for source_name, val in sources_to_check.items():
|
||
cond1 = bool(val); cond2 = isinstance(val, str); cond3 = False; cond4 = False; cond5 = False
|
||
if cond1 and cond2:
|
||
stripped_val = val.strip(); cond3 = bool(stripped_val)
|
||
if cond3: lower_stripped_val = stripped_val.lower(); cond4 = lower_stripped_val != "k.a."; cond5 = not stripped_val.upper().startswith("FEHLER")
|
||
is_valid_source = cond1 and cond2 and cond3 and cond4 and cond5
|
||
if is_valid_source: info_sources_count += 1; counted_sources.append(source_name)
|
||
self.logger.debug(f" Prüfe Quelle '{source_name}': Wert='{str(val)[:30]}...', c1?{cond1}, c2?{cond2}, c3?{cond3}, c4?{cond4}, c5?{cond5} -> Gültig? {is_valid_source}")
|
||
self.logger.debug(f"Zeile {i} ({company_name_log[:30]}...) - Gezählte valide Quellen: {info_sources_count} - {counted_sources}")
|
||
|
||
if info_sources_count < 2:
|
||
self.logger.info(f"Zeile {i} ({company_name_log[:30]}...) (Branch Check): Uebersprungen (Timestamp BC leer, aber nur {info_sources_count} Informationsquellen verfuegbar: {counted_sources}). Mindestens 2 benoetigt.")
|
||
skipped_count += 1
|
||
continue
|
||
|
||
# Task zur Liste hinzufügen, wenn alle Kriterien erfüllt sind UND Limit noch nicht erreicht
|
||
if limit is None or (processed_tasks_count + len(tasks_for_current_batch)) < limit:
|
||
tasks_for_current_batch.append({
|
||
"row_num": i, "crm_branche": crm_branche_val, "beschreibung": crm_beschreibung_val,
|
||
"wiki_branche": wiki_branche_val, "wiki_kategorien": wiki_kategorien_val,
|
||
"website_summary": website_summary_val
|
||
})
|
||
elif limit is not None and (processed_tasks_count + len(tasks_for_current_batch)) >= limit :
|
||
# Wenn das Hinzufügen dieses Tasks das Limit erreichen oder überschreiten würde,
|
||
# füge ihn noch hinzu (wird im nächsten Batch-Check gekürzt) und beende dann die Schleife
|
||
tasks_for_current_batch.append({
|
||
"row_num": i, "crm_branche": crm_branche_val, "beschreibung": crm_beschreibung_val,
|
||
"wiki_branche": wiki_branche_val, "wiki_kategorien": wiki_kategorien_val,
|
||
"website_summary": website_summary_val
|
||
})
|
||
self.logger.info(f"Zeile {i} wurde als letzter Task vor Erreichen des Limits ({limit}) gesammelt.")
|
||
# Die execute_and_write_batch Logik wird getriggert, wenn die Schleife endet oder der Batch voll ist.
|
||
|
||
|
||
# Batch ausführen, wenn voll ODER es die letzte Zeile ist
|
||
if len(tasks_for_current_batch) >= processing_batch_size or i == end_sheet_row:
|
||
_execute_and_write_batch(tasks_for_current_batch)
|
||
tasks_for_current_batch = [] # Batch-Liste für den nächsten Durchlauf leeren
|
||
|
||
# Sicherstellen, dass ein eventuell nicht voller letzter Batch auch noch verarbeitet wird,
|
||
# falls die Schleife durch das Limit beendet wurde und noch Tasks übrig sind.
|
||
if tasks_for_current_batch:
|
||
self.logger.debug(f"Verarbeite verbleibenden Rest-Batch von {len(tasks_for_current_batch)} Tasks...")
|
||
_execute_and_write_batch(tasks_for_current_batch)
|
||
|
||
self.logger.info(f"Brancheneinschaetzung (Parallel Batch) abgeschlossen. {processed_tasks_count} Zeilen verarbeitet, {skipped_count} Zeilen uebersprungen.")
|
||
|
||
|
||
# ==============================================================================
|
||
# Ende DataProcessor Klasse Batch: Branch Evaluation Block
|
||
# ==============================================================================
|
||
|
||
# ==========================================================================
|
||
# === Batch Processing Methods =============================================
|
||
# ==========================================================================
|
||
|
||
# --- Methode fuer den SerpAPI Wiki Search Batchmodus (AY) ---
|
||
# Diese Methode sucht fehlende Wiki-URLs ueber SerpAPI.
|
||
# Basierend auf process_find_wiki_with_serp aus Teil 2.
|
||
# Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter.
|
||
# Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), datetime, time,
|
||
# get_numeric_filter_value (Block 5), serp_wikipedia_lookup (Block 10).
|
||
# Nutzt die uebergeordnete sheet_handler Instanz (Block 14).
|
||
def process_find_wiki_serp(self, start_sheet_row=None, end_sheet_row=None, limit=None, min_employees=500, min_umsatz=200):
|
||
"""
|
||
Sucht fehlende Wikipedia-URLs (Spalte M = k.A.) ueber SerpAPI fuer Unternehmen mit
|
||
(Umsatz CRM > min_umsatz MIO € ODER Mitarbeiter CRM > min_employees)
|
||
UND wenn der SerpAPI Wiki Search Timestamp (AY) leer ist.
|
||
Traegt gefundene URLs in Spalte M ein. Setzt ReEval-Flag (A)
|
||
und loescht abhaengige Wiki-Spalten (N-V, AN, AO, AP, AX).
|
||
Setzt Timestamp in Spalte AY, wann die Suche durchgefuehrt wurde (unabhaengig vom Ergebnis).
|
||
|
||
Args:
|
||
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AY).
|
||
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
|
||
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht uebersprungener) Zeilen. Defaults to None (Unbegrenzt).
|
||
min_employees (int, optional): Mindestanzahl Mitarbeiter (Spalte K) als Teilfilter. Defaults to 500.
|
||
min_umsatz (int, optional): Mindestumsatz in MIO € (Spalte J) als Teilfilter. Defaults to 200.
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
# Logge die Konfiguration des Batch-Laufs
|
||
self.logger.info(f"Starte Modus 'find_wiki_serp' (AY, M, A). Filter: (Umsatz CRM > {min_umsatz} MIO € ODER Mitarbeiter CRM > {min_employees}). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT
|
||
|
||
|
||
# --- Daten laden und Startzeile ermitteln ---
|
||
# Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt
|
||
if start_sheet_row is None:
|
||
self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AY...") # <<< GEÄNDERT
|
||
# Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AY (Block 1 Column Map).
|
||
# Standardmaessig ab Zeile 7
|
||
start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="SerpAPI Wiki Search Timestamp", min_sheet_row=7)
|
||
|
||
# Wenn get_start_row_index -1 zurueckgibt (Fehler)
|
||
if start_data_index_no_header == -1:
|
||
self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche Batch ab.") # <<< GEÄNDERT
|
||
return # Beende die Methode
|
||
|
||
# Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten Daten-Index
|
||
start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut
|
||
self.logger.info(f"Automatisch ermittelte Startzeile (erste leere AY Zelle): {start_sheet_row}") # <<< GEÄNDERT
|
||
else:
|
||
# Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein.
|
||
# Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2).
|
||
if not self.sheet_handler.load_data():
|
||
self.logger.error("FEHLER beim Laden der Daten fuer process_find_wiki_serp.") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn das Laden fehlschlaegt
|
||
|
||
|
||
# Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler.
|
||
all_data = self.sheet_handler.get_all_data_with_headers();
|
||
# Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14).
|
||
header_rows = self.sheet_handler._header_rows;
|
||
total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet
|
||
|
||
|
||
# Berechne Endzeile, wenn nicht manuell gesetzt
|
||
if end_sheet_row is None:
|
||
end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
|
||
|
||
# Logge den verarbeitungsbereich
|
||
self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT
|
||
|
||
# Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen)
|
||
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
|
||
self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn der Bereich leer ist
|
||
|
||
|
||
# --- Indizes und Buchstaben ---
|
||
# Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind
|
||
required_keys = [
|
||
"SerpAPI Wiki Search Timestamp", "Wiki URL", "CRM Umsatz", "CRM Anzahl Mitarbeiter", # AY, M, J, K (Pruefkriterien / Timestamp)
|
||
"ReEval Flag", "CRM Name", "CRM Website", # A, B, D (Daten fuer Suche / Updates)
|
||
"Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", # N-R (Spalten zum Leeren)
|
||
"Chat Wiki Konsistenzpruefung", "Chat Begruendung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", # S-U (Spalten zum Leeren)
|
||
"Begruendung bei Abweichung", "Wikipedia Timestamp", "Timestamp letzte Pruefung", # V, AN, AO (Spalten zum Leeren)
|
||
"Version", "Wiki Verif. Timestamp" # AP, AX (Spalten zum Leeren)
|
||
]
|
||
# Erstellen Sie ein Dictionary mit Schluesseln und Indizes
|
||
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
|
||
|
||
# Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden
|
||
if None in col_indices.values():
|
||
missing = [k for k, v in col_indices.items() if v is None]
|
||
self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_find_wiki_serp: {missing}. Breche ab.") # <<< GEÄNDERT
|
||
return # Beende die Methode bei kritischem Fehler
|
||
|
||
# Ermitteln Sie die Spaltenbuchstaben fuer Updates und Leerung (nutzt interne Helfer _get_col_letter Block 14)
|
||
ts_ay_letter = self.sheet_handler._get_col_letter(col_indices["SerpAPI Wiki Search Timestamp"] + 1) # Timestamp zu setzen (AY)
|
||
m_letter = self.sheet_handler._get_col_letter(col_indices["Wiki URL"] + 1) # Wiki URL Spalte (M)
|
||
a_letter = self.sheet_handler._get_col_letter(col_indices["ReEval Flag"] + 1) # ReEval Flag (A)
|
||
|
||
# Spalten N-V leeren.
|
||
# N ist Wiki Absatz, V ist Begruendung bei Abweichung.
|
||
n_idx = col_indices["Wiki Absatz"]
|
||
v_idx = col_indices["Begruendung bei Abweichung"]
|
||
# Erstellen Sie den Bereichsnamen (z.B. "N:V")
|
||
n_letter = self.sheet_handler._get_col_letter(n_idx + 1)
|
||
v_letter = self.sheet_handler._get_col_letter(v_idx + 1)
|
||
nv_range_letter = f'{n_letter}:{v_letter}' # z.B. N:V
|
||
# Erstellen Sie eine Liste von leeren Strings fuer diesen Bereich
|
||
empty_nv_values = [''] * (v_idx - n_idx + 1) # Anzahl der Spalten = V_Index - N_Index + 1
|
||
|
||
|
||
# Timestamps AN, AO, AP, AX leeren.
|
||
an_letter = self.sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"] + 1) # AN (Wiki Extraction TS)
|
||
ao_letter = self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Pruefung"] + 1) # AO (Chat Evaluation TS)
|
||
ap_letter = self.sheet_handler._get_col_letter(col_indices["Version"] + 1) # AP (Version)
|
||
ax_letter = self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"] + 1) # AX (Wiki Verif. TS)
|
||
|
||
|
||
# --- Verarbeitung ---
|
||
# Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1)
|
||
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)
|
||
|
||
|
||
all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts)
|
||
|
||
|
||
processed_count = 0 # Zaehlt Zeilen, fuer die SerpAPI versucht wurde (im Rahmen des Limits zaehlen).
|
||
skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (verschiedene Gruende).
|
||
found_urls_count = 0 # Zaehlt Zeilen, wo eine URL gefunden und eingetragen wurde.
|
||
|
||
|
||
# Aktueller Zeitstempel fuer den AY Timestamp
|
||
now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
|
||
# Iteriere durch die Datenzeilen im definierten Bereich (1-basierte Sheet-Zeilennummer)
|
||
for i in range(start_sheet_row, end_sheet_row + 1):
|
||
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
|
||
# Pruefen Sie, ob das Ende des Sheets erreicht wurde
|
||
if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht
|
||
|
||
|
||
row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile
|
||
|
||
|
||
# Stellen Sie sicher, dass die Zeile nicht leer ist
|
||
if not any(cell and isinstance(cell, str) and cell.strip() for cell in row):
|
||
#self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug
|
||
skipped_count += 1 # Zaehlen als uebersprungen
|
||
continue # Springe zur naechsten Zeile
|
||
|
||
|
||
# --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
|
||
# Kriterium: SerpAPI Wiki Search Timestamp (AY) ist leer.
|
||
# UND Wiki URL (M) ist leer oder "k.A.".
|
||
# UND (Umsatz CRM (J) > min_umsatz MIO € ODER Mitarbeiter CRM (K) > min_employees).
|
||
|
||
# Holen Sie die Werte aus den entsprechenden Spalten (nutzt interne Helfer _get_cell_value_safe)
|
||
ay_value = self._get_cell_value_safe(row, "SerpAPI Wiki Search Timestamp").strip() # Block 1 Column Map
|
||
m_value = self._get_cell_value_safe(row, "Wiki URL").strip() # Block 1 Column Map
|
||
umsatz_val_str = self._get_cell_value_safe(row, "CRM Umsatz") # Block 1 Column Map
|
||
ma_val_str = self._get_cell_value_safe(row, "CRM Anzahl Mitarbeiter") # Block 1 Column Map
|
||
|
||
|
||
# Pruefen Sie, ob AY leer ist.
|
||
is_ay_empty = not ay_value
|
||
# Pruefen Sie, ob M leer oder "k.A." ist.
|
||
is_m_empty_or_ka = not m_value or (isinstance(m_value, str) and m_value.lower() == "k.a.")
|
||
|
||
# Nutze die globale Hilfsfunktion (Block 5), um die Werte fuer den Groessen-Filter zu bekommen.
|
||
# get_numeric_filter_value gibt 0 fuer ungueltige/leere Werte zurueck.
|
||
umsatz_val_mio = get_numeric_filter_value(umsatz_val_str, is_umsatz=True)
|
||
ma_val_num = get_numeric_filter_value(ma_val_str, is_umsatz=False)
|
||
|
||
# Pruefen Sie, ob das Groessen-Kriterium erfuellt ist.
|
||
size_criteria_met = (umsatz_val_mio > min_umsatz) or (ma_val_num > min_employees)
|
||
|
||
|
||
# Verarbeitung ist noetig, wenn AY leer ist UND M leer/k.A. ist UND das Groessen-Kriterium erfuellt ist.
|
||
processing_needed_for_row = is_ay_empty and is_m_empty_or_ka and size_criteria_met
|
||
|
||
|
||
# Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
|
||
log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
|
||
if log_check:
|
||
company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map
|
||
self.logger.debug(f"Zeile {i} ({company_name[:50]}... SerpAPI Wiki Search Check): AY leer? {is_ay_empty}, M leer/k.A.? {is_m_empty_or_ka}, Groesse ({umsatz_val_mio:.1f} Mio, {ma_val_num} MA) Kriterium ({min_umsatz} Mio, {min_employees} MA)? {size_criteria_met}. Benötigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT
|
||
|
||
|
||
# Wenn die Verarbeitung fuer diese Zeile nicht noetig ist
|
||
if not processing_needed_for_row:
|
||
skipped_count += 1 # Zaehlen als uebersprungene Zeile
|
||
continue # Springe zur naechsten Zeile
|
||
|
||
|
||
# --- Wenn Verarbeitung noetig: Fuehre SerpAPI Suche aus ---
|
||
processed_count += 1 # Zaehle die Zeile, fuer die SerpAPI versucht wird (im Rahmen des Limits zaehlen)
|
||
|
||
# Pruefe das Limit fuer verarbeitete Zeilen
|
||
if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit:
|
||
# Wenn das Limit erreicht ist und es ein positives Limit gibt
|
||
self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_find_wiki_serp erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
|
||
break # Brich die Schleife ab
|
||
|
||
|
||
# Hole Firmenname und Website fuer die Suche (nutzt interne Helfer _get_cell_value_safe)
|
||
company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map
|
||
website_url = self._get_cell_value_safe(row, "CRM Website").strip() # Block 1 Column Map (Website kann fuer SerpAPI Kontext hilfreich sein)
|
||
|
||
# Wenn kein Firmenname vorhanden ist, kann die Suche nicht durchgefuehrt werden
|
||
if not company_name:
|
||
self.logger.warning(f"Zeile {i}: Uebersprungen (kein Firmenname fuer Suche vorhanden in Spalte B).") # <<< GEÄNDERT
|
||
skipped_count += 1 # Zaehlen als uebersprungene Zeile, da Suche nicht moeglich
|
||
# Setze AY Timestamp auch hier, um nicht immer wieder zu versuchen
|
||
# Erstelle leeres Update-Dict, damit extend funktioniert
|
||
updates = []
|
||
updates.append({'range': f'{ts_ay_letter}{i}', 'values': [[now_timestamp_str]]}) # Block 1 Column Map
|
||
all_sheet_updates.extend(updates) # Fuege dieses einzelne Update zur Liste hinzu
|
||
updates = [] # Leere die lokale Liste
|
||
continue # Springe zur naechsten Zeile
|
||
|
||
|
||
self.logger.info(f"Zeile {i}: Suche Wiki-URL fuer '{company_name[:100]}...' (Umsatz (Mio): {umsatz_val_mio:.1f}, MA: {ma_val_num}) ueber SerpAPI...") # <<< GEÄNDERT
|
||
|
||
|
||
# Führe die SerpAPI Suche durch (nutzt globale Funktion Block 10 mit Retry).
|
||
# serp_wikipedia_lookup ist mit retry_on_failure dekoriert (Block 2).
|
||
# Wenn serp_wikipedia_lookup nach Retries fehlschlaegt, wirft er eine Exception.
|
||
wiki_url_found = None # Initialisiere mit None
|
||
try:
|
||
wiki_url_found = serp_wikipedia_lookup(company_name, website=website_url) # Nutzt globalen Helfer (Block 10)
|
||
# Wenn serp_wikipedia_lookup erfolgreich ist, gibt es die URL oder None zurueck.
|
||
|
||
except Exception as e_serp_wiki:
|
||
# Wenn serp_wikipedia_lookup eine Exception wirft (nach Retries)
|
||
self.logger.error(f"FEHLER bei serp_wikipedia_lookup fuer Zeile {i} ('{company_name[:100]}...'): {e_serp_wiki}") # <<< GEÄNDERT
|
||
# wiki_url_found bleibt None. Fahren Sie fort.
|
||
pass # Fahren Sie fort, um Timestamp zu setzen und Updates vorzubereiten
|
||
|
||
|
||
# --- Updates vorbereiten ---
|
||
# Timestamp AY IMMER setzen, nachdem der Versuch gemacht wurde, unabhaengig vom Ergebnis der Suche.
|
||
updates_for_row = [] # Lokale Liste fuer Updates dieser Zeile
|
||
|
||
updates_for_row.append({'range': f'{ts_ay_letter}{i}', 'values': [[now_timestamp_str]]}) # Block 1 Column Map
|
||
|
||
|
||
# Wenn eine URL gefunden wurde, bereite weitere Updates vor.
|
||
# Eine gefundene URL ist ein String, der nicht None ist und nicht "k.A." oder Fehlerstring ist.
|
||
if wiki_url_found and isinstance(wiki_url_found, str) and wiki_url_found.lower() not in ["k.a.", "kein artikel gefunden"] and not wiki_url_found.startswith("FEHLER"): # Korrektur Pruefung
|
||
self.logger.info(f" -> URL gefunden: {wiki_url_found[:100]}... Bereite Update vor (Setze M, A; Loesche N-V, AN, AO, AP, AX).") # <<< GEÄNDERT
|
||
found_urls_count += 1 # Zaehle den Fund
|
||
|
||
|
||
# Setze M (Wiki URL) mit der gefundenen URL
|
||
updates_for_row.append({'range': f'{m_letter}{i}', 'values': [[wiki_url_found]]}) # Block 1 Column Map
|
||
# Setze ReEval Flag (A) auf 'x' (signalisiert, dass eine Re-Evaluation noetig ist)
|
||
updates_for_row.append({'range': f'{a_letter}{i}', 'values': [['x']]}) # Block 1 Column Map
|
||
|
||
|
||
# Leere Spalten N-V.
|
||
# Fuege das Update zum Leeren des Bereichs V-Y hinzu, falls der Bereichsname ermittelt werden konnte.
|
||
if nv_range_letter: # Pruefe, ob der Bereichsname ermittelt werden konnte.
|
||
updates_for_row.append({'range': f'{n_letter}{i}:{v_letter}{i}', 'values': [empty_nv_values]}) # Block 1 Column Map, lokale Variable
|
||
else:
|
||
self.logger.warning(f"Konnte Spaltenbereich N-V ({n_letter}:{v_letter}) fuer Leerung nicht ermitteln fuer Zeile {i}. Leerung uebersprungen.") # <<< GEÄNDERT
|
||
|
||
|
||
# Leere Timestamps AN, AO, AP, AX.
|
||
# Dies setzt die Zeile zurueck, damit andere Schritte sie spaeter bearbeiten.
|
||
updates_for_row.append({'range': f'{an_letter}{i}', 'values': [['']]}) # Block 1 Column Map
|
||
updates_for_row.append({'range': f'{ao_letter}{i}', 'values': [['']]}) # Block 1 Column Map
|
||
updates_for_row.append({'range': f'{ap_letter}{i}', 'values': [['']]}) # Block 1 Column Map
|
||
updates_for_row.append({'range': f'{ax_letter}{i}', 'values': [['']]}) # Block 1 Column Map
|
||
|
||
|
||
else:
|
||
# Wenn keine Wiki-URL ueber SerpAPI gefunden wurde
|
||
self.logger.debug(f" -> Keine Wiki-URL fuer '{company_name[:100]}...' ueber SerpAPI gefunden.") # <<< GEÄNDERT
|
||
# Nur AY Timestamp wird gesetzt, was bereits oben passiert ist. Keine weiteren Updates fuer M, A, N-V etc.
|
||
|
||
|
||
# Sammle die Updates fuer diese Zeile in der globalen Liste.
|
||
all_sheet_updates.extend(updates_for_row)
|
||
|
||
|
||
# Sende gesammelte Sheet Updates, wenn das Update-Batch-Limit erreicht ist.
|
||
# update_batch_row_limit wird aus Config geholt (Block 1).
|
||
# Die Anzahl der Updates pro Zeile variiert (1 bei nicht gefunden, ca. 10+ bei gefunden).
|
||
# Pruefen Sie einfach die Laenge der gesammelten Liste.
|
||
if len(all_sheet_updates) >= update_batch_row_limit * 5: # Grobe Schaetzung: im Schnitt 5 Updates pro Zeile
|
||
self.logger.debug(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
|
||
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
|
||
# Wenn es fehlschlaegt, wird es intern geloggt.
|
||
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
|
||
if success:
|
||
self.logger.info(f" Sheet-Update fuer {len(all_sheet_updates)} Zellen erfolgreich.") # <<< GEÄNDERT
|
||
# Der Fehlerfall wird von batch_update_cells geloggt
|
||
|
||
# Leere die gesammelten Updates nach dem Senden.
|
||
all_sheet_updates = []
|
||
|
||
# Kleine Pause nach jeder SerpAPI-Suche (nutzt Config Block 1).
|
||
# Der retry_on_failure Decorator (Block 2) kuemmert sich um Retries mit Backoff.
|
||
# Dies ist eine globale Rate-Limit-Vorsorge zwischen einzelnen Anfragen.
|
||
serp_delay = getattr(Config, 'SERPAPI_DELAY', 1.5)
|
||
#self.logger.debug(f"Warte {serp_delay:.2f}s nach SerpAPI Suche...") # Zu viel Laerm im Debug
|
||
time.sleep(serp_delay)
|
||
|
||
|
||
# --- Finale Sheet Updates senden ---
|
||
# Sende alle verbleibenden gesammelten Updates in einem letzten Batch-Update.
|
||
if all_sheet_updates:
|
||
self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
|
||
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
|
||
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
|
||
if success:
|
||
self.logger.info(f"FINALES Sheet-Update erfolgreich.") # <<< GEÄNDERT
|
||
# Der Fehlerfall wird von batch_update_cells geloggt
|
||
|
||
|
||
# Logge den Abschluss des Modus
|
||
self.logger.info(f"Modus 'find_wiki_serp' abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {found_urls_count} URLs gefunden & eingetragen, {skipped_count} Zeilen uebersprungen.") # <<< GEÄNDERT
|
||
# Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt.
|
||
|
||
|
||
# --- Methode fuer den Contact Search Batchmodus (AM, AI-AL) ---
|
||
# Diese Methode sucht LinkedIn Kontakte ueber SerpAPI und traegt Trefferzahlen ins Sheet ein.
|
||
# Basierend auf process_contact_research aus Teil 10.
|
||
# Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter.
|
||
# Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), datetime, time,
|
||
# search_linkedin_contacts (Block 10), get_gender (Block 5), get_email_address (Block 5).
|
||
# Nutzt die uebergeordnete sheet_handler Instanz (Block 14).
|
||
def process_contact_search(self, start_sheet_row=None, end_sheet_row=None, limit=None):
|
||
"""
|
||
Sucht LinkedIn Kontakte ueber SerpAPI fuer Zeilen, bei denen der
|
||
Contact Search Timestamp (AM) leer ist. Traegt Trefferzahlen in
|
||
AI-AL und den Timestamp in AM ein. Schreibt Details optional in ein 'Contacts' Blatt.
|
||
|
||
Args:
|
||
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AM).
|
||
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
|
||
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht uebersprungener) Zeilen. Defaults to None (Unbegrenzt).
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
# Logge die Konfiguration des Batch-Laufs
|
||
self.logger.info(f"Starte Contact Research (Batch AM, AI-AL). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT
|
||
|
||
|
||
# --- Daten laden und Startzeile ermitteln ---
|
||
# Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt
|
||
if start_sheet_row is None:
|
||
self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AM...") # <<< GEÄNDERT
|
||
# Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AM (Block 1 Column Map).
|
||
# Standardmaessig ab Zeile 7
|
||
start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Contact Search Timestamp", min_sheet_row=7)
|
||
|
||
# Wenn get_start_row_index -1 zurueckgibt (Fehler)
|
||
if start_data_index_no_header == -1:
|
||
self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche Batch ab.") # <<< GEÄNDERT
|
||
return # Beende die Methode
|
||
|
||
# Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten Daten-Index
|
||
start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut
|
||
self.logger.info(f"Automatisch ermittelte Startzeile (erste leere AM Zelle): {start_sheet_row}") # <<< GEÄNDERT
|
||
else:
|
||
# Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein.
|
||
# Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2).
|
||
if not self.sheet_handler.load_data():
|
||
self.logger.error("FEHLER beim Laden der Daten fuer process_contact_search.") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn das Laden fehlschlaegt
|
||
|
||
|
||
# Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler.
|
||
all_data = self.sheet_handler.get_all_data_with_headers()
|
||
# Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14).
|
||
header_rows = self.sheet_handler._header_rows
|
||
total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet
|
||
|
||
|
||
# Berechne Endzeile, wenn nicht manuell gesetzt
|
||
if end_sheet_row is None:
|
||
end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
|
||
|
||
# Logge den verarbeitungsbereich
|
||
self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT
|
||
|
||
# Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen)
|
||
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
|
||
self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn der Bereich leer ist
|
||
|
||
|
||
# --- Indizes und Buchstaben ---
|
||
# Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind
|
||
required_keys = [
|
||
"Contact Search Timestamp", # AM - Pruefkriterium / Timestamp
|
||
"CRM Name", "CRM Kurzform", "CRM Website", # B, C, D (Daten fuer Suche)
|
||
"Linked Serviceleiter gefunden", "Linked It-Leiter gefunden", # AI, AJ (Zielspalten fuer Trefferzahlen)
|
||
"Linked Management gefunden", "Linked Disponent gefunden" # AK, AL (Zielspalten fuer Trefferzahlen)
|
||
]
|
||
# Erstellen Sie ein Dictionary mit Schluesseln und Indizes
|
||
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
|
||
|
||
# Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden
|
||
if None in col_indices.values():
|
||
missing = [k for k, v in col_indices.items() if v is None]
|
||
self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_contact_search: {missing}. Breche ab.") # <<< GEÄNDERT
|
||
return # Beende die Methode bei kritischem Fehler
|
||
|
||
|
||
# Ermitteln Sie die Spaltenbuchstaben fuer Updates (AI-AL, AM) (nutzt interne Helfer _get_col_letter Block 14)
|
||
ts_am_letter = self.sheet_handler._get_col_letter(col_indices["Contact Search Timestamp"] + 1) # AM
|
||
ai_letter = self.sheet_handler._get_col_letter(col_indices["Linked Serviceleiter gefunden"] + 1) # AI
|
||
aj_letter = self.sheet_handler._get_col_letter(col_indices["Linked It-Leiter gefunden"] + 1) # AJ
|
||
ak_letter = self.sheet_handler._get_col_letter(col_indices["Linked Management gefunden"] + 1) # AK
|
||
al_letter = self.sheet_handler._get_col_letter(col_indices["Linked Disponent gefunden"] + 1) # AL
|
||
|
||
|
||
# Positionen, nach denen gesucht wird (kann in Config verschoben werden Block 1)
|
||
# Die Zuordnung zur Zaehlspalte (AI-AL) muss hier im Code erfolgen.
|
||
positions_to_search = {
|
||
"Serviceleiter": ["Serviceleiter", "Leiter Kundendienst", "Einsatzleiter"],
|
||
"IT-Leiter": ["IT-Leiter", "Leiter IT"],
|
||
"Management": ["Geschäftsführer", "Vorstand", "Inhaber", "CEO", "CTO", "COO", "Kaufmännischer Leiter", "Technischer Leiter"], # Management erweitert
|
||
"Disponent": ["Disponent", "Einsatzplaner"] # Disponent erweitert
|
||
}
|
||
# Stellen Sie sicher, dass die Schluessel im Dict den COLUMN_MAP Keys (AI-AL) entsprechen,
|
||
# damit die Zaehlung korrekt zugeordnet werden kann.
|
||
|
||
|
||
# --- Kontakte-Blatt oeffnen oder erstellen ---
|
||
contacts_sheet = None # Initialisiere mit None
|
||
# Der Zugriff auf das Spreadsheet-Objekt erfolgt ueber den SheetHandler (Block 14).
|
||
if self.sheet_handler and self.sheet_handler.sheet and self.sheet_handler.sheet.spreadsheet:
|
||
try:
|
||
# Versuche, das Sheet "Contacts" zu oeffnen
|
||
contacts_sheet = self.sheet_handler.sheet.spreadsheet.worksheet("Contacts")
|
||
self.logger.info("Blatt 'Contacts' gefunden.") # <<< GEÄNDERT
|
||
except gspread.exceptions.WorksheetNotFound:
|
||
# Wenn nicht gefunden, erstelle es.
|
||
self.logger.info("Blatt 'Contacts' nicht gefunden, erstelle neu...") # <<< GEÄNDERT
|
||
try:
|
||
# Definieren Sie den Header fuer das neue Blatt
|
||
contacts_header = ["Firmenname", "CRM Kurzform", "Website", "Geschlecht", "Vorname", "Nachname", "Position", "Suchbegriffskategorie", "E-Mail-Adresse", "LinkedIn-Link", "Timestamp"]
|
||
# Schaetzen Sie die Anzahl der Zeilen und Spalten fuer das neue Blatt (kann angepasst werden)
|
||
num_cols_contacts_sheet = len(contacts_header)
|
||
# Erstellen Sie das neue Blatt
|
||
contacts_sheet = self.sheet_handler.sheet.spreadsheet.add_worksheet(title="Contacts", rows="5000", cols=num_cols_contacts_sheet)
|
||
# Schreiben Sie den Header in die erste Zeile des neuen Blattes
|
||
# Nutzt _get_col_letter interne Methode des SheetHandlers (Block 14)
|
||
contacts_sheet.update(values=[contacts_header], range_name=f"A1:{self.sheet_handler._get_col_letter(num_cols_contacts_sheet)}1")
|
||
self.logger.info("Neues Blatt 'Contacts' erstellt und Header eingetragen.") # <<< GEÄNDERT
|
||
|
||
except Exception as e_create_sheet:
|
||
# Fange Fehler bei der Erstellung des Blattes ab und logge sie.
|
||
self.logger.critical(f"FEHLER: Konnte Blatt 'Contacts' nicht erstellen: {e_create_sheet}. Kontakt-Details koennen NICHT gespeichert werden.") # <<< GEÄNDERT
|
||
# Logge den Traceback.
|
||
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
|
||
contacts_sheet = None # Setze contacts_sheet auf None, um spaetere Schreibversuche zu verhindern
|
||
|
||
else:
|
||
# Wenn SheetHandler oder Sheet-Objekt nicht verfuegbar war.
|
||
self.logger.warning("SheetHandler oder Sheet-Objekt nicht verfuegbar. Kann Blatt 'Contacts' nicht oeffnen/erstellen. Kontakt-Details werden NICHT gespeichert.") # <<< GEÄNDERT
|
||
contacts_sheet = None # Sicherstellen, dass contacts_sheet None ist
|
||
|
||
|
||
# --- Verarbeitung ---
|
||
all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Hauptblatt (Liste von Dicts)
|
||
all_contact_rows_to_append = [] # Gesammelte Zeilen fuer append_rows ins Contacts-Blatt (Liste von Listen)
|
||
# append_rows kann grosse Batches handhaben, wir koennen hier mehr sammeln als beim Batch-Update.
|
||
# Oder wir schreiben pro Firma in das Contacts-Blatt (weniger sammelbar).
|
||
# Fuer diesen Modus sammeln wir alle Kontaktzeilen und schreiben am Ende gesammelt mit append_rows.
|
||
|
||
|
||
processed_count = 0 # Zaehlt Zeilen im Hauptblatt, die fuer die Verarbeitung in Frage kommen und in den Batch aufgenommen werden (im Rahmen des Limits).
|
||
skipped_count = 0 # Zaehlt Zeilen im Hauptblatt, die uebersprungen wurden (wegen AM oder fehlender Daten).
|
||
|
||
|
||
# Aktueller Zeitstempel fuer die AM Timestamp und Kontaktzeilen
|
||
now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
|
||
# Iteriere durch die Datenzeilen im definierten Bereich (1-basierte Sheet-Zeilennummer)
|
||
for i in range(start_sheet_row, end_sheet_row + 1):
|
||
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
|
||
# Pruefen Sie, ob das Ende des Sheets erreicht wurde
|
||
if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht
|
||
|
||
|
||
row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile
|
||
|
||
|
||
# Stellen Sie sicher, dass die Zeile nicht leer ist
|
||
if not any(cell and isinstance(cell, str) and cell.strip() for cell in row):
|
||
#self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug
|
||
skipped_count += 1 # Zaehlen als uebersprungen
|
||
continue # Springe zur naechsten Zeile
|
||
|
||
|
||
# --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
|
||
# Kriterium: Contact Search Timestamp (AM) ist leer.
|
||
# ZUSAETZLICH: Pruefen, ob CRM Name, Kurzform und Website vorhanden und gueltig sind.
|
||
|
||
# Holen Sie den Wert aus Spalte AM (Contact Search Timestamp) (nutzt interne Helfer _get_cell_value_safe)
|
||
am_value = self._get_cell_value_safe(row, "Contact Search Timestamp").strip() # Block 1 Column Map
|
||
# Pruefung basiert darauf, ob AM leer ist.
|
||
processing_needed_based_on_status = not am_value
|
||
|
||
|
||
# Holen Sie die benoetigten Daten fuer die Suche (nutzt interne Helfer _get_cell_value_safe)
|
||
company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map
|
||
crm_kurzform = self._get_cell_value_safe(row, "CRM Kurzform").strip() # Block 1 Column Map
|
||
website = self._get_cell_value_safe(row, "CRM Website").strip() # Block 1 Column Map
|
||
|
||
# Pruefen Sie, ob die Mindestdaten fuer die Suche vorhanden und gueltig sind.
|
||
# Name und Kurzform duerfen nicht leer sein. Website muss vorhanden und gueltig aussehen.
|
||
has_min_data_for_search = company_name and crm_kurzform and website and isinstance(website, str) and website.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"] # Fuege "http:" hinzu
|
||
|
||
|
||
# Verarbeitung ist noetig, wenn AM leer ist UND die Mindestdaten fuer die Suche vorhanden sind.
|
||
processing_needed_for_row = processing_needed_based_on_status and has_min_data_for_search
|
||
|
||
|
||
# Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
|
||
log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
|
||
if log_check:
|
||
company_name_log = company_name[:50] + '...' if len(company_name) > 50 else company_name # Gekuerzt loggen
|
||
self.logger.debug(f"Zeile {i} ({company_name_log} Contact Check): AM leer? {processing_needed_based_on_status}, Mindestdaten gueltig? {has_min_data_for_search}. Benötigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT
|
||
|
||
|
||
# Wenn die Verarbeitung fuer diese Zeile nicht noetig ist
|
||
if not processing_needed_for_row:
|
||
skipped_count += 1 # Zaehlen als uebersprungene Zeile
|
||
continue # Springe zur naechsten Zeile
|
||
|
||
|
||
# --- Wenn Verarbeitung noetig: Fuehre LinkedIn Suche(n) aus ---
|
||
processed_count += 1 # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im Rahmen des Limits zaehlen)
|
||
|
||
# Pruefe das Limit fuer verarbeitete Zeilen
|
||
if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit:
|
||
# Wenn das Limit erreicht ist und es ein positives Limit gibt
|
||
self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_contact_search erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
|
||
break # Brich die Schleife ab
|
||
|
||
|
||
self.logger.info(f"Zeile {i}: Suche LinkedIn Kontakte fuer '{crm_kurzform[:50]}...' ({website[:50]}...)...") # <<< GEÄNDERT
|
||
|
||
|
||
all_found_contacts_for_row = [] # Liste zum Sammeln aller gefundenen Kontakte fuer DIESE Zeile (Liste von Dicts)
|
||
contact_counts_for_row = {key: 0 for key in positions_to_search.keys()} # Dictionary zum Zaehlen der Treffer pro Kategorie fuer diese Zeile (AI-AL)
|
||
|
||
|
||
# Führe die Suche fuer jede Positionskategorie durch.
|
||
# positions_to_search Dictionary ist oben definiert.
|
||
for category, queries in positions_to_search.items():
|
||
# Führe die Suche fuer jede spezifische Abfrage innerhalb der Kategorie durch.
|
||
# search_linkedin_contacts (Block 10) nutzt den retry_on_failure Decorator (Block 2).
|
||
# Wenn search_linkedin_contacts fehlschlaegt, wirft es eine Exception oder gibt eine leere Liste zurueck.
|
||
found_contacts_in_category = {} # Dictionary zum Sammeln eindeutiger Kontakte {linkedin_url: contact_data} fuer diese Kategorie
|
||
|
||
for position_query in queries:
|
||
self.logger.debug(f" -> Suche nach Position: '{position_query}' bei '{crm_kurzform[:50]}'...") # <<< GEÄNDERT
|
||
try:
|
||
# Rufe die globale Funktion search_linkedin_contacts auf (Block 10).
|
||
# Limitieren Sie die Anzahl der SerpAPI Ergebnisse pro Query, um Kosten zu managen.
|
||
contacts_from_query = search_linkedin_contacts(
|
||
company_name=company_name, # Voller Name fuer Kontext (optional genutzt)
|
||
website=website, # Website fuer Email-Generierung spaeter
|
||
position_query=position_query, # Die spezifische Position
|
||
crm_kurzform=crm_kurzform, # Die Kurzform der Firma
|
||
num_results=getattr(Config, 'SERPAPI_LINKEDIN_RESULTS_PER_QUERY', 5) # Konfigurierbar in Config (Block 1)
|
||
)
|
||
|
||
# Fuege die gefundenen Kontakte (mit Suchkategorie) zur Liste fuer diese Kategorie hinzu, dedupliziert ueber URL.
|
||
for contact in contacts_from_query:
|
||
linkedin_url = contact.get("LinkedInURL")
|
||
if linkedin_url and isinstance(linkedin_url, str) and linkedin_url.strip(): # Stelle sicher, dass URL gueltig ist
|
||
if linkedin_url not in found_contacts_in_category:
|
||
# Wenn die URL noch nicht in dieser Kategorie gefunden wurde, fuege den Kontakt hinzu.
|
||
contact["Suchbegriffskategorie"] = category # Speichere die Kategorie, die den Treffer brachte
|
||
found_contacts_in_category[linkedin_url] = contact
|
||
# else: Wenn die URL bereits gefunden wurde, mache nichts (erste Kategorie wird beibehalten).
|
||
# self.logger.debug(f" -> Gefunden: {contact.get('Vorname')} {contact.get('Nachname')} ({contact.get('Position')})") # Zu viel Laerm im Debug
|
||
|
||
|
||
except Exception as e_linkedin_search:
|
||
# Wenn search_linkedin_contacts eine Exception wirft (nach Retries)
|
||
# Der Fehler wird bereits vom retry_on_failure Decorator oder search_linkedin_contacts geloggt.
|
||
self.logger.error(f"FEHLER bei search_linkedin_contacts fuer Zeile {i} (Query: '{position_query}', Firma: '{crm_kurzform[:50]}...'): {e_linkedin_search}") # <<< GEÄNDERT
|
||
pass # Faert fort mit der naechsten Query oder Kategorie
|
||
|
||
# Pause nach jeder SerpAPI Suche (pro position_query)
|
||
# Nutzt Config.SERPAPI_DELAY (Block 1).
|
||
serp_delay = getattr(Config, 'SERPAPI_DELAY', 1.5)
|
||
#self.logger.debug(f"Warte {serp_delay:.2f}s nach LinkedIn Suche fuer '{position_query}'...") # Zu viel Laerm im Debug
|
||
time.sleep(serp_delay)
|
||
|
||
# Zaehle die eindeutigen Treffer in dieser Kategorie nach allen Queries innerhalb der Kategorie.
|
||
contact_counts_for_row[category] = len(found_contacts_in_category)
|
||
# Fuege die eindeutigen Kontakte DIESER Kategorie zur Gesamtliste fuer DIESE Zeile hinzu.
|
||
all_found_contacts_for_row.extend(found_contacts_in_category.values())
|
||
|
||
|
||
# --- Verarbeite gefundene Kontakte und bereite Updates vor ---
|
||
rows_to_append_to_contacts_sheet = [] # Liste von Listen fuer append_rows ins 'Contacts' Blatt
|
||
main_sheet_updates_for_row = [] # Updates fuer das Hauptblatt (AI-AL, AM) fuer DIESE Zeile
|
||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Timestamp fuer DIESE Zeile/Kontakte
|
||
|
||
|
||
# Fuegen Sie die Updates fuer die Trefferzahlen im Hauptblatt hinzu (nutzt interne Helfer _get_col_letter Block 14)
|
||
# Stellen Sie sicher, dass die Spaltenbuchstaben korrekt sind (AI, AJ, AK, AL) (aus oben ermittelt)
|
||
main_sheet_updates_for_row.append({'range': f'{ai_letter}{i}', 'values': [[str(contact_counts_for_row.get("Serviceleiter", 0))]]})
|
||
main_sheet_updates_for_row.append({'range': f'{aj_letter}{i}', 'values': [[str(contact_counts_for_row.get("IT-Leiter", 0))]]})
|
||
main_sheet_updates_for_row.append({'range': f'{ak_letter}{i}', 'values': [[str(contact_counts_for_row.get("Management", 0))]]})
|
||
main_sheet_updates_for_row.append({'range': f'{al_letter}{i}', 'values': [[str(contact_counts_for_row.get("Disponent", 0))]]})
|
||
# Setze den Contact Search Timestamp (AM) fuer DIESE Zeile
|
||
main_sheet_updates_for_row.append({'range': f'{ts_am_letter}{i}', 'values': [[timestamp]]})
|
||
|
||
|
||
# Sammeln Sie diese Updates fuer das Hauptblatt in der globalen Liste all_sheet_updates.
|
||
all_sheet_updates.extend(main_sheet_updates_for_row)
|
||
self.logger.info(f"Zeile {i}: Kontaktzahlen gesammelt: {contact_counts_for_row} – Timestamp AM vorgemerkt fuer Update.") # <<< GEÄNDERT
|
||
|
||
|
||
# Bereiten Sie die Zeilen fuer das 'Contacts' Blatt vor (falls es existiert).
|
||
# all_found_contacts_for_row enthaelt alle gefundenen Kontakte fuer DIESE Zeile (mit evtl. Duplikaten bei URL).
|
||
if contacts_sheet: # Pruefen Sie, ob das Contacts-Sheet geoeffnet/erstellt werden konnte (siehe Initialisierung oben)
|
||
# Führen Sie eine finale Deduplizierung ueber die LinkedIn-URL durch.
|
||
# Dictionary-Comprehension behält nur das letzte Vorkommen bei Duplikaten.
|
||
unique_contacts_for_row_dict = {c['LinkedInURL']: c for c in all_found_contacts_for_row if c.get('LinkedInURL')} # Filtere Kontakte ohne URL
|
||
unique_contacts_for_row = list(unique_contacts_for_row_dict.values()) # Liste der eindeutigen Kontakte
|
||
|
||
# Iteriere ueber die eindeutigen Kontakte fuer diese Zeile
|
||
for contact in unique_contacts_for_row:
|
||
firstname = contact.get("Vorname", "") # Nutzt den extrahierten Vornamen
|
||
lastname = contact.get("Nachname", "") # Nutzt den extrahierten Nachnamen
|
||
|
||
# Generiere Geschlecht und E-Mail-Adresse (nutzt globale Funktionen Block 5).
|
||
# get_gender und get_email_address behandeln leere/ungueltige Eingaben.
|
||
gender_value = get_gender(firstname)
|
||
email = get_email_address(firstname, lastname, website) # Nutzt die Website der Firma (initial geladen)
|
||
|
||
# Erstellen Sie die Liste der Werte fuer eine Zeile im 'Contacts' Blatt.
|
||
contact_row = [
|
||
contact.get("Firmenname", ""), # Voller Firmenname
|
||
contact.get("CRM Kurzform", ""), # Firmenkurzform
|
||
contact.get("Website", ""), # Website der Firma
|
||
gender_value, # Generiertes Geschlecht
|
||
firstname, # Extrahierter Vorname
|
||
lastname, # Extrahierter Nachname
|
||
contact.get("Position", ""), # Extrahierte oder Fallback Position
|
||
contact.get("Suchbegriffskategorie", ""), # Kategorie, die den Treffer brachte
|
||
email, # Generierte E-Mail-Adresse
|
||
contact.get("LinkedInURL", ""), # URL des LinkedIn Profils
|
||
timestamp # Zeitstempel des Suchlaufs
|
||
]
|
||
# Fuegen Sie diese Zeile zur Liste der Zeilen hinzu, die spaeter ins Contacts-Sheet geschrieben werden.
|
||
rows_to_append_to_contacts_sheet.append(contact_row)
|
||
|
||
# Wenn Zeilen zum Anfuegen gefunden wurden
|
||
if rows_to_append_to_contacts_sheet:
|
||
# Fuegen Sie diese Zeilen zur globalen Liste aller Kontakte hinzu, die spaeter angefuegt werden.
|
||
all_contact_rows_to_append.extend(rows_to_append_to_contacts_sheet)
|
||
self.logger.debug(f" -> {len(rows_to_append_to_contacts_sheet)} eindeutige Kontakte fuer Zeile {i} zum Anfuegen an 'Contacts' vorgemerkt.") # <<< GEÄNDERT
|
||
else:
|
||
self.logger.debug(f" -> Keine neuen Kontakte fuer Zeile {i} gefunden.") # <<< GEÄNDERT
|
||
|
||
|
||
# Sende gesammelte Sheet Updates (Hauptblatt) wenn das Update-Batch-Limit erreicht ist.
|
||
# update_batch_row_limit wird aus Config geholt (Block 1).
|
||
# Updates pro Zeile im Hauptblatt sind 5 (AI-AL + AM). Anzahl der Zeilen = len(all_sheet_updates) / 5.
|
||
rows_in_main_sheet_update_batch = len(all_sheet_updates) // 5
|
||
|
||
if rows_in_main_sheet_update_batch >= update_batch_row_limit:
|
||
self.logger.debug(f" Sende gesammelte Hauptblatt-Updates ({rows_in_main_sheet_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
|
||
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
|
||
# Wenn es fehlschlaegt, wird es intern geloggt.
|
||
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
|
||
if success:
|
||
self.logger.info(f" Hauptblatt-Update fuer {rows_in_main_sheet_update_batch} Zeilen erfolgreich.") # <<< GEÄNDERT
|
||
# Der Fehlerfall wird von batch_update_cells geloggt
|
||
|
||
# Leere die gesammelten Updates nach dem Senden.
|
||
all_sheet_updates = []
|
||
|
||
|
||
# Eine laengere Pause nach der Verarbeitung jeder Firma im Contact Search Modus.
|
||
# Dieser Modus ist API-intensiv und sollte langsamer laufen.
|
||
# Nutzt Config.RETRY_DELAY (Block 1).
|
||
pause_duration = getattr(Config, 'RETRY_DELAY', 10) * 0.8 # Laengere Pause, z.B. 80% der Retry-Wartezeit
|
||
self.logger.debug(f"Warte {pause_duration:.2f}s nach Verarbeitung von Zeile {i}...") # <<< GEÄNDERT
|
||
time.sleep(pause_duration)
|
||
|
||
|
||
# --- Finale Sheet Updates (Hauptblatt) senden ---
|
||
# Sende alle verbleibenden gesammelten Updates in einem letzten Batch-Update.
|
||
if all_sheet_updates:
|
||
rows_in_final_main_sheet_update_batch = len(all_sheet_updates) // 5
|
||
self.logger.info(f"Sende FINALE gesammelte Hauptblatt-Updates ({rows_in_final_main_sheet_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
|
||
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
|
||
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
|
||
if success:
|
||
self.logger.info(f"FINALES Hauptblatt-Update erfolgreich.") # <<< GEÄNDERT
|
||
# Der Fehlerfall wird von batch_update_cells geloggt
|
||
|
||
|
||
# --- Finale Kontakte-Zeilen (Contacts Sheet) anfuegen ---
|
||
# Fuege alle gesammelten Kontaktzeilen auf einmal ans Ende des 'Contacts' Blattes an.
|
||
if contacts_sheet and all_contact_rows_to_append:
|
||
self.logger.info(f"Fuege {len(all_contact_rows_to_append)} gesammelte Kontaktzeilen an Blatt 'Contacts' an...") # <<< GEÄNDERT
|
||
try:
|
||
# append_rows ist effizienter als batch_update fuer viele neue Zeilen am Ende.
|
||
# Die gspread.Worksheet.append_rows Methode kann Exceptions werfen (z.B. APIError),
|
||
# die hier gefangen werden koennen, wenn gewuenscht.
|
||
# Wenn sie eine Exception wirft, wird diese nicht von retry_on_failure auf
|
||
# process_contact_search behandelt, da append_rows nicht mit @retry_on_failure
|
||
# dekoriert ist. Sie muessten append_rows selbst in einen try/except Block packen oder
|
||
# es mit @retry_on_failure dekorieren (falls gspread es unterstuetzt).
|
||
# Fuer jetzt, fangen wir die Exception hier.
|
||
contacts_sheet.append_rows(all_contact_rows_to_append, value_input_option='USER_ENTERED') # Standard Option
|
||
self.logger.info(f"Anfuegen von {len(all_contact_rows_to_append)} Kontaktzeilen erfolgreich.") # <<< GEÄNDERT
|
||
except Exception as e_append:
|
||
# Fange Fehler beim Anfuegen der Zeilen ab und logge sie.
|
||
self.logger.error(f"FEHLER beim Anfuegen von Kontaktzeilen an Blatt 'Contacts': {type(e_append).__name__} - {e_append}") # <<< GEÄNDERT
|
||
# Logge den Traceback.
|
||
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
|
||
pass # Faert fort, der Rest des Skripts sollte nicht blockiert werden
|
||
|
||
|
||
# Logge den Abschluss des Modus
|
||
self.logger.info(f"Modus 'contact_search' abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {skipped_count} Zeilen uebersprungen.") # <<< GEÄNDERT
|
||
# Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt.
|
||
|
||
# ==============================================================================
|
||
# Ende DataProcessor Klasse Batch: SerpAPI Suchen & Contacts Block
|
||
# ==============================================================================
|
||
|
||
|
||
# ==========================================================================
|
||
# === Utility Methods (URL Check & Update) =================================
|
||
# ==========================================================================
|
||
|
||
def process_url_check(self, start_sheet_row=None, end_sheet_row=None, limit=None):
|
||
"""
|
||
Sucht nach Zeilen, die in Spalte AR mit URL_CHECK_MARKER oder bekannten "k.A. (Fehler...)"
|
||
Mustern markiert sind UND bei denen der AY-Timestamp (SerpAPI Wiki Search Timestamp) leer ist
|
||
(außer bei URL_CHECK_MARKER, der immer eine Suche auslöst).
|
||
Versucht, eine neue URL ueber SerpAPI zu finden.
|
||
Wenn erfolgreich und URL ist NEU: Aktualisiert D, loescht AR, setzt ReEval-Flag (A) und loescht Timestamps.
|
||
Wenn URL identisch oder keine neue URL gefunden: AR wird entsprechend aktualisiert.
|
||
Setzt immer den AY-Timestamp (als Timestamp der URL-Prüfung).
|
||
|
||
Args:
|
||
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (ab Zeile 7).
|
||
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
|
||
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER Zeilen. Defaults to None (Unbegrenzt).
|
||
"""
|
||
self.logger.info(f"Starte Modus 'check_urls'. Sucht nach '{URL_CHECK_MARKER}' oder 'k.A. (Fehler...)' in AR. Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...")
|
||
|
||
# --- Konfiguration holen ---
|
||
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) # <<< NEUE ZEILE HINZUGEFÜGT
|
||
|
||
if not self.sheet_handler.load_data():
|
||
self.logger.error("Fehler beim Laden der Daten fuer URL Check.")
|
||
return
|
||
|
||
all_data = self.sheet_handler.get_all_data_with_headers()
|
||
header_rows = self.sheet_handler._header_rows
|
||
total_sheet_rows = len(all_data)
|
||
|
||
if start_sheet_row is None: start_sheet_row = header_rows + 1
|
||
if end_sheet_row is None: end_sheet_row = total_sheet_rows
|
||
|
||
self.logger.info(f"Suchbereich fuer URL Checks: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}.")
|
||
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
|
||
self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.")
|
||
return
|
||
|
||
required_keys = [
|
||
"Website Rohtext", "CRM Name", "CRM Website", "ReEval Flag",
|
||
"Website Scrape Timestamp", "Timestamp letzte Pruefung",
|
||
"Wikipedia Timestamp", "Wiki Verif. Timestamp", "SerpAPI Wiki Search Timestamp",
|
||
"Version"
|
||
]
|
||
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
|
||
if None in col_indices.values():
|
||
missing = [k for k, v in col_indices.items() if v is None]
|
||
self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_url_check: {missing}. Breche ab.")
|
||
return
|
||
|
||
ar_letter = self.sheet_handler._get_col_letter(col_indices["Website Rohtext"] + 1)
|
||
d_letter = self.sheet_handler._get_col_letter(col_indices["CRM Website"] + 1)
|
||
a_letter = self.sheet_handler._get_col_letter(col_indices["ReEval Flag"] + 1)
|
||
at_letter = self.sheet_handler._get_col_letter(col_indices["Website Scrape Timestamp"] + 1)
|
||
ao_letter = self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Pruefung"] + 1)
|
||
an_letter = self.sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"] + 1)
|
||
ax_letter = self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"] + 1)
|
||
ay_letter = self.sheet_handler._get_col_letter(col_indices["SerpAPI Wiki Search Timestamp"] + 1) # Timestamp dieser Funktion
|
||
ap_letter = self.sheet_handler._get_col_letter(col_indices["Version"] + 1)
|
||
|
||
ka_error_patterns = [
|
||
"k.A.", "k.A. (Extraktion leer)", "k.A. (Nur Cookie-Banner erkannt)",
|
||
"k.A. (Kein Body gefunden)", "k.A. (Fehler Parsing:", "k.A. (Unerwarteter Fehler Task)",
|
||
"k.A. (Fehler Scraping:", "k.A. (Timeout", "k.A. (SSL Fehler",
|
||
"k.A. (Connection Error", "k.A. (HTTP Error", URL_CHECK_MARKER
|
||
]
|
||
|
||
all_sheet_updates = []
|
||
processed_count = 0
|
||
skipped_count = 0
|
||
found_new_url_count = 0
|
||
now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
for i in range(start_sheet_row, end_sheet_row + 1):
|
||
row_index_in_list = i - 1
|
||
if row_index_in_list >= total_sheet_rows: break
|
||
|
||
row = all_data[row_index_in_list]
|
||
if not any(cell and isinstance(cell, str) and cell.strip() for cell in row):
|
||
skipped_count += 1
|
||
continue
|
||
|
||
ar_value = self._get_cell_value_safe(row, "Website Rohtext").strip()
|
||
ay_timestamp_value = self._get_cell_value_safe(row, "SerpAPI Wiki Search Timestamp").strip() # Verwende den spezifischen Timestamp für diese Funktion
|
||
|
||
processing_needed_for_row = False
|
||
is_marker_case = ar_value == URL_CHECK_MARKER
|
||
is_ka_error_case = any(pattern in ar_value for pattern in ka_error_patterns if pattern != URL_CHECK_MARKER)
|
||
|
||
if is_marker_case: # URL_CHECK_MARKER löst immer eine Suche aus
|
||
processing_needed_for_row = True
|
||
elif is_ka_error_case and not ay_timestamp_value: # Alte k.A.-Fehler nur, wenn AY-Timestamp noch nicht gesetzt wurde
|
||
processing_needed_for_row = True
|
||
|
||
if not processing_needed_for_row:
|
||
skipped_count += 1
|
||
continue
|
||
|
||
processed_count += 1
|
||
if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit:
|
||
self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_url_check erreicht.")
|
||
break
|
||
|
||
company_name = self._get_cell_value_safe(row, "CRM Name").strip()
|
||
old_crm_website_url = self._get_cell_value_safe(row, "CRM Website").strip()
|
||
normalized_old_crm_url = simple_normalize_url(old_crm_website_url)
|
||
|
||
if not company_name:
|
||
self.logger.warning(f"Zeile {i}: Uebersprungen (kein Firmenname fuer Suche vorhanden).")
|
||
skipped_count += 1
|
||
updates_for_row_skip = [{'range': f'{ay_letter}{i}', 'values': [[now_timestamp_str]]}] # Timestamp trotzdem setzen
|
||
all_sheet_updates.extend(updates_for_row_skip)
|
||
continue
|
||
|
||
self.logger.info(f"Zeile {i}: AR='{ar_value[:50]}...'. Suche neue URL für '{company_name[:50]}...' (Aktuell D: '{old_crm_website_url[:50]}...')...")
|
||
|
||
updates_for_row = []
|
||
new_url_found_str = "k.A."
|
||
|
||
try:
|
||
new_url_found_str = serp_website_lookup(company_name)
|
||
normalized_new_url = simple_normalize_url(new_url_found_str)
|
||
|
||
if new_url_found_str != "k.A." and normalized_new_url != "k.A.":
|
||
if normalized_new_url != normalized_old_crm_url:
|
||
self.logger.info(f" -> Neue, andere URL gefunden: {new_url_found_str}. Alte war: '{old_crm_website_url}'. Bereite Update vor.")
|
||
found_new_url_count += 1
|
||
updates_for_row.append({'range': f'{d_letter}{i}', 'values': [[new_url_found_str]]})
|
||
updates_for_row.append({'range': f'{ar_letter}{i}', 'values': [['']]})
|
||
updates_for_row.append({'range': f'{a_letter}{i}', 'values': [['x']]})
|
||
updates_for_row.append({'range': f'{at_letter}{i}', 'values': [['']]})
|
||
updates_for_row.append({'range': f'{ao_letter}{i}', 'values': [['']]})
|
||
updates_for_row.append({'range': f'{an_letter}{i}', 'values': [['']]})
|
||
updates_for_row.append({'range': f'{ax_letter}{i}', 'values': [['']]})
|
||
updates_for_row.append({'range': f'{ay_letter}{i}', 'values': [['']]}) # Wird unten explizit neu gesetzt
|
||
updates_for_row.append({'range': f'{ap_letter}{i}', 'values': [['']]})
|
||
else:
|
||
self.logger.info(f" -> SerpAPI fand URL '{new_url_found_str}', aber diese ist identisch mit der bereits vorhandenen URL in Spalte D. Keine Änderung in D.")
|
||
updates_for_row.append({'range': f'{ar_letter}{i}', 'values': [["k.A. (URL via SerpAPI identisch mit alter URL)"]]})
|
||
else:
|
||
self.logger.warning(f" -> Keine neue gueltige URL via SerpAPI für '{company_name[:50]}...' gefunden. Setze AR auf 'k.A. (Keine URL bei Neusuche)'.")
|
||
updates_for_row.append({'range': f'{ar_letter}{i}', 'values': [["k.A. (Keine URL bei Neusuche)"]]})
|
||
|
||
except Exception as e_serp_lookup:
|
||
self.logger.error(f"FEHLER bei SERP Website Lookup für Zeile {i} ('{company_name[:50]}...'): {e_serp_lookup}")
|
||
updates_for_row.append({'range': f'{ar_letter}{i}', 'values': [[f"k.A. (Fehler URL Suche)"]]})
|
||
pass
|
||
|
||
updates_for_row.append({'range': f'{ay_letter}{i}', 'values': [[now_timestamp_str]]}) # AY Timestamp immer setzen
|
||
all_sheet_updates.extend(updates_for_row)
|
||
|
||
if len(all_sheet_updates) >= update_batch_row_limit * 3 : # Angepasst, da Anzahl Updates variiert
|
||
self.logger.debug(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Operationen)...")
|
||
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
|
||
if success: self.logger.info(f" Sheet-Update für {len(all_sheet_updates)} Operationen erfolgreich.")
|
||
all_sheet_updates = []
|
||
|
||
serp_delay = getattr(Config, 'SERPAPI_DELAY', 1.5)
|
||
time.sleep(serp_delay)
|
||
|
||
if all_sheet_updates:
|
||
self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Operationen)...")
|
||
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
|
||
if success: self.logger.info(f"FINALES Sheet-Update erfolgreich.")
|
||
|
||
self.logger.info(f"Modus 'check_urls' abgeschlossen. {processed_count} Zeilen mit Marker/Fehler verarbeitet, {found_new_url_count} neue URLs gefunden, {skipped_count} Zeilen uebersprungen.")
|
||
|
||
def process_repair_sitz_data(self, start_sheet_row=None, end_sheet_row=None, limit=None):
|
||
"""
|
||
Liest bestehende Sitz-Stadt/Land-Angaben, wendet die verbesserte Parsing-Logik
|
||
an und aktualisiert das Sheet, falls sich Änderungen ergeben.
|
||
"""
|
||
self.logger.info(f"Starte Modus 'Sitz-Daten Reparatur'. Bereich: {start_sheet_row if start_sheet_row is not None else 'Komplett ab Datenstart'}, End: {end_sheet_row if end_sheet_row else 'Sheet-Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...")
|
||
|
||
if not self.sheet_handler.load_data():
|
||
self.logger.error("Konnte Sheet-Daten nicht laden für Sitz-Reparatur. Abbruch.")
|
||
return
|
||
|
||
all_data = self.sheet_handler.get_all_data_with_headers()
|
||
header_offset = self.sheet_handler._header_rows
|
||
|
||
stadt_col_idx = COLUMN_MAP.get("Wiki Sitz Stadt")
|
||
land_col_idx = COLUMN_MAP.get("Wiki Sitz Land")
|
||
# Optional: Eine Spalte für den originalen Roh-Sitz-String, falls vorhanden
|
||
# roh_sitz_col_idx = COLUMN_MAP.get("IHRE_ROH_SITZ_SPALTE")
|
||
|
||
if stadt_col_idx is None or land_col_idx is None:
|
||
self.logger.error("Spaltenindizes für 'Wiki Sitz Stadt' oder 'Wiki Sitz Land' nicht in COLUMN_MAP. Abbruch.")
|
||
return
|
||
|
||
updates_fuer_sheet = []
|
||
processed_rows_count = 0
|
||
updated_rows_count = 0
|
||
|
||
effective_start_row = start_sheet_row if start_sheet_row is not None else header_offset + 1
|
||
effective_end_row = end_sheet_row if end_sheet_row is not None else len(all_data)
|
||
|
||
self.logger.info(f"Prüfe Zeilen {effective_start_row} bis {effective_end_row} für Sitz-Reparatur.")
|
||
|
||
for row_num_sheet in range(effective_start_row, effective_end_row + 1):
|
||
if limit is not None and processed_rows_count >= limit:
|
||
self.logger.info(f"Limit von {limit} erreichten Zeilen für Sitz-Reparatur erreicht.")
|
||
break
|
||
|
||
row_list_idx = row_num_sheet - 1
|
||
if row_list_idx >= len(all_data): break # Ende der Daten erreicht
|
||
|
||
row_data = all_data[row_list_idx]
|
||
|
||
aktuelle_stadt = self._get_cell_value_safe(row_data, "Wiki Sitz Stadt")
|
||
aktuelle_land = self._get_cell_value_safe(row_data, "Wiki Sitz Land")
|
||
|
||
# Erzeuge den Input-String für die Parsing-Funktion
|
||
# Besser: Wenn Sie den *ursprünglichen* String aus der Wikipedia Infobox
|
||
# in einer separaten Spalte gespeichert hätten, würden Sie diesen hier verwenden.
|
||
# Als Fallback kombinieren wir aktuelle Stadt und Land.
|
||
input_sitz_string = aktuelle_stadt
|
||
if aktuelle_land and aktuelle_land.lower() not in ["", "k.a."]:
|
||
if input_sitz_string and input_sitz_string.lower() not in ["", "k.a."]:
|
||
input_sitz_string += f", {aktuelle_land}" # Kombiniere mit Komma
|
||
else:
|
||
input_sitz_string = aktuelle_land # Wenn Stadt leer/kA, nimm nur Land
|
||
|
||
if not input_sitz_string or not input_sitz_string.strip() or input_sitz_string.lower() == 'k.a.':
|
||
# self.logger.debug(f"Zeile {row_num_sheet}: Keine validen aktuellen Sitzdaten ('{aktuelle_stadt}', '{aktuelle_land}') zum Reparieren.")
|
||
continue
|
||
|
||
processed_rows_count += 1
|
||
|
||
try:
|
||
# Verwende die neue Parsing-Methode des WikipediaScrapers
|
||
# Stellen Sie sicher, dass self.wiki_scraper eine Instanz von WikipediaScraper ist
|
||
parsed_sitz_info = self.wiki_scraper._parse_sitz_string_detailed(input_sitz_string)
|
||
neue_stadt = parsed_sitz_info.get('sitz_stadt', 'k.A.')
|
||
neues_land = parsed_sitz_info.get('sitz_land', 'k.A.')
|
||
|
||
# Nur updaten, wenn sich etwas geändert hat
|
||
if (neue_stadt != aktuelle_stadt and not (neue_stadt == "k.A." and aktuelle_stadt == "")) or \
|
||
(neues_land != aktuelle_land and not (neues_land == "k.A." and aktuelle_land == "")):
|
||
self.logger.info(f"Zeile {row_num_sheet}: SITZ-UPDATE. Input: '{input_sitz_string[:60]}...' Alt: '{aktuelle_stadt} / {aktuelle_land}' -> Neu: '{neue_stadt} / {neues_land}'")
|
||
updates_fuer_sheet.append({
|
||
'range': f'{self.sheet_handler._get_col_letter(stadt_col_idx + 1)}{row_num_sheet}',
|
||
'values': [[neue_stadt]]
|
||
})
|
||
updates_fuer_sheet.append({
|
||
'range': f'{self.sheet_handler._get_col_letter(land_col_idx + 1)}{row_num_sheet}',
|
||
'values': [[neues_land]]
|
||
})
|
||
updated_rows_count += 1
|
||
# else:
|
||
# self.logger.debug(f"Zeile {row_num_sheet}: Keine Änderung bei Sitzdaten für Input '{input_sitz_string[:60]}...'. Alt: '{aktuelle_stadt} / {aktuelle_land}', Neu: '{neue_stadt} / {neues_land}'")
|
||
|
||
|
||
except Exception as e_parse:
|
||
self.logger.error(f"Fehler beim Parsen des Sitzes für Zeile {row_num_sheet} mit Input '{input_sitz_string}': {e_parse}")
|
||
|
||
# Batch-Update Logik
|
||
if len(updates_fuer_sheet) >= getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) * 2: # Mal 2, da zwei Spalten pro Zeile
|
||
self.logger.info(f"Sende Batch-Update für {len(updates_fuer_sheet)//2} Sitzreparaturen...")
|
||
self.sheet_handler.batch_update_cells(updates_fuer_sheet)
|
||
updates_fuer_sheet = []
|
||
# time.sleep(1) # Optionale Pause
|
||
|
||
# Letzten Batch senden
|
||
if updates_fuer_sheet:
|
||
self.logger.info(f"Sende finalen Batch-Update für {len(updates_fuer_sheet)//2} Sitzreparaturen...")
|
||
self.sheet_handler.batch_update_cells(updates_fuer_sheet)
|
||
|
||
self.logger.info(f"Sitz-Daten Reparatur abgeschlossen. {processed_rows_count} Zeilen geprüft, {updated_rows_count} Zeilen aktualisiert.")
|
||
|
||
|
||
|
||
# Innerhalb der DataProcessor Klasse
|
||
# Innerhalb der DataProcessor Klasse (ersetzen Sie Ihre bestehende Version vollständig hiermit)
|
||
def _get_numeric_value_for_plausi(self, value_str, is_umsatz=False):
|
||
logger = logging.getLogger(__name__ + "._get_numeric_value_for_plausi")
|
||
|
||
# Schritt 1: Nutze die globale, verbesserte extract_numeric_value,
|
||
# um einen sauberen Zahlenstring oder "k.A."/"0" zu bekommen.
|
||
# extract_numeric_value liefert Umsatz bereits in Mio, MA absolut.
|
||
extracted_val_str = extract_numeric_value(value_str, is_umsatz)
|
||
|
||
if extracted_val_str.lower() in ['k.a.']:
|
||
logger.debug(f"Input '{value_str}' -> extrahiert zu '{extracted_val_str}' -> NaN (als 'unbekannt' interpretiert).")
|
||
return np.nan
|
||
|
||
# Wenn extract_numeric_value "0" zurückgibt, bedeutet das nach Ihrer Regel "unbekannt".
|
||
if extracted_val_str == "0":
|
||
logger.debug(f"Input '{value_str}' -> extrahiert zu '{extracted_val_str}' -> NaN (als 'unbekannt' interpretiert).")
|
||
return np.nan
|
||
|
||
try:
|
||
# extracted_val_str sollte jetzt eine saubere Zahl als String sein (z.B. "173", "4380")
|
||
# oder es wurde schon "k.A." zurückgegeben.
|
||
num_val_sheet_unit = float(extracted_val_str) # z.B. 173.0 (Mio) für Umsatz "173"
|
||
|
||
final_num_absolute = num_val_sheet_unit
|
||
if is_umsatz:
|
||
# num_val_sheet_unit ist bereits in Mio, konvertiere zu absolutem Euro für Plausi-Schwellenwerte
|
||
final_num_absolute = num_val_sheet_unit * 1000000.0
|
||
# Für Mitarbeiter ist num_val_sheet_unit bereits die absolute Zahl
|
||
|
||
return final_num_absolute
|
||
|
||
except ValueError:
|
||
logger.debug(f"ValueError bei Konvertierung von '{extracted_val_str}' (aus Input '{value_str}') zu float in _get_numeric_value_for_plausi.")
|
||
return np.nan
|
||
except Exception as e_general:
|
||
logger.error(f"Unerwarteter Fehler in _get_numeric_value_for_plausi für '{value_str[:50]}...': {e_general}")
|
||
return np.nan
|
||
|
||
|
||
|
||
def _check_financial_plausibility(self, row_data_dict):
|
||
results = {
|
||
"plaus_umsatz_flag": "NICHT_PRUEFBAR", "plaus_ma_flag": "NICHT_PRUEFBAR",
|
||
"plaus_ratio_flag": "NICHT_PRUEFBAR", "abweichung_umsatz_flag": "N/A",
|
||
"abweichung_ma_flag": "N/A",
|
||
"plausi_begruendung_final": "Plausibilität OK"
|
||
}
|
||
temp_begruendungen = []
|
||
|
||
parent_account_name_d_val = row_data_dict.get("Parent Account Name", "").strip()
|
||
is_konzern_tochter_laut_d = bool(parent_account_name_d_val and parent_account_name_d_val.lower() != 'k.a.')
|
||
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# +++ NEU/ERWEITERT: Info aus Spalte O und P für Konzernlogik heranziehen +++
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# Diese Keys müssen im row_data_dict vorhanden sein, wenn diese Funktion aufgerufen wird!
|
||
parent_o_val = row_data_dict.get("System Vorschlag Parent Account", "").strip().lower()
|
||
parent_p_val = row_data_dict.get("Parent Vorschlag Status", "").strip().lower()
|
||
is_konzern_tochter_laut_o_und_p = bool(parent_o_val and parent_o_val != 'k.a.' and parent_p_val == 'x')
|
||
|
||
# (Das `wiki_stammt_von_parent_explizit` Flag ist optional, wenn Sie den anderen Ansatz verfolgen)
|
||
# wiki_stammt_von_parent_explizit = row_data_dict.get("Wiki Daten von Parent", False)
|
||
|
||
is_part_of_a_group_for_plausi = is_konzern_tochter_laut_d or is_konzern_tochter_laut_o_und_p # or wiki_stammt_von_parent_explizit
|
||
|
||
log_msg_group_parts = []
|
||
if is_konzern_tochter_laut_d:
|
||
log_msg_group_parts.append(f"D='{parent_account_name_d_val}'")
|
||
if is_konzern_tochter_laut_o_und_p:
|
||
log_msg_group_parts.append(f"O/P='{parent_o_val}/{parent_p_val}'")
|
||
# if wiki_stammt_von_parent_explizit:
|
||
# log_msg_group_parts.append("WikiParentFlag=True")
|
||
|
||
if is_part_of_a_group_for_plausi:
|
||
self.logger.debug(f" PlausiCheck: Unternehmen ist Teil einer Gruppe ({'; '.join(log_msg_group_parts)}). Abweichungs-Checks CRM/Wiki werden als INFO_KONZERN_LOGIK behandelt.")
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# +++ ENDE NEU/ERWEITERT ++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
|
||
|
||
# --- 1. Plausibilität Finaler Umsatz (BG) ---
|
||
final_umsatz_str = row_data_dict.get("Finaler Umsatz (Wiki>CRM)", "k.A.")
|
||
umsatz_num_absolut = self._get_numeric_value_for_plausi(final_umsatz_str, is_umsatz=True)
|
||
|
||
exclusion_list_common = ['k.a.', '', 'n/a', '-', '0', '0.0', '0,00', '0.000', '0.00']
|
||
if pd.isna(umsatz_num_absolut):
|
||
if final_umsatz_str.lower().strip() not in exclusion_list_common and not final_umsatz_str.startswith("FEHLER"):
|
||
results["plaus_umsatz_flag"] = "FEHLER_FORMAT"
|
||
temp_begruendungen.append(f"Finaler Umsatz ('{final_umsatz_str}') konnte nicht als Zahl interpretiert werden.")
|
||
else:
|
||
results["plaus_umsatz_flag"] = "OK"
|
||
if umsatz_num_absolut == 0 and final_umsatz_str != "0":
|
||
results["plaus_umsatz_flag"] = "WARNUNG_NULL_WERT"
|
||
temp_begruendungen.append(f"Finaler Umsatz ist numerisch 0 (aus '{final_umsatz_str}').")
|
||
elif umsatz_num_absolut < getattr(Config, 'PLAUSI_UMSATZ_MIN_WARNUNG', 50000):
|
||
results["plaus_umsatz_flag"] = "WARNUNG_NIEDRIG"
|
||
temp_begruendungen.append(f"Finaler Umsatz ({umsatz_num_absolut:,.0f} €) < Min-Schwelle ({getattr(Config, 'PLAUSI_UMSATZ_MIN_WARNUNG', 50000):,.0f} €).")
|
||
elif umsatz_num_absolut > getattr(Config, 'PLAUSI_UMSATZ_MAX_WARNUNG', 200000000000):
|
||
results["plaus_umsatz_flag"] = "WARNUNG_HOCH"
|
||
temp_begruendungen.append(f"Finaler Umsatz ({umsatz_num_absolut:,.0f} €) > Max-Schwelle ({getattr(Config, 'PLAUSI_UMSATZ_MAX_WARNUNG', 200000000000):,.0f} €).")
|
||
|
||
# --- 2. Plausibilität Finale Mitarbeiter (BH) ---
|
||
final_ma_str = row_data_dict.get("Finaler Mitarbeiter (Wiki>CRM)", "k.A.")
|
||
ma_num_absolut = self._get_numeric_value_for_plausi(final_ma_str, is_umsatz=False)
|
||
|
||
if pd.isna(ma_num_absolut):
|
||
if final_ma_str.lower().strip() not in exclusion_list_common and not final_ma_str.startswith("FEHLER"):
|
||
results["plaus_ma_flag"] = "FEHLER_FORMAT"
|
||
temp_begruendungen.append(f"Finale MA ('{final_ma_str}') konnte nicht als Zahl interpretiert werden.")
|
||
else:
|
||
results["plaus_ma_flag"] = "OK"
|
||
if ma_num_absolut == 0 and final_ma_str != "0":
|
||
results["plaus_ma_flag"] = "WARNUNG_NULL_WERT"
|
||
temp_begruendungen.append(f"Finale MA ist numerisch 0 (aus '{final_ma_str}').")
|
||
elif ma_num_absolut < getattr(Config, 'PLAUSI_MA_MIN_WARNUNG_ABS', 1):
|
||
results["plaus_ma_flag"] = "WARNUNG_NIEDRIG"
|
||
temp_begruendungen.append(f"Finale MA ({ma_num_absolut:.0f}) < Min-Schwelle ({getattr(Config, 'PLAUSI_MA_MIN_WARNUNG_ABS', 1):.0f}).")
|
||
elif not pd.isna(umsatz_num_absolut) and umsatz_num_absolut >= getattr(Config, 'PLAUSI_UMSATZ_MIN_SCHWELLE_FUER_MA_CHECK', 1000000) and \
|
||
ma_num_absolut < getattr(Config, 'PLAUSI_MA_MIN_WARNUNG_BEI_UMSATZ', 3):
|
||
results["plaus_ma_flag"] = "WARNUNG_ZU_WENIG_MA_BEI_UMSATZ"
|
||
temp_begruendungen.append(f"Finale MA ({ma_num_absolut:.0f}) auffällig niedrig für Umsatz ({umsatz_num_absolut:,.0f} €).")
|
||
elif ma_num_absolut > getattr(Config, 'PLAUSI_MA_MAX_WARNUNG', 1000000):
|
||
results["plaus_ma_flag"] = "WARNUNG_HOCH"
|
||
temp_begruendungen.append(f"Finale MA ({ma_num_absolut:.0f}) > Max-Schwelle ({getattr(Config, 'PLAUSI_MA_MAX_WARNUNG', 1000000):,.0f}).")
|
||
|
||
# --- 3. Plausibilität Umsatz/MA Ratio (BI) ---
|
||
if not pd.isna(umsatz_num_absolut) and not pd.isna(ma_num_absolut) and umsatz_num_absolut > 0 :
|
||
if ma_num_absolut > 0:
|
||
ratio = umsatz_num_absolut / ma_num_absolut
|
||
results["plaus_ratio_flag"] = "OK"
|
||
if ratio < getattr(Config, 'PLAUSI_RATIO_UMSATZ_PRO_MA_MIN', 25000):
|
||
results["plaus_ratio_flag"] = "WARNUNG_RATIO_NIEDRIG"
|
||
temp_begruendungen.append(f"Umsatz/MA Ratio ({ratio:,.0f} €/MA) < Min-Schwelle ({getattr(Config, 'PLAUSI_RATIO_UMSATZ_PRO_MA_MIN', 25000):,.0f} €/MA).")
|
||
elif ratio > getattr(Config, 'PLAUSI_RATIO_UMSATZ_PRO_MA_MAX', 1500000):
|
||
results["plaus_ratio_flag"] = "WARNUNG_RATIO_HOCH"
|
||
temp_begruendungen.append(f"Umsatz/MA Ratio ({ratio:,.0f} €/MA) > Max-Schwelle ({getattr(Config, 'PLAUSI_RATIO_UMSATZ_PRO_MA_MAX', 1500000):,.0f} €/MA).")
|
||
elif ma_num_absolut == 0:
|
||
results["plaus_ratio_flag"] = "FEHLER_MA_NULL_BEI_UMSATZ"
|
||
temp_begruendungen.append("Umsatz vorhanden, aber MA ist 0. Ratio nicht berechenbar.")
|
||
elif pd.isna(umsatz_num_absolut) or umsatz_num_absolut <= 0 :
|
||
results["plaus_ratio_flag"] = "NICHT_PRUEFBAR_UMSATZ_FEHLT"
|
||
|
||
# --- 4. Abgleich CRM vs. Wiki (BJ, BK) ---
|
||
crm_umsatz_str = row_data_dict.get("CRM Umsatz", "k.A.")
|
||
wiki_umsatz_str = row_data_dict.get("Wiki Umsatz", "k.A.")
|
||
crm_ma_str = row_data_dict.get("CRM Anzahl Mitarbeiter", "k.A.")
|
||
wiki_ma_str = row_data_dict.get("Wiki Mitarbeiter", "k.A.")
|
||
|
||
crm_u_abs = self._get_numeric_value_for_plausi(crm_umsatz_str, is_umsatz=True)
|
||
wiki_u_abs = self._get_numeric_value_for_plausi(wiki_umsatz_str, is_umsatz=True)
|
||
crm_m_abs_comp = self._get_numeric_value_for_plausi(crm_ma_str, is_umsatz=False)
|
||
wiki_m_abs_comp = self._get_numeric_value_for_plausi(wiki_ma_str, is_umsatz=False)
|
||
|
||
abweichung_prozent_config = getattr(Config, 'PLAUSI_ABWEICHUNG_CRM_WIKI_PROZENT', 30) / 100.0
|
||
|
||
# Umsatz Abweichung (BJ)
|
||
if pd.notna(crm_u_abs) and pd.notna(wiki_u_abs) and crm_u_abs > 0 and wiki_u_abs > 0 :
|
||
if is_part_of_a_group_for_plausi: # ERWEITERTE BEDINGUNG HIER VERWENDEN
|
||
results["abweichung_umsatz_flag"] = "INFO_KONZERN_LOGIK"
|
||
else:
|
||
diff_umsatz = abs(crm_u_abs - wiki_u_abs)
|
||
bezugswert_umsatz = max(crm_u_abs, wiki_u_abs) if max(crm_u_abs, wiki_u_abs) > 0 else 1
|
||
if (diff_umsatz / bezugswert_umsatz) > abweichung_prozent_config:
|
||
results["abweichung_umsatz_flag"] = "WARNUNG_SIGNIFIKANT"
|
||
temp_begruendungen.append(f"Umsatz CRM ({crm_u_abs:,.0f} €) vs. Wiki ({wiki_u_abs:,.0f} €) weicht >{abweichung_prozent_config*100:.0f}% ab.")
|
||
else:
|
||
results["abweichung_umsatz_flag"] = "OK"
|
||
elif pd.notna(crm_u_abs) and crm_u_abs > 0 and (pd.isna(wiki_u_abs) or wiki_u_abs <=0) : results["abweichung_umsatz_flag"] = "WIKI_FEHLT_ODER_NULL"
|
||
elif (pd.isna(crm_u_abs) or crm_u_abs <=0) and pd.notna(wiki_u_abs) and wiki_u_abs > 0 : results["abweichung_umsatz_flag"] = "CRM_FEHLT_ODER_NULL"
|
||
else: results["abweichung_umsatz_flag"] = "BEIDE_FEHLEN_ODER_NULL"
|
||
|
||
# Mitarbeiter Abweichung (BK)
|
||
if pd.notna(crm_m_abs_comp) and pd.notna(wiki_m_abs_comp) and crm_m_abs_comp > 0 and wiki_m_abs_comp > 0:
|
||
if is_part_of_a_group_for_plausi: # ERWEITERTE BEDINGUNG HIER VERWENDEN
|
||
results["abweichung_ma_flag"] = "INFO_KONZERN_LOGIK"
|
||
else:
|
||
diff_ma = abs(crm_m_abs_comp - wiki_m_abs_comp)
|
||
bezugswert_ma = max(crm_m_abs_comp, wiki_m_abs_comp) if max(crm_m_abs_comp, wiki_m_abs_comp) > 0 else 1
|
||
if (diff_ma / bezugswert_ma) > abweichung_prozent_config:
|
||
results["abweichung_ma_flag"] = "WARNUNG_SIGNIFIKANT"
|
||
temp_begruendungen.append(f"MA CRM ({crm_m_abs_comp:.0f}) vs. Wiki ({wiki_m_abs_comp:.0f}) weicht >{abweichung_prozent_config*100:.0f}% ab.")
|
||
else:
|
||
results["abweichung_ma_flag"] = "OK"
|
||
elif pd.notna(crm_m_abs_comp) and crm_m_abs_comp > 0 and (pd.isna(wiki_m_abs_comp) or wiki_m_abs_comp <=0): results["abweichung_ma_flag"] = "WIKI_FEHLT_ODER_NULL"
|
||
elif (pd.isna(crm_m_abs_comp) or crm_m_abs_comp <=0) and pd.notna(wiki_m_abs_comp) and wiki_m_abs_comp > 0: results["abweichung_ma_flag"] = "CRM_FEHLT_ODER_NULL"
|
||
else: results["abweichung_ma_flag"] = "BEIDE_FEHLEN_ODER_NULL"
|
||
|
||
if temp_begruendungen:
|
||
results["plausi_begruendung_final"] = "; ".join(temp_begruendungen)
|
||
|
||
return results
|
||
|
||
|
||
def run_plausibility_checks_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None):
|
||
self.logger.info(f"Starte Modus 'plausi_check_data' (Konsolidierung & Plausi-Checks). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}")
|
||
|
||
if not self.sheet_handler.load_data():
|
||
self.logger.error("Konnte Sheet-Daten nicht laden für Plausi-Checks. Abbruch.")
|
||
return
|
||
|
||
all_data = self.sheet_handler.get_all_data_with_headers()
|
||
header_offset = self.sheet_handler._header_rows
|
||
total_sheet_rows = len(all_data)
|
||
|
||
effective_start_row = start_sheet_row if start_sheet_row is not None else header_offset + 1
|
||
effective_end_row = end_sheet_row if end_sheet_row is not None else total_sheet_rows
|
||
|
||
if effective_start_row > effective_end_row or effective_start_row > total_sheet_rows:
|
||
self.logger.info("Start liegt nach Ende oder außerhalb des Sheets. Keine Zeilen zu verarbeiten.")
|
||
return
|
||
|
||
self.logger.info(f"Verarbeite Zeilen {effective_start_row} bis {effective_end_row} für Konsolidierung und Plausi-Checks.")
|
||
|
||
required_keys_for_plausi_mode = [ # Schlüssel wie gehabt
|
||
"CRM Umsatz", "Wiki Umsatz", "CRM Anzahl Mitarbeiter", "Wiki Mitarbeiter", "Parent Account Name",
|
||
"Finaler Umsatz (Wiki>CRM)", "Finaler Mitarbeiter (Wiki>CRM)",
|
||
"Plausibilität Umsatz", "Plausibilität Mitarbeiter", "Plausibilität Umsatz/MA Ratio",
|
||
"Abweichung Umsatz CRM/Wiki", "Abweichung MA CRM/Wiki", "Plausibilität Begründung",
|
||
"Plausibilität Prüfdatum", "CRM Name" # CRM Name für Logging hinzugefügt
|
||
]
|
||
if not all(key in COLUMN_MAP for key in required_keys_for_plausi_mode):
|
||
missing_k = [k for k in required_keys_for_plausi_mode if k not in COLUMN_MAP]
|
||
self.logger.error(f"Nicht alle benötigten Spalten ({missing_k}) für Modus 'plausi_check_data' in COLUMN_MAP. Abbruch.")
|
||
return
|
||
|
||
all_sheet_updates = []
|
||
processed_rows_count = 0
|
||
now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
update_batch_limit_config = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)
|
||
|
||
for row_num_sheet in range(effective_start_row, effective_end_row + 1):
|
||
if limit is not None and processed_rows_count >= limit:
|
||
self.logger.info(f"Verarbeitungslimit von {limit} Zeilen erreicht.")
|
||
break
|
||
|
||
row_list_idx = row_num_sheet - 1
|
||
if row_list_idx >= total_sheet_rows: break
|
||
row_data = all_data[row_list_idx]
|
||
|
||
crm_name_check = self._get_cell_value_safe(row_data, "CRM Name").strip()
|
||
if not crm_name_check:
|
||
continue
|
||
|
||
self.logger.debug(f"Zeile {row_num_sheet} ({crm_name_check[:30]}...): Starte Konsolidierung und Plausi-Check.")
|
||
current_row_updates = []
|
||
|
||
# 1. Konsolidierung (BD, BE)
|
||
final_umsatz_str_konsolidiert = "k.A."
|
||
final_ma_str_konsolidiert = "k.A."
|
||
parent_account_name_d_val = self._get_cell_value_safe(row_data, "Parent Account Name").strip()
|
||
parent_o_val_plausi = self._get_cell_value_safe(row_data, "System Vorschlag Parent Account").strip() # Für Plausi-Check holen
|
||
parent_p_val_plausi = self._get_cell_value_safe(row_data, "Parent Vorschlag Status").strip() # Für Plausi-Check holen
|
||
|
||
|
||
try:
|
||
crm_umsatz_val_str = self._get_cell_value_safe(row_data, "CRM Umsatz")
|
||
# Wiki-Werte direkt aus der Zeile (row_data) lesen
|
||
wiki_umsatz_val_str_sheet = self._get_cell_value_safe(row_data, "Wiki Umsatz")
|
||
crm_ma_val_str = self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter")
|
||
wiki_ma_val_str_sheet = self._get_cell_value_safe(row_data, "Wiki Mitarbeiter")
|
||
|
||
num_crm_umsatz = get_numeric_filter_value(crm_umsatz_val_str, is_umsatz=True)
|
||
num_wiki_umsatz = get_numeric_filter_value(wiki_umsatz_val_str_sheet, is_umsatz=True) # Verwende _sheet Wert
|
||
num_crm_ma = get_numeric_filter_value(crm_ma_val_str, is_umsatz=False)
|
||
num_wiki_ma = get_numeric_filter_value(wiki_ma_val_str_sheet, is_umsatz=False) # Verwende _sheet Wert
|
||
|
||
if parent_account_name_d_val and parent_account_name_d_val.lower() != 'k.a.':
|
||
self.logger.debug(f" -> Parent D ('{parent_account_name_d_val}') ist gesetzt. Konsolidiere primär mit CRM-Daten der Tochter.")
|
||
final_num_umsatz = num_crm_umsatz if num_crm_umsatz > 0 else num_wiki_umsatz
|
||
final_num_ma = num_crm_ma if num_crm_ma > 0 else num_wiki_ma
|
||
else:
|
||
final_num_umsatz = num_wiki_umsatz if num_wiki_umsatz > 0 else num_crm_umsatz
|
||
final_num_ma = num_wiki_ma if num_wiki_ma > 0 else num_crm_ma
|
||
|
||
final_umsatz_str_konsolidiert = str(int(round(final_num_umsatz))) if final_num_umsatz > 0 else 'k.A.'
|
||
final_ma_str_konsolidiert = str(int(round(final_num_ma))) if final_num_ma > 0 else 'k.A.'
|
||
except Exception as e_conso_batch:
|
||
self.logger.error(f"Fehler bei Konsolidierung in Plausi-Batch für Zeile {row_num_sheet}: {e_conso_batch}")
|
||
final_umsatz_str_konsolidiert = "FEHLER_KONSO_PLAUSI"
|
||
final_ma_str_konsolidiert = "FEHLER_KONSO_PLAUSI"
|
||
|
||
current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Umsatz (Wiki>CRM)"] + 1)}{row_num_sheet}', 'values': [[final_umsatz_str_konsolidiert]]})
|
||
current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Finaler Mitarbeiter (Wiki>CRM)"] + 1)}{row_num_sheet}', 'values': [[final_ma_str_konsolidiert]]})
|
||
|
||
# 2. Plausibilitäts-Checks (BG-BM)
|
||
if not final_umsatz_str_konsolidiert.startswith("FEHLER") and not final_ma_str_konsolidiert.startswith("FEHLER"):
|
||
try:
|
||
plausi_input_data = {
|
||
"Finaler Umsatz (Wiki>CRM)": final_umsatz_str_konsolidiert,
|
||
"Finaler Mitarbeiter (Wiki>CRM)": final_ma_str_konsolidiert,
|
||
"CRM Umsatz": self._get_cell_value_safe(row_data, "CRM Umsatz"),
|
||
"Wiki Umsatz": self._get_cell_value_safe(row_data, "Wiki Umsatz"),
|
||
"CRM Anzahl Mitarbeiter": self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter"),
|
||
"Wiki Mitarbeiter": self._get_cell_value_safe(row_data, "Wiki Mitarbeiter"),
|
||
"Parent Account Name": parent_account_name_d_val,
|
||
"System Vorschlag Parent Account": parent_o_val_plausi, # Variable von oben
|
||
"Parent Vorschlag Status": parent_p_val_plausi # Variable von oben
|
||
}
|
||
plausi_results = self._check_financial_plausibility(plausi_input_data)
|
||
|
||
|
||
|
||
|
||
current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Umsatz"] + 1)}{row_num_sheet}', 'values': [[plausi_results.get("plaus_umsatz_flag", "ERR_FLAG")]]})
|
||
current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Mitarbeiter"] + 1)}{row_num_sheet}', 'values': [[plausi_results.get("plaus_ma_flag", "ERR_FLAG")]]})
|
||
current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Umsatz/MA Ratio"] + 1)}{row_num_sheet}', 'values': [[plausi_results.get("plaus_ratio_flag", "ERR_FLAG")]]})
|
||
current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Abweichung Umsatz CRM/Wiki"] + 1)}{row_num_sheet}', 'values': [[plausi_results.get("abweichung_umsatz_flag", "ERR_FLAG")]]})
|
||
current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Abweichung MA CRM/Wiki"] + 1)}{row_num_sheet}', 'values': [[plausi_results.get("abweichung_ma_flag", "ERR_FLAG")]]})
|
||
current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Begründung"] + 1)}{row_num_sheet}', 'values': [[plausi_results.get("plausi_begruendung_final", "Fehler Begr.")]]})
|
||
|
||
except Exception as e_plausi_run_batch:
|
||
self.logger.error(f"Fehler im Plausi-Check Aufruf (Batch-Modus) für Zeile {row_num_sheet}: {e_plausi_run_batch}")
|
||
for key_flag in ["Plausibilität Umsatz", "Plausibilität Mitarbeiter", "Plausibilität Umsatz/MA Ratio", "Abweichung Umsatz CRM/Wiki", "Abweichung MA CRM/Wiki"]:
|
||
current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP[key_flag] + 1)}{row_num_sheet}', 'values': [['FEHLER_CALL']]})
|
||
current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Begründung"] + 1)}{row_num_sheet}', 'values': [[f"Systemfehler: {str(e_plausi_run_batch)[:100]}"]]})
|
||
else: # Fehler bei Konsolidierung
|
||
self.logger.warning(f"Zeile {row_num_sheet}: Überspringe Plausi-Checks wegen Fehler bei Konsolidierung.")
|
||
for key_flag in ["Plausibilität Umsatz", "Plausibilität Mitarbeiter", "Plausibilität Umsatz/MA Ratio", "Abweichung Umsatz CRM/Wiki", "Abweichung MA CRM/Wiki"]:
|
||
current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP[key_flag] + 1)}{row_num_sheet}', 'values': [['INPUT_FEHLER_KONSO']]})
|
||
current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Begründung"] + 1)}{row_num_sheet}', 'values': [["Konsolidierung fehlgeschlagen"]]})
|
||
|
||
current_row_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Plausibilität Prüfdatum"] + 1)}{row_num_sheet}', 'values': [[now_timestamp_str]]})
|
||
|
||
all_sheet_updates.extend(current_row_updates)
|
||
processed_rows_count += 1
|
||
|
||
# Batch-Update auslösen, wenn Limit erreicht
|
||
if processed_rows_count % update_batch_limit_config == 0 and processed_rows_count > 0:
|
||
if all_sheet_updates:
|
||
self.logger.info(f"Plausi-Batch: Sende {len(all_sheet_updates)} Operationen für {update_batch_limit_config} Zeilen...")
|
||
self.sheet_handler.batch_update_cells(all_sheet_updates)
|
||
all_sheet_updates = [] # Liste leeren nach dem Senden
|
||
time.sleep(0.5) # Kurze Pause nach Batch-Update
|
||
|
||
# Ende der for-Schleife über die Zeilen
|
||
|
||
# Sende verbleibende Updates
|
||
if all_sheet_updates:
|
||
self.logger.info(f"Plausi-Batch: Sende verbleibende {len(all_sheet_updates)} Operationen...")
|
||
self.sheet_handler.batch_update_cells(all_sheet_updates)
|
||
|
||
self.logger.info(f"Modus 'plausi_check_data' abgeschlossen. {processed_rows_count} Zeilen verarbeitet.")
|
||
|
||
# ==========================================================================
|
||
# === Batch Processing Methods (Parent Account Suggestion) ==============
|
||
# ==========================================================================
|
||
|
||
def _suggest_parent_account_openai_task(self, task_data, openai_semaphore):
|
||
"""
|
||
Fragt ChatGPT nach einem Parent Account für ein einzelnes Unternehmen.
|
||
Läuft in einem separaten Thread für den Parent-Suggestion-Batch.
|
||
|
||
Args:
|
||
task_data (dict): Enthält die Daten für die Zeile
|
||
(row_num, crm_name, crm_website, crm_beschreibung,
|
||
wiki_url, wiki_absatz, wiki_kategorien, website_zusammenfassung).
|
||
openai_semaphore (threading.Semaphore): Semaphore zur Begrenzung gleichzeitiger OpenAI-Calls.
|
||
|
||
Returns:
|
||
dict: {'row_num': int, 'suggested_parent': str, 'justification': str, 'error': str or None}
|
||
"""
|
||
logger = logging.getLogger(__name__ + ".suggest_parent_task")
|
||
row_num = task_data['row_num']
|
||
suggested_parent = "k.A."
|
||
justification = "Keine Begründung erhalten."
|
||
error_msg = None
|
||
|
||
# Bereinige Input-Daten für den Prompt
|
||
crm_name = str(task_data.get('crm_name', 'N/A')).strip()
|
||
crm_website = str(task_data.get('crm_website', 'N/A')).strip()
|
||
crm_beschreibung = str(task_data.get('crm_beschreibung', 'N/A')).strip()[:800] # Gekürzt
|
||
wiki_url = str(task_data.get('wiki_url', 'N/A')).strip()
|
||
wiki_absatz = str(task_data.get('wiki_absatz', 'N/A')).strip()[:800] # Gekürzt
|
||
wiki_kategorien = str(task_data.get('wiki_kategorien', 'N/A')).strip()[:500] # Gekürzt
|
||
website_zusammenfassung = str(task_data.get('website_zusammenfassung', 'N/A')).strip()[:800] # Gekürzt
|
||
|
||
prompt_parts = [
|
||
"Du bist ein Wirtschaftsanalyst und recherchierst Unternehmensstrukturen.",
|
||
"Basierend auf den folgenden Informationen, identifiziere bitte den Namen der direkten Muttergesellschaft oder des übergeordneten Konzerns für das genannte Unternehmen.",
|
||
"Wenn keine klare Muttergesellschaft ersichtlich ist oder das Unternehmen selbständig zu sein scheint, antworte mit 'k.A.' für den Parent Account.",
|
||
"Gib deine Antwort ausschließlich im folgenden Format aus (keine Einleitung, kein Schlusssatz):",
|
||
"Vorgeschlagener Parent Account: <Name des Parent Accounts oder k.A.>",
|
||
"Begründung: <Sehr kurze Begründung für deinen Vorschlag oder warum keiner gemacht werden kann. Erwähne die Informationsquelle, falls möglich (z.B. Wikipedia Infobox, Website).>",
|
||
"\n--- Unternehmensinformationen ---",
|
||
f"Unternehmen: {crm_name}",
|
||
]
|
||
if crm_website and crm_website.lower() != "n/a":
|
||
prompt_parts.append(f"Website: {crm_website}")
|
||
if crm_beschreibung and crm_beschreibung.lower() != "n/a":
|
||
prompt_parts.append(f"CRM Beschreibung: {crm_beschreibung}")
|
||
if wiki_url and "wikipedia.org" in wiki_url.lower():
|
||
prompt_parts.append(f"Wikipedia URL: {wiki_url}")
|
||
if wiki_absatz and wiki_absatz.lower() != "n/a":
|
||
prompt_parts.append(f"Wikipedia Absatz: {wiki_absatz}")
|
||
if wiki_kategorien and wiki_kategorien.lower() != "n/a":
|
||
prompt_parts.append(f"Wikipedia Kategorien: {wiki_kategorien}")
|
||
if website_zusammenfassung and website_zusammenfassung.lower() != "n/a" and not website_zusammenfassung.startswith("k.A. (Fehler"):
|
||
prompt_parts.append(f"Website Zusammenfassung: {website_zusammenfassung}")
|
||
|
||
prompt_parts.append("\nBitte gib NUR die Antwort im oben genannten Format.")
|
||
prompt = "\n".join(prompt_parts)
|
||
|
||
# Token Count (optional, zur Info)
|
||
# try:
|
||
# pt_count = token_count(prompt)
|
||
# logger.debug(f"Zeile {row_num}: Prompt für Parent Suggestion ({pt_count} Tokens): {prompt[:200]}...")
|
||
# except Exception: pass
|
||
|
||
try:
|
||
with openai_semaphore:
|
||
# call_openai_chat ist mit @retry_on_failure dekoriert
|
||
raw_chat_response = call_openai_chat(prompt, temperature=0.1, model=getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo'))
|
||
|
||
if raw_chat_response:
|
||
parsed_parent = "k.A."
|
||
parsed_justification = "Keine Begründung extrahiert."
|
||
|
||
parent_match = re.search(r"Vorgeschlagener Parent Account:\s*(.*)", raw_chat_response, re.IGNORECASE)
|
||
if parent_match:
|
||
parsed_parent = parent_match.group(1).strip()
|
||
if not parsed_parent or parsed_parent.lower() == "k.a.": # Sicherstellen, dass "k.A." korrekt übernommen wird
|
||
parsed_parent = "k.A."
|
||
|
||
justification_match = re.search(r"Begründung:\s*(.*)", raw_chat_response, re.IGNORECASE)
|
||
if justification_match:
|
||
parsed_justification = justification_match.group(1).strip()
|
||
|
||
suggested_parent = parsed_parent
|
||
justification = parsed_justification
|
||
logger.debug(f"Zeile {row_num}: ChatGPT Parent Vorschlag='{suggested_parent}', Begründung='{justification[:100]}...'")
|
||
else:
|
||
error_msg = "Leere Antwort von OpenAI erhalten."
|
||
logger.warning(f"Zeile {row_num}: {error_msg}")
|
||
justification = error_msg
|
||
|
||
except Exception as e:
|
||
error_msg = f"Fehler bei OpenAI Call für Parent Suggestion (Zeile {row_num}): {type(e).__name__} - {str(e)[:100]}"
|
||
logger.error(error_msg)
|
||
justification = error_msg
|
||
|
||
return {"row_num": row_num, "suggested_parent": suggested_parent, "justification": justification, "error": error_msg}
|
||
|
||
def process_parent_suggestion_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None, re_evaluate_question_mark=False):
|
||
"""
|
||
Batch-Prozess zur Generierung von Parent-Account-Vorschlägen mittels ChatGPT.
|
||
Schreibt Ergebnisse in Spalten O, P, Q.
|
||
Bearbeitet nur Zeilen, bei denen Spalte Q (Parent Vorschlag Timestamp) leer ist,
|
||
es sei denn re_evaluate_question_mark ist True und Spalte P ist '?'.
|
||
|
||
Args:
|
||
start_sheet_row (int, optional): Die 1-basierte Startzeile.
|
||
end_sheet_row (int, optional): Die 1-basierte Endzeile.
|
||
limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen.
|
||
re_evaluate_question_mark (bool, optional): Wenn True, werden auch Zeilen mit '?'
|
||
in Spalte P (Parent Vorschlag Status) erneut bewertet,
|
||
AUCH WENN Spalte Q bereits einen Timestamp hat.
|
||
"""
|
||
self.logger.info(f"Starte Parent Account Suggestion Batch. Bereich: {start_sheet_row if start_sheet_row else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit else 'Unbegrenzt'}, Re-Eval ?: {re_evaluate_question_mark}")
|
||
|
||
# --- Daten laden und Startzeile ermitteln ---
|
||
col_o_key = "System Vorschlag Parent Account"
|
||
col_p_key = "Parent Vorschlag Status"
|
||
col_q_key = "Parent Vorschlag Timestamp" # Timestamp-Spalte für die Auswahl
|
||
|
||
if start_sheet_row is None:
|
||
self.logger.info(f"Automatische Ermittlung der Startzeile basierend auf leerem '{col_q_key}'...") # Geändert: Start basierend auf leerem Q
|
||
start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key=col_q_key, min_sheet_row=7)
|
||
if start_data_index_no_header == -1:
|
||
self.logger.error("FEHLER bei autom. Startzeilenermittlung. Breche ab.")
|
||
return
|
||
start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1
|
||
self.logger.info(f"Automatisch ermittelte Startzeile: {start_sheet_row}")
|
||
else:
|
||
if not self.sheet_handler.load_data():
|
||
self.logger.error("FEHLER beim Laden der Daten für Parent Suggestion Batch.")
|
||
return
|
||
|
||
all_data = self.sheet_handler.get_all_data_with_headers()
|
||
header_rows = self.sheet_handler._header_rows
|
||
total_sheet_rows = len(all_data)
|
||
|
||
if end_sheet_row is None: end_sheet_row = total_sheet_rows
|
||
self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}.")
|
||
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
|
||
self.logger.info("Start liegt nach Ende oder außerhalb des Sheets. Keine Verarbeitung.")
|
||
return
|
||
|
||
# --- Indizes ---
|
||
required_keys = [
|
||
col_o_key, col_p_key, col_q_key, "CRM Name", "CRM Website", "CRM Beschreibung",
|
||
"Wiki URL", "Wiki Absatz", "Wiki Kategorien", "Website Zusammenfassung", "Version"
|
||
]
|
||
if not all(key in COLUMN_MAP for key in required_keys):
|
||
missing = [k for k in required_keys if k not in COLUMN_MAP]
|
||
self.logger.critical(f"FEHLER: Spaltenschlüssel für Parent Suggestion Batch fehlen: {missing}. Abbruch.")
|
||
return
|
||
|
||
col_o_letter = self.sheet_handler._get_col_letter(COLUMN_MAP[col_o_key] + 1)
|
||
col_p_letter = self.sheet_handler._get_col_letter(COLUMN_MAP[col_p_key] + 1)
|
||
col_q_letter = self.sheet_handler._get_col_letter(COLUMN_MAP[col_q_key] + 1)
|
||
|
||
openai_sem = threading.Semaphore(getattr(Config, 'OPENAI_CONCURRENCY_LIMIT', 3))
|
||
max_workers = getattr(Config, 'MAX_BRANCH_WORKERS', 10)
|
||
processing_batch_size = getattr(Config, 'PROCESSING_BRANCH_BATCH_SIZE', 10)
|
||
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)
|
||
|
||
tasks_for_current_openai_batch = []
|
||
all_sheet_updates = []
|
||
processed_count = 0
|
||
skipped_count = 0
|
||
|
||
# Funktion zum Verarbeiten und Schreiben eines Batches (bleibt intern gleich)
|
||
def _execute_and_write_openai_batch(current_tasks):
|
||
# ... (Code der inneren Funktion bleibt identisch wie im vorherigen Vorschlag) ...
|
||
nonlocal processed_count
|
||
if not current_tasks:
|
||
return
|
||
|
||
batch_start_log_row = current_tasks[0]['row_num']
|
||
batch_end_log_row = current_tasks[-1]['row_num']
|
||
self.logger.debug(f"\n--- Starte Parent Suggestion OpenAI Batch ({len(current_tasks)} Tasks, Zeilen {batch_start_log_row}-{batch_end_log_row}) ---")
|
||
|
||
batch_results_list = []
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||
future_to_task_map = {executor.submit(self._suggest_parent_account_openai_task, task, openai_sem): task for task in current_tasks}
|
||
for future in concurrent.futures.as_completed(future_to_task_map):
|
||
task_info_orig = future_to_task_map[future]
|
||
try:
|
||
result_data = future.result()
|
||
batch_results_list.append(result_data)
|
||
except Exception as e_future:
|
||
self.logger.error(f"Exception im Future für Parent Suggestion Zeile {task_info_orig['row_num']}: {e_future}")
|
||
batch_results_list.append({
|
||
"row_num": task_info_orig['row_num'],
|
||
"suggested_parent": "FEHLER_TASK",
|
||
"justification": str(e_future)[:150],
|
||
"error": str(e_future)
|
||
})
|
||
|
||
self.logger.debug(f" OpenAI Batch ({batch_start_log_row}-{batch_end_log_row}) abgeschlossen. {len(batch_results_list)} Ergebnisse erhalten.")
|
||
|
||
if batch_results_list:
|
||
now_ts_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
updates_for_this_batch = []
|
||
|
||
for res_item in batch_results_list:
|
||
rn = res_item['row_num']
|
||
parent_val = res_item.get('suggested_parent', 'k.A. (Fehler)')
|
||
|
||
updates_for_this_batch.append({'range': f'{col_o_letter}{rn}', 'values': [[parent_val]]})
|
||
status_val = "?" if parent_val and parent_val.lower() != "k.a." and not parent_val.startswith("FEHLER") else ""
|
||
updates_for_this_batch.append({'range': f'{col_p_letter}{rn}', 'values': [[status_val]]})
|
||
updates_for_this_batch.append({'range': f'{col_q_letter}{rn}', 'values': [[now_ts_str]]})
|
||
|
||
if res_item.get('justification'):
|
||
self.logger.debug(f"Zeile {rn} - Parent Begründung: {res_item.get('justification')[:200]}...")
|
||
|
||
all_sheet_updates.extend(updates_for_this_batch)
|
||
|
||
processed_count += len(current_tasks) # Zähle hier, wenn Tasks tatsächlich an OpenAI gingen
|
||
|
||
if len(all_sheet_updates) >= update_batch_row_limit * 3:
|
||
self.logger.info(f"Sende Batch-Updates für Parent Suggestions ({len(all_sheet_updates)//3} Zeilen)...")
|
||
self.sheet_handler.batch_update_cells(all_sheet_updates)
|
||
all_sheet_updates.clear()
|
||
|
||
time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.5)
|
||
# Ende der Hilfsfunktion _execute_and_write_openai_batch
|
||
|
||
# Hauptschleife über die Zeilen
|
||
for i in range(start_sheet_row, end_sheet_row + 1):
|
||
# Limit-Prüfung erfolgt jetzt innerhalb der _execute_and_write_openai_batch
|
||
# oder besser hier vor dem Sammeln von Tasks, um nicht unnötig zu iterieren.
|
||
if limit is not None and processed_count >= limit:
|
||
self.logger.info(f"Verarbeitungslimit ({limit}) für Parent Suggestions erreicht.")
|
||
break
|
||
|
||
row_idx_list = i - 1
|
||
if row_idx_list >= total_sheet_rows: break
|
||
row = all_data[row_idx_list]
|
||
|
||
if not any(cell and str(cell).strip() for cell in row):
|
||
skipped_count += 1
|
||
continue
|
||
|
||
# Kriterien für Verarbeitung
|
||
val_q_timestamp = self._get_cell_value_safe(row, col_q_key).strip() # Timestamp aus Spalte Q
|
||
val_p_status = self._get_cell_value_safe(row, col_p_key).strip() # Status aus Spalte P
|
||
|
||
needs_processing = False
|
||
if not val_q_timestamp: # Spalte Q (Timestamp) ist leer
|
||
needs_processing = True
|
||
elif re_evaluate_question_mark and val_p_status == "?": # Neubewertung für Status "?" auch wenn Timestamp Q gesetzt ist
|
||
needs_processing = True
|
||
self.logger.debug(f"Zeile {i}: Wird trotz vorhandenem Timestamp in Q ('{val_q_timestamp}') verarbeitet, da P='?' und re_evaluate_question_mark=True.")
|
||
|
||
if not needs_processing:
|
||
skipped_count += 1
|
||
continue
|
||
|
||
# Daten für Task sammeln
|
||
task_data = {
|
||
"row_num": i,
|
||
"crm_name": self._get_cell_value_safe(row, "CRM Name"),
|
||
"crm_website": self._get_cell_value_safe(row, "CRM Website"),
|
||
"crm_beschreibung": self._get_cell_value_safe(row, "CRM Beschreibung"),
|
||
"wiki_url": self._get_cell_value_safe(row, "Wiki URL"),
|
||
"wiki_absatz": self._get_cell_value_safe(row, "Wiki Absatz"),
|
||
"wiki_kategorien": self._get_cell_value_safe(row, "Wiki Kategorien"),
|
||
"website_zusammenfassung": self._get_cell_value_safe(row, "Website Zusammenfassung")
|
||
}
|
||
tasks_for_current_openai_batch.append(task_data)
|
||
|
||
if len(tasks_for_current_openai_batch) >= processing_batch_size:
|
||
_execute_and_write_openai_batch(tasks_for_current_openai_batch)
|
||
tasks_for_current_openai_batch.clear()
|
||
|
||
if tasks_for_current_openai_batch:
|
||
_execute_and_write_openai_batch(tasks_for_current_openai_batch)
|
||
|
||
if all_sheet_updates:
|
||
self.logger.info(f"Sende finale Batch-Updates für Parent Suggestions ({len(all_sheet_updates)//3} Zeilen)...")
|
||
self.sheet_handler.batch_update_cells(all_sheet_updates)
|
||
|
||
self.logger.info(f"Parent Account Suggestion Batch abgeschlossen. {processed_count} Zeilen verarbeitet, {skipped_count} Zeilen übersprungen.")
|
||
|
||
|
||
|
||
# ==========================================================================
|
||
# === Utility Methods (ML Data Prep & Training) ============================
|
||
# ==========================================================================
|
||
|
||
# --- Methode fuer ML Vorhersage (AU) ---
|
||
# Diese Methode wird in _process_single_row (Block 21) aufgerufen, wenn der ML-Schritt angefordert ist und noetig ist.
|
||
# Sie fuehrt eine Vorhersage des Servicetechniker-Buckets fuer eine einzelne Zeile mit dem trainierten ML-Modell durch.
|
||
# Sie nutzt das geladene Modell und den Imputer (Attribute der DataProcessor Instanz).
|
||
# Nutzt interne Helfer: _get_cell_value_safe, _load_ml_model (denselben Block).
|
||
# Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, pandas, numpy, re, clean_text (Block 4), get_numeric_filter_value (Block 5).
|
||
def _predict_technician_bucket(self, row_data):
|
||
company_name = self._get_cell_value_safe(row_data, 'CRM Name').strip()
|
||
self.logger.debug(f"Versuche ML-Schaetzung fuer Zeile ({company_name[:50]}...)")
|
||
|
||
if self.model is None or self.imputer is None or self._expected_features is None:
|
||
self.logger.info("Lade ML-Modell, Imputer und Feature-Spalten...")
|
||
self._load_ml_model(MODEL_FILE, IMPUTER_FILE)
|
||
if self.model is None or self.imputer is None or self._expected_features is None:
|
||
self.logger.error("Laden von Modell, Imputer oder Feature-Spalten fehlgeschlagen.")
|
||
return "FEHLER Schaetzung (Modell-Laden)"
|
||
try:
|
||
# === Feature Erstellung (muss exakt zum Training passen!) ===
|
||
|
||
# 1. Konsolidierte numerische Werte holen
|
||
final_umsatz_val_str = self._get_cell_value_safe(row_data, "Finaler Umsatz (Wiki>CRM)")
|
||
final_ma_val_str = self._get_cell_value_safe(row_data, "Finaler Mitarbeiter (Wiki>CRM)")
|
||
|
||
umsatz_for_pred = get_numeric_filter_value(final_umsatz_val_str, is_umsatz=True)
|
||
ma_for_pred = get_numeric_filter_value(final_ma_val_str, is_umsatz=False)
|
||
|
||
umsatz_for_pred = np.nan if umsatz_for_pred == 0 else umsatz_for_pred
|
||
ma_for_pred = np.nan if ma_for_pred == 0 else ma_for_pred
|
||
|
||
# 2. 'is_part_of_group' Feature erstellen
|
||
parent_d_val = self._get_cell_value_safe(row_data, "Parent Account Name").strip().lower()
|
||
parent_o_val = self._get_cell_value_safe(row_data, "System Vorschlag Parent Account").strip().lower()
|
||
parent_p_val = self._get_cell_value_safe(row_data, "Parent Vorschlag Status").strip().lower()
|
||
cond1_pred = bool(parent_d_val and parent_d_val != 'k.a.')
|
||
cond2_pred = bool(parent_o_val and parent_o_val != 'k.a.' and parent_p_val == 'x')
|
||
is_group_val = 1 if cond1_pred or cond2_pred else 0
|
||
|
||
# 3. Zusätzliche Features (Ratio, Log) erstellen
|
||
# Log-Transformationen
|
||
log_umsatz_val = np.log1p(umsatz_for_pred) if pd.notna(umsatz_for_pred) else np.nan
|
||
log_ma_val = np.log1p(ma_for_pred) if pd.notna(ma_for_pred) else np.nan
|
||
|
||
# Umsatz pro MA
|
||
umsatz_pro_ma_val = np.nan
|
||
if pd.notna(umsatz_for_pred) and pd.notna(ma_for_pred) and ma_for_pred > 0:
|
||
umsatz_pro_ma_val = umsatz_for_pred / ma_for_pred
|
||
|
||
# 4. Branchen-Feature holen
|
||
# Wichtig: Hier die gleiche Branchenspalte wie im Training verwenden!
|
||
branche_val_str = self._get_cell_value_safe(row_data, "CRM Branche")
|
||
|
||
# DataFrame mit einer Zeile und den internen Namen (wie in prepare_data_for_modeling) erstellen
|
||
single_row_dict = {
|
||
'Log_Finaler_Umsatz_ML': [log_umsatz_val],
|
||
'Log_Finaler_Mitarbeiter_ML': [log_ma_val],
|
||
'Umsatz_pro_MA_ML': [umsatz_pro_ma_val],
|
||
'is_part_of_group': [is_group_val],
|
||
'branche_crm': [str(branche_val_str).strip() if branche_val_str else 'Unbekannt']
|
||
}
|
||
df_single_row = pd.DataFrame.from_dict(single_row_dict)
|
||
|
||
# One-Hot Encoding
|
||
df_encoded = pd.get_dummies(df_single_row, columns=['branche_crm'], prefix='Branche', dummy_na=False)
|
||
|
||
# Angleichung an die im Training verwendeten Features
|
||
# Erstelle einen DataFrame mit einer Zeile und den erwarteten Spalten
|
||
data_for_df_processed = {col: [0] for col in self._expected_features}
|
||
for col in self._expected_features:
|
||
if col in df_encoded.columns:
|
||
data_for_df_processed[col] = [df_encoded[col].iloc[0]]
|
||
|
||
df_processed = pd.DataFrame(data_for_df_processed, columns=self._expected_features)
|
||
|
||
# Imputation und Vorhersage
|
||
df_imputed_array = self.imputer.transform(df_processed)
|
||
|
||
prediction_proba = self.model.predict_proba(df_imputed_array)
|
||
predicted_bucket_label = self.model.classes_[np.argmax(prediction_proba[0])]
|
||
|
||
self.logger.debug(f" -> ML Vorhersage Ergebnis: '{predicted_bucket_label}'")
|
||
return predicted_bucket_label
|
||
|
||
except Exception as e_predict:
|
||
self.logger.exception(f"FEHLER bei der ML-Vorhersage für Zeile ({company_name[:50]}...): {e_predict}")
|
||
return f"FEHLER Schaetzung: {str(e_predict)[:100]}..."
|
||
|
||
|
||
|
||
|
||
|
||
# --- Methode zum Laden des ML Modells und Imputers ---
|
||
# Diese Methode wird von _predict_technician_bucket (denselben Block) und train_technician_model (denselben Block) aufgerufen.
|
||
# Sie laedt die serialisierten Modelle von der Festplatte.
|
||
# Nutzt globale Helfer: MODEL_FILE (Block 1), IMPUTER_FILE (Block 1), PATTERNS_FILE_JSON (Block 1),
|
||
# logger, os, pickle, json.
|
||
def _load_ml_model(self, model_path, imputer_path):
|
||
"""
|
||
Laedt das trainierte ML-Modell, den Imputer und die erwarteten Feature-Spalten
|
||
von den definierten Dateipfaden. Speichert sie als Instanzattribute.
|
||
|
||
Args:
|
||
model_path (str): Dateipfad zum Modell (.pkl).
|
||
imputer_path (str): Dateipfad zum Imputer (.pkl).
|
||
# Der Pfad zur Feature-Spaltenliste (JSON) wird aus PATTERNS_FILE_JSON (Block 1) geholt.
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
# Setzen Sie die Instanzattribute zunaechst auf None
|
||
self.model = None
|
||
self.imputer = None
|
||
self._expected_features = None # Liste der erwarteten Feature-Spalten fuer Vorhersage
|
||
|
||
try:
|
||
# Pruefen Sie, ob die Modelldateien existieren
|
||
if not os.path.exists(model_path):
|
||
self.logger.error(f"ML-Modell Datei nicht gefunden: {model_path}") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn die Datei fehlt
|
||
if not os.path.exists(imputer_path):
|
||
self.logger.error(f"Imputer Datei nicht gefunden: {imputer_path}") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn die Datei fehlt
|
||
|
||
# Laden Sie das serialisierte Modell
|
||
with open(model_path, 'rb') as f:
|
||
self.model = pickle.load(f)
|
||
self.logger.info(f"ML-Modell '{model_path}' erfolgreich geladen.") # <<< GEÄNDERT
|
||
# Loggen Sie die Klassen-Labels des geladenen Modells zur Info
|
||
if hasattr(self.model, 'classes_'):
|
||
self.logger.debug(f"Geladene Modell-Klassen: {self.model.classes_}") # <<< GEÄNDERT
|
||
else:
|
||
self.logger.debug("Geladenes Modell hat kein 'classes_' Attribut.") # <<< GEÄNDERT
|
||
|
||
|
||
# Laden Sie den serialisierten Imputer
|
||
with open(imputer_path, 'rb') as f:
|
||
self.imputer = pickle.load(f)
|
||
self.logger.info(f"Imputer '{imputer_path}' erfolgreich geladen.") # <<< GEÄNDERT
|
||
|
||
|
||
# Laden Sie die Liste der erwarteten Feature-Spalten (JSON-Datei wird empfohlen)
|
||
expected_features_path = PATTERNS_FILE_JSON # Nutzt globale Konstante (Block 1)
|
||
# Pruefen Sie, ob die Feature-Spalten-Datei existiert
|
||
if os.path.exists(expected_features_path):
|
||
try:
|
||
# Oeffnen Sie die JSON-Datei
|
||
with open(expected_features_path, 'r', encoding='utf-8') as f:
|
||
# Laden Sie die Daten aus der JSON-Datei
|
||
data = json.load(f)
|
||
# Annahme: Die JSON-Datei enthaelt eine Liste der Feature-Spalten unter dem Schluessel "feature_columns"
|
||
self._expected_features = data.get("feature_columns")
|
||
# Pruefen Sie, ob die geladenen Daten eine nicht-leere Liste sind.
|
||
if self._expected_features and isinstance(self._expected_features, list):
|
||
self.logger.info(f"Erwartete Feature-Spalten ({len(self._expected_features)}) aus '{expected_features_path}' geladen.") # <<< GEÄNDERT
|
||
# Loggen Sie die ersten paar erwarteten Features auf Debug
|
||
# self.logger.debug(f"Erwartete Features (erste 5): {self._expected_features[:5]}...") # Zu viel Laerm im Debug
|
||
else:
|
||
# Wenn die geladenen Daten nicht das erwartete Format haben oder leer sind
|
||
self.logger.error(f"Formatfehler in '{expected_features_path}' oder Schluessel 'feature_columns' fehlt/ist leer. ML-Vorhersage koennte fehlschlagen.") # <<< GEÄNDERT
|
||
self._expected_features = None # Setze auf None bei Fehler
|
||
|
||
except Exception as e_json:
|
||
# Fangen Sie Fehler beim Laden oder Parsen der JSON-Datei ab
|
||
self.logger.error(f"FEHLER beim Laden oder Parsen der Feature-Spalten Datei '{expected_features_path}': {e_json}") # <<< GEÄNDERT
|
||
# Logge den Traceback
|
||
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
|
||
self._expected_features = None # Setze auf None bei Fehler
|
||
|
||
else:
|
||
# Wenn die Feature-Spalten-Datei nicht gefunden wird
|
||
self.logger.warning(f"Datei mit erwarteten Feature-Spalten '{expected_features_path}' nicht gefunden. ML-Vorhersage koennte fehlschlagen.") # <<< GEÄNDERT
|
||
self._expected_features = None # Setze auf None, da die Datei fehlt
|
||
|
||
|
||
# Fallback: Wenn expected_features nicht geladen werden konnte, versuchen Sie es aus Imputer/Modell zu extrahieren (wenn die Bibliothek es unterstuetzt)
|
||
if self._expected_features is None:
|
||
try:
|
||
# Neuere Scikit-learn Versionen haben oft ein feature_names_in_ Attribut
|
||
if hasattr(self.imputer, 'feature_names_in_') and self.imputer.feature_names_in_ is not None:
|
||
self._expected_features = list(self.imputer.feature_names_in_)
|
||
self.logger.info(f"Erwartete Feature-Spalten ({len(self._expected_features)}) aus Imputer geladen (Fallback).") # <<< GEÄNDERT
|
||
elif hasattr(self.model, 'feature_names_in_') and self.model.feature_names_in_ is not None:
|
||
self._expected_features = list(self.model.feature_names_in_)
|
||
self.logger.info(f"Erwartete Feature-Spalten ({len(self._expected_features)}) aus Modell geladen (Fallback).") # <<< GEÄNDERT
|
||
else:
|
||
# Wenn es nirgends gefunden werden konnte
|
||
self.logger.error("Konnte erwartete Feature-Spalten weder aus Datei noch aus Modell/Imputer extrahieren. ML-Vorhersage wird fehlschlagen.") # <<< GEÄNDERT
|
||
self._expected_features = None
|
||
except Exception as e_extract:
|
||
# Fange Fehler beim Extrahieren aus Modell/Imputer ab
|
||
self.logger.error(f"FEHLER beim Extrahieren der Feature-Namen aus Modell/Imputer (Fallback): {e_extract}") # <<< GEÄNDERT
|
||
# Logge den Traceback
|
||
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
|
||
self._expected_features = None
|
||
|
||
|
||
except Exception as e:
|
||
# Fange alle anderen unerwarteten Fehler waehrend des Ladens ab
|
||
self.logger.exception(f"FEHLER beim Laden von ML-Artefakten: {e}") # <<< GEÄNDERT
|
||
# Setzen Sie die Attribute auf None bei Fehler
|
||
self.model = None
|
||
self.imputer = None
|
||
self._expected_features = None
|
||
# Die Methode endet implizit hier nach dem Fangen der Exception.
|
||
|
||
|
||
# Methode zur Datenvorbereitung fuer ML (WIRD VON train_technician_model aufgerufen)
|
||
# Diese Methode laedt alle relevanten Daten aus dem Sheet, bereitet sie auf
|
||
# und gibt einen DataFrame fuer das Training zurueck.
|
||
# Basierend auf prepare_data_for_modeling aus Teil 12/13.
|
||
# Nutzt interne Helfer: _get_cell_value_safe.
|
||
# Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, pandas, numpy, re,
|
||
# clean_text (Block 4), normalize_string (Block 4), get_numeric_filter_value (Block 5),
|
||
# load_target_schema (Block 6 - relevant fuer Branchentypen), traceback.
|
||
# Nutzt die uebergeordnete sheet_handler Instanz (Block 14).
|
||
def prepare_data_for_modeling(self):
|
||
"""
|
||
Laedt Daten aus dem Google Sheet ueber den sheet_handler,
|
||
bereitet sie fuer das Decision Tree Modell vor:
|
||
- Waehlt relevante Spalten aus und benennt sie um.
|
||
- Konsolidiert Umsatz/Mitarbeiter (Wiki > CRM Prioritaet).
|
||
- Filtert nach gueltiger Technikerzahl (> 0).
|
||
- Erstellt die Zielvariable (Techniker-Bucket).
|
||
- Bereitet Features auf (One-Hot Encoding fuer Branche).
|
||
- Behaelt NaNs in numerischen Features fuer spaetere Imputation.
|
||
|
||
Returns:
|
||
pandas.DataFrame: Vorbereiteter DataFrame fuer Training/Test-Split,
|
||
oder None bei Fehlern oder wenn keine gueltigen Trainingsdaten gefunden wurden.
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
self.logger.info("Starte Datenvorbereitung fuer Modellierung (Training)...") # <<< GEÄNDERT
|
||
# Nutzt den self.sheet_handler der Klasse (Block 15).
|
||
# Pruefen Sie, ob der Sheet Handler initialisiert wurde und Daten hat.
|
||
if not self.sheet_handler or not self.sheet_handler.sheet_values:
|
||
self.logger.error("Fehler: Sheet Handler nicht initialisiert oder keine Daten geladen fuer prepare_data_for_modeling.") # <<< GEÄNDERT
|
||
# Versuchen Sie die Daten einmalig innerhalb dieser Methode zu laden, falls sie fehlen.
|
||
# Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2).
|
||
if not self.sheet_handler.load_data():
|
||
self.logger.critical("Konnte Daten auch nach erneutem Versuch nicht laden. Abbruch der Datenvorbereitung.") # <<< GEÄNDERT
|
||
return None # Gebe None zurueck, wenn Laden fehlschlaegt
|
||
|
||
|
||
# Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler.
|
||
all_data = self.sheet_handler.get_all_data_with_headers()
|
||
# Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14).
|
||
header_rows = self.sheet_handler._header_rows
|
||
# Pruefe auf ausreichende Zeilenzahl (Header + mindestens eine Datenzeile)
|
||
min_required_rows = header_rows + 1
|
||
# Wenn nicht genuegend Zeilen da sind
|
||
if not all_data or len(all_data) < min_required_rows:
|
||
self.logger.error(f"Fehler: Nicht genuegend Datenzeilen ({len(all_data)}) im Sheet gefunden fuer Modellierung (mindestens {min_required_rows} benoetigt).") # <<< GEÄNDERT
|
||
return None # Gebe None zurueck, wenn nicht genuegend Daten da sind
|
||
|
||
|
||
# --- Header pruefen und DataFrame erstellen ---
|
||
try:
|
||
# Die erste Zeile sollte die Spaltennamen enthalten.
|
||
headers = all_data[0]
|
||
# Stellen Sie sicher, dass die Header-Zeile auch die erwartete Mindestlaenge hat,
|
||
# um die Spaltenindizes aus COLUMN_MAP (Block 1) zu finden.
|
||
try:
|
||
max_col_idx_in_map = max(COLUMN_MAP.values()) # Finde den hoechsten Index in COLUMN_MAP
|
||
# Pruefen Sie, ob die Anzahl der geladenen Spalten im Header ausreicht
|
||
if len(headers) <= max_col_idx_in_map:
|
||
# Logge einen kritischen Fehler, wenn das Mapping auf Spalten zeigt, die nicht im Sheet existieren
|
||
self.logger.critical(f"FEHLER: Header-Zeile ({len(headers)} Spalten) ist kuerzer als der hoechste Index in COLUMN_MAP ({max_col_idx_in_map}). COLUMN_MAP passt nicht zum Sheet.") # <<< GEÄNDERT
|
||
return None # Beende die Methode
|
||
except ValueError: # Tritt auf, wenn COLUMN_MAP leer ist
|
||
self.logger.critical("FEHLER: COLUMN_MAP scheint leer zu sein oder enthaelt keine Werte. Kann Max Index nicht ermitteln.") # <<< GEÄNDERT
|
||
return None # Beende die Methode
|
||
except Exception as e:
|
||
# Fange andere unerwartete Fehler ab
|
||
self.logger.critical(f"FEHLER beim Pruefen der Spaltenlaenge der Header-Zeile: {e}") # <<< GEÄNDERT
|
||
return None # Beende die Methode
|
||
|
||
except IndexError:
|
||
# Wenn das Sheet leer ist oder keine erste Zeile hat
|
||
self.logger.critical("FEHLER: Sheet scheint leer zu sein oder hat keine erste Zeile, keine Header gefunden.") # <<< GEÄNDERT
|
||
return None # Beende die Methode
|
||
except Exception as e:
|
||
# Fange andere unerwartete Fehler beim Zugriff auf Header ab
|
||
self.logger.critical(f"FEHLER beim Zugriff auf Header: {e}") # <<< GEÄNDERT
|
||
# Logge den Traceback
|
||
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
|
||
return None # Beende die Methode
|
||
|
||
|
||
# Datenzeilen sind alle Zeilen nach den Header-Zeilen
|
||
data_rows = all_data[header_rows:] # Annahme: Die ersten X Zeilen sind Header
|
||
|
||
# Erstelle DataFrame aus den Datenzeilen und den Headern
|
||
df = pd.DataFrame(data_rows, columns=headers)
|
||
self.logger.info(f"Initialen DataFrame fuer Modellierung erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.") # <<< GEÄNDERT
|
||
|
||
|
||
# DACH-Filter (basierend auf CRM Land - Spalte G)
|
||
crm_land_col_header = headers[COLUMN_MAP["CRM Land"]] # Holt den tatsächlichen Spaltennamen
|
||
# Erlaubte Werte für DACH-Länder (Groß- und Kleinschreibung wird durch .str.upper() behandelt)
|
||
dach_countries = ["DE", "CH", "AT", "DEUTSCHLAND", "ÖSTERREICH", "SCHWEIZ", "OESTERREICH"] # OESTERREICH hinzugefügt
|
||
|
||
# Sicherstellen, dass die Spalte existiert, bevor gefiltert wird
|
||
if crm_land_col_header in df.columns:
|
||
df = df[df[crm_land_col_header].astype(str).str.upper().isin(dach_countries)].copy() # .copy() um Warnung zu vermeiden
|
||
self.logger.info(f"Nach DACH-Filter (basierend auf '{crm_land_col_header}'): {len(df)} Zeilen verbleiben.")
|
||
if df.empty:
|
||
self.logger.error("Keine DACH-Unternehmen im Datensatz nach Filterung.")
|
||
return None
|
||
else:
|
||
self.logger.error(f"Spalte '{crm_land_col_header}' für DACH-Filter nicht im DataFrame gefunden.")
|
||
return None
|
||
|
||
# Plausibilitätsfilter (basierend auf Spalten BG und BH)
|
||
plausi_umsatz_col_header = headers[COLUMN_MAP["Plausibilität Umsatz"]]
|
||
plausi_ma_col_header = headers[COLUMN_MAP["Plausibilität Mitarbeiter"]]
|
||
|
||
# Sicherstellen, dass die Spalten existieren
|
||
if plausi_umsatz_col_header in df.columns and plausi_ma_col_header in df.columns:
|
||
# Filtere Zeilen, bei denen Plausi-Umsatz oder Plausi-MA einen Fehler anzeigt
|
||
# Hier gehen wir davon aus, dass Fehler mit "FEHLER_" beginnen.
|
||
df = df[~df[plausi_umsatz_col_header].astype(str).str.upper().str.startswith('FEHLER')].copy()
|
||
df = df[~df[plausi_ma_col_header].astype(str).str.upper().str.startswith('FEHLER')].copy()
|
||
self.logger.info(f"Nach Entfernung von FEHLER-Plausi-Fällen (BG, BH): {len(df)} Zeilen verbleiben.")
|
||
if df.empty:
|
||
self.logger.error("Keine Zeilen nach Plausi-Filterung übrig.")
|
||
return None
|
||
# Hier könnten Sie noch spezifischere Filter für bestimmte WARNUNG-Typen einbauen, falls gewünscht
|
||
else:
|
||
self.logger.error(f"Plausibilitätsspalten '{plausi_umsatz_col_header}' oder '{plausi_ma_col_header}' nicht im DataFrame gefunden.")
|
||
return None
|
||
|
||
|
||
|
||
# --- Spaltenauswahl und Umbenennung ---
|
||
# Definiere die notwendigen Spalten anhand ihrer COLUMN_MAP Schluessel (Block 1)
|
||
# und weisen ihnen interne, einfachere Namen zu, die im DataFrame verwendet werden.
|
||
col_keys_mapping = {
|
||
"name": "CRM Name", # Zur Identifikation, wird spaeter entfernt
|
||
"branche_ki": "Chat Vorschlag Branche", # Fuer One-Hot Encoding
|
||
"umsatz_crm": "CRM Umsatz", # Fuer Konsolidierung
|
||
"umsatz_wiki": "Wiki Umsatz", # Fuer Konsolidierung
|
||
"ma_crm": "CRM Anzahl Mitarbeiter", # Fuer Konsolidierung
|
||
"ma_wiki": "Wiki Mitarbeiter", # Fuer Konsolidierung
|
||
"techniker": "CRM Anzahl Techniker", # DIE ZIELVARIABLE (Bekannte Technikerzahl)
|
||
"parent_d_raw": "Parent Account Name", # Spalte D <- Dies ist Zeile 9012
|
||
"parent_o_raw": "System Vorschlag Parent Account", # Spalte O
|
||
"parent_p_raw": "Parent Vorschlag Status" # Spalte P
|
||
}
|
||
|
||
# Ueberpruefe, ob alle benoetigten Spalten-Schluessel in der COLUMN_MAP (Block 1) vorhanden sind
|
||
missing_keys_in_map = [key for key in col_keys_mapping.values() if key not in COLUMN_MAP]
|
||
if missing_keys_in_map:
|
||
self.logger.critical(f"FEHLER: Folgende benoetigte Spalten-Schluessel fehlen in COLUMN_MAP fuer prepare_data_for_modeling: {missing_keys_in_map}.") # <<< GEÄNDERT
|
||
return None # Beende die Methode
|
||
|
||
# Erstelle das Mapping von tatsaechlichen Header-Namen zu internen Schluesseln.
|
||
# Verwende die Header-Namen aus dem geladenen Sheet und die COLUMN_MAP, um die richtigen Header zu finden.
|
||
header_to_internal_key = {} # Dict zum Umbenennen der Spalten
|
||
cols_to_select_by_header = [] # Liste der Header-Namen, die aus dem DF ausgewaehlt werden
|
||
|
||
try:
|
||
# Iteriere ueber das Mapping von internen zu COLUMN_MAP Schluesseln
|
||
for internal_key, column_map_key in col_keys_mapping.items():
|
||
# Hole den tatsaechlichen Header-Namen aus dem Sheet
|
||
header_name_from_sheet = headers[COLUMN_MAP[column_map_key]]
|
||
# Fuege das Mapping hinzu
|
||
header_to_internal_key[header_name_from_sheet] = internal_key
|
||
# Fuege den Header-Namen zur Liste der auszuwaehlenden Spalten hinzu
|
||
cols_to_select_by_header.append(header_name_from_sheet)
|
||
|
||
# Waehle nur die benoetigten Spalten im DataFrame aus
|
||
df_subset = df[cols_to_select_by_header].copy() # Kopie erstellen, um SettingWithCopyWarning zu vermeiden
|
||
# Benenne die Spalten um zu den internen Namen
|
||
df_subset.rename(columns=header_to_internal_key, inplace=True)
|
||
|
||
except KeyError as e:
|
||
# Dieser Fehler sollte eigentlich durch die obige Pruefung abgefangen werden,
|
||
# tritt aber auf, wenn ein erwarteter Header-Name nicht im geladenen DF ist (selten, wenn COLUMN_MAP korrekt ist).
|
||
self.logger.critical(f"FEHLER beim Auswaehlen/Umbenennen der Spalten (KeyError: '{e}'). Der Header wurde nicht im DataFrame gefunden.") # <<< GEÄNDERT
|
||
self.logger.debug(f"Erwartete Header: {cols_to_select_by_header}. Verfuegbare Header im DF: {list(df.columns)}") # <<< GEÄNDERT
|
||
return None # Beende die Methode
|
||
except IndexError as e:
|
||
# Tritt auf, wenn COLUMN_MAP einen Index > Anzahl Spalten im DF hat
|
||
self.logger.critical(f"FEHLER beim Auswaehlen/Umbenennen der Spalten (IndexError: '{e}'). COLUMN_MAP zeigt auf Spalten, die nicht im geladenen Sheet existieren.") # <<< GEÄNDERT
|
||
self.logger.debug(f"COLUMN_MAP: {COLUMN_MAP}. Sheet hat {len(headers)} Spalten.") # <<< GEÄNDERT
|
||
return None # Beende die Methode
|
||
except Exception as e:
|
||
# Fange andere unerwartete Fehler ab
|
||
self.logger.critical(f"Unerwarteter FEHLER beim Auswaehlen/Umbenennen der Spalten: {e}") # <<< GEÄNDERT
|
||
# Logge den Traceback
|
||
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
|
||
return None # Beende die Methode
|
||
|
||
|
||
self.logger.info(f"Benötigte Spalten fuer Modellierung ausgewaehlt und umbenannt: {list(df_subset.columns)}") # <<< GEÄNDERT
|
||
|
||
|
||
self.logger.info("Erstelle Feature 'is_part_of_group'...")
|
||
|
||
# Zugreifen auf die Spalten im DataFrame df_subset
|
||
# Die Spaltennamen hier müssen den internen Namen entsprechen,
|
||
# die in col_keys_mapping definiert wurden (z.B. 'parent_d_raw').
|
||
parent_d_series = df_subset['parent_d_raw'].astype(str).str.strip().str.lower()
|
||
parent_o_series = df_subset['parent_o_raw'].astype(str).str.strip().str.lower()
|
||
parent_p_series = df_subset['parent_p_raw'].astype(str).str.strip().str.lower()
|
||
|
||
cond1 = parent_d_series.notna() & (parent_d_series != 'k.a.') & (parent_d_series != '')
|
||
cond2_o = parent_o_series.notna() & (parent_o_series != 'k.a.') & (parent_o_series != '')
|
||
cond2_p = parent_p_series == 'x'
|
||
cond2 = cond2_o & cond2_p
|
||
|
||
# .loc verwenden, um die neue Spalte sicher zuzuweisen und SettingWithCopyWarning zu vermeiden
|
||
df_subset.loc[:, 'is_part_of_group'] = np.where(cond1 | cond2, 1, 0)
|
||
|
||
self.logger.info(f"Feature 'is_part_of_group' erstellt. {df_subset['is_part_of_group'].sum()} Unternehmen als Teil einer Gruppe markiert.")
|
||
self.logger.debug(f"Verteilung von 'is_part_of_group':\n{df_subset['is_part_of_group'].value_counts(normalize=True, dropna=False)}")
|
||
|
||
|
||
|
||
# --- Features konsolidieren (Umsatz, Mitarbeiter) ---
|
||
self.logger.debug("Konsolidiere Umsatz und Mitarbeiter für ML-Features...")
|
||
cols_to_process_ml = {
|
||
'Umsatz': ('umsatz_wiki', 'umsatz_crm', 'Finaler_Umsatz_ML'),
|
||
'Mitarbeiter': ('ma_wiki', 'ma_crm', 'Finaler_Mitarbeiter_ML')
|
||
}
|
||
for base_name, (wiki_col_ml, crm_col_ml, final_col_ml) in cols_to_process_ml.items():
|
||
is_umsatz_flag = (base_name == 'Umsatz')
|
||
wiki_series = df_subset[wiki_col_ml].apply(lambda x: get_numeric_filter_value(x, is_umsatz=is_umsatz_flag))
|
||
crm_series = df_subset[crm_col_ml].apply(lambda x: get_numeric_filter_value(x, is_umsatz=is_umsatz_flag))
|
||
|
||
# Wähle Wiki-Wert, wenn vorhanden und > 0, sonst CRM-Wert
|
||
df_subset.loc[:, final_col_ml] = np.where(
|
||
(wiki_series.notna()) & (wiki_series > 0),
|
||
wiki_series,
|
||
crm_series
|
||
)
|
||
# Ersetze 0 explizit durch NaN, damit es von log1p und Imputer korrekt behandelt wird
|
||
df_subset.loc[:, final_col_ml] = df_subset[final_col_ml].replace(0, np.nan)
|
||
self.logger.info(f" -> {df_subset[final_col_ml].notna().sum()} gueltige '{final_col_ml}' Werte erstellt (von {len(df_subset)} Zeilen).")
|
||
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# +++ NEUER BLOCK: Feature Engineering (Ratio & Log-Transformationen) +++++
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
self.logger.info("Erstelle zusätzliche Features (Ratio, Log-Transformationen)...")
|
||
|
||
if 'Finaler_Umsatz_ML' in df_subset.columns and 'Finaler_Mitarbeiter_ML' in df_subset.columns:
|
||
# Umsatz pro Mitarbeiter
|
||
ma_for_ratio = df_subset['Finaler_Mitarbeiter_ML'] # Hier sind Nullen schon durch NaN ersetzt
|
||
df_subset.loc[:, 'Umsatz_pro_MA_ML'] = df_subset['Finaler_Umsatz_ML'] / ma_for_ratio
|
||
df_subset['Umsatz_pro_MA_ML'].replace([np.inf, -np.inf], np.nan, inplace=True)
|
||
self.logger.debug(f" -> Feature 'Umsatz_pro_MA_ML' erstellt.")
|
||
|
||
# Log-Transformationen (np.log1p(x) berechnet log(1+x), sicher für NaNs)
|
||
df_subset.loc[:, 'Log_Finaler_Umsatz_ML'] = np.log1p(df_subset['Finaler_Umsatz_ML'])
|
||
df_subset.loc[:, 'Log_Finaler_Mitarbeiter_ML'] = np.log1p(df_subset['Finaler_Mitarbeiter_ML'])
|
||
self.logger.debug(f" -> Log-transformierte Features erstellt.")
|
||
else:
|
||
self.logger.warning("Konsolidierte Umsatz/Mitarbeiter-Spalten nicht gefunden, Feature Engineering übersprungen.")
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# +++ ENDE NEUER BLOCK ++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
|
||
# --- Zielvariable vorbereiten (Technikerzahl) ---
|
||
self.logger.info("Verarbeite Zielvariable 'techniker'...")
|
||
techniker_col_internal = "techniker"
|
||
df_subset.loc[:, 'Anzahl_Servicetechniker_Numeric'] = df_subset[techniker_col_internal].apply(lambda x: get_numeric_filter_value(x, is_umsatz=False))
|
||
|
||
initial_rows_before_tech_filter = len(df_subset)
|
||
df_filtered = df_subset[
|
||
df_subset['Anzahl_Servicetechniker_Numeric'].notna() &
|
||
(df_subset['Anzahl_Servicetechniker_Numeric'] > 0)
|
||
].copy()
|
||
|
||
removed_rows_tech_filter = initial_rows_before_tech_filter - len(df_filtered)
|
||
if removed_rows_tech_filter > 0:
|
||
self.logger.info(f"{removed_rows_tech_filter} Zeilen entfernt aufgrund fehlender/ungueltiger Technikerzahl.")
|
||
self.logger.info(f"Verbleibende Zeilen fuer Modellierungstraining: {len(df_filtered)}")
|
||
|
||
if df_filtered.empty:
|
||
self.logger.error("FEHLER: Keine Zeilen mit gueltiger Technikerzahl (>0) uebrig fuer Modellierungstraining!")
|
||
return None
|
||
|
||
# --- Techniker-Buckets erstellen (mit reduzierter Klassenanzahl) ---
|
||
self.logger.info("Erstelle reduzierte Techniker-Buckets (3 Klassen)...")
|
||
bins_new = [-1, 49, 249, float('inf')]
|
||
labels_new = ['Techniker_Klein (0-49)', 'Techniker_Mittel (50-249)', 'Techniker_Gross (250+)']
|
||
|
||
df_filtered.loc[:, 'Techniker_Bucket'] = pd.cut(
|
||
df_filtered['Anzahl_Servicetechniker_Numeric'], bins=bins_new, labels=labels_new, right=True, include_lowest=True
|
||
)
|
||
self.logger.info(f"Verteilung der neuen Techniker-Buckets:\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True, dropna=False).sort_index().round(3)}")
|
||
|
||
# --- Kategoriale Features vorbereiten (Branche) ---
|
||
branche_col_internal = "branche_ki" # Name der Spalte mit den Detail-Branchen
|
||
self.logger.info(f"Verarbeite kategoriales Feature '{branche_col_internal}' und mappe es zu 'Branchen_Gruppe'...")
|
||
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# +++ FINALER DEBUGGING-BLOCK ZUR PRÜFUNG DES MAPPING-DICTIONARIES ++++++
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
self.logger.info(f"PRÜFE MAPPING-DICT: Das BRANCH_MAPPING Dictionary hat {len(BRANCH_MAPPING)} Einträge.")
|
||
if len(BRANCH_MAPPING) < 5:
|
||
self.logger.error("ALARM: Das BRANCH_MAPPING Dictionary ist fast oder komplett leer! Das Laden der Branchen.csv ist fehlgeschlagen.")
|
||
else:
|
||
# Zeige die ersten 5 Schlüssel-Wert-Paare aus dem Dictionary, um zu sehen, ob es korrekt ist.
|
||
self.logger.info("PRÜFE MAPPING-DICT: Die ersten 5 Einträge sind:")
|
||
for i, (key, value) in enumerate(BRANCH_MAPPING.items()):
|
||
if i >= 5: break
|
||
self.logger.info(f" -> Key: '{key}' -> Value: '{value}'")
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# +++ ENDE FINALER DEBUGGING-BLOCK ++++++++++++++++++++++++++++++++++++++
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
|
||
|
||
normalized_sheet_branches = df_filtered[branche_col_internal].apply(normalize_for_mapping)
|
||
|
||
if branche_col_internal not in df_filtered.columns:
|
||
self.logger.critical(f"FEHLER: Spalte '{branche_col_internal}' nicht im DataFrame gefunden.")
|
||
return None
|
||
|
||
# Bereinige die Branchennamen in der Spalte für das Mapping
|
||
# Wichtig: .str.strip().str.title() oder .str.lower() anwenden, je nachdem wie das Mapping-Dict aufgebaut ist
|
||
cleaned_branches = df_filtered[branche_col_internal].astype(str).str.strip()
|
||
|
||
# Erstelle das Mapping-Dictionary ebenfalls bereinigt (idealerweise schon in load_branch_mapping)
|
||
# Für jetzt gehen wir davon aus, BRANCH_MAPPING hat bereinigte Keys
|
||
|
||
# Wende das Mapping an.
|
||
# .map() ist case-sensitive. Um das zu umgehen, können wir die Keys des Mappings und die Werte in der Spalte normalisieren,
|
||
# z.B. alles in Kleinbuchstaben.
|
||
|
||
# Sicherere Methode:
|
||
# Erstelle eine normalisierte Version des Mapping-Dictionaries
|
||
mapping_lower = {k.lower(): v for k, v in BRANCH_MAPPING.items()}
|
||
# Wende dieses normalisierte Mapping auf die normalisierte Branchenspalte an
|
||
df_filtered.loc[:, 'Branchen_Gruppe'] = normalized_sheet_branches.map(BRANCH_MAPPING).fillna('Sonstige')
|
||
|
||
self.logger.info("Mapping zu 'Branchen_Gruppe' durchgeführt.")
|
||
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# +++ NEUER, ERWEITERTER DEBUGGING-BLOCK ++++++++++++++++++++++++++++++++
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# Finde heraus, welche Branchen nicht gemappt werden konnten
|
||
unmapped_df = df_filtered[df_filtered['Branchen_Gruppe'] == 'Sonstige']
|
||
|
||
if not unmapped_df.empty:
|
||
unmapped_branches = unmapped_df[branche_col_internal].value_counts()
|
||
self.logger.warning(f"KONNTE MAPPING NICHT DURCHFÜHREN! {len(unmapped_branches)} verschiedene Detail-Branchen wurden als 'Sonstige' klassifiziert.")
|
||
self.logger.warning("Überprüfen Sie auf Unterschiede in Schreibweise, Leerzeichen oder Sonderzeichen.")
|
||
|
||
# Zeige die Top 10 nicht gemappten Branchen mit ihrer Häufigkeit
|
||
self.logger.warning("Top 10 nicht gemappte Branchen aus dem Google Sheet:")
|
||
for branch_name, count in unmapped_branches.head(10).items():
|
||
# Zeige den Wert exakt so an, wie er in der Spalte steht, in Anführungszeichen
|
||
self.logger.warning(f" -> '{branch_name}' (kam {count} mal vor)")
|
||
|
||
# Zeige einige Beispiele der normalisierten Schlüssel aus dem Mapping-File zum Vergleich
|
||
if mapping_lower:
|
||
self.logger.warning("Beispiele für normalisierte Schlüssel aus der Branchen.csv:")
|
||
# .keys() ist eine Ansicht, konvertiere zu Liste für Slicing
|
||
example_keys = list(mapping_lower.keys())
|
||
for i in range(min(10, len(example_keys))):
|
||
self.logger.warning(f" -> '{example_keys[i]}'")
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# +++ ENDE DEBUGGING-BLOCK ++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
|
||
self.logger.debug(f"Verteilung der Branchen-Gruppen:\n{df_filtered['Branchen_Gruppe'].value_counts(normalize=True).sort_index().round(3)}")
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# +++ ENDE NEUER BLOCK ++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
|
||
# One-Hot Encoding auf der neuen 'Branchen_Gruppe'-Spalte durchführen
|
||
df_encoded = pd.get_dummies(df_filtered, columns=['Branchen_Gruppe'], prefix='Gruppe', dummy_na=False) # << KORRIGIERT
|
||
self.logger.info(f"One-Hot Encoding fuer 'Branchen_Gruppe' durchgefuehrt...")
|
||
|
||
# --- Finale Auswahl der Features fuer das Modell ---
|
||
# Passe die Feature-Auswahl an, um die neuen Gruppen-Features zu verwenden
|
||
feature_columns_ml = [col for col in df_encoded.columns if col.startswith('Gruppe_')] # << KORRIGIERT
|
||
feature_columns_ml.extend([
|
||
'Log_Finaler_Umsatz_ML',
|
||
'Log_Finaler_Mitarbeiter_ML',
|
||
'Umsatz_pro_MA_ML',
|
||
'is_part_of_group'
|
||
])
|
||
self.logger.info(f"Finale Feature-Auswahl für das Training: {feature_columns_ml}")
|
||
|
||
target_column_ml = 'Techniker_Bucket'
|
||
identification_cols_ml = ['name', 'Anzahl_Servicetechniker_Numeric']
|
||
|
||
final_cols_for_df_ml = identification_cols_ml + feature_columns_ml + [target_column_ml]
|
||
missing_final_cols_ml = [col for col in final_cols_for_df_ml if col not in df_encoded.columns]
|
||
if missing_final_cols_ml:
|
||
self.logger.critical(f"FEHLER: Finale Spalten fuer Modellierung fehlen im DataFrame: {missing_final_cols_ml}")
|
||
return None
|
||
|
||
df_model_ready = df_encoded[final_cols_for_df_ml].copy()
|
||
|
||
numeric_features_to_convert = [
|
||
'Log_Finaler_Umsatz_ML', 'Log_Finaler_Mitarbeiter_ML', 'Umsatz_pro_MA_ML', 'Anzahl_Servicetechniker_Numeric'
|
||
]
|
||
for col in numeric_features_to_convert:
|
||
if col in df_model_ready.columns:
|
||
df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce')
|
||
|
||
df_model_ready = df_model_ready.reset_index(drop=True)
|
||
|
||
self.logger.info("Datenvorbereitung fuer Modellierung (Training) abgeschlossen.")
|
||
self.logger.info(f"Finaler DataFrame fuer Modellierung hat {len(df_model_ready)} Zeilen und {len(df_model_ready.columns)} Spalten.")
|
||
self.logger.info(f"Anzahl Feature-Spalten: {len(feature_columns_ml)}")
|
||
|
||
numeric_features_for_imputation_ml = [
|
||
'Log_Finaler_Umsatz_ML',
|
||
'Log_Finaler_Mitarbeiter_ML',
|
||
'Umsatz_pro_MA_ML'
|
||
]
|
||
existing_numeric_features = [col for col in numeric_features_for_imputation_ml if col in df_model_ready.columns]
|
||
if existing_numeric_features:
|
||
nan_counts = df_model_ready[existing_numeric_features].isna().sum()
|
||
self.logger.info(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}")
|
||
rows_with_nan = df_model_ready[existing_numeric_features].isna().any(axis=1).sum()
|
||
self.logger.info(f"Anzahl Zeilen mit mindestens einem fehlenden numerischen Feature (vor Imputation): {rows_with_nan}")
|
||
|
||
return df_model_ready
|
||
|
||
|
||
# Methode zum Trainieren des ML Modells
|
||
# Nutzt interne Methode: prepare_data_for_modeling.
|
||
# Nutzt globale Helfer: MODEL_FILE, IMPUTER_FILE, PATTERNS_FILE_JSON (Block 1),
|
||
# logger, pickle, json, os,
|
||
# train_test_split, SimpleImputer, DecisionTreeClassifier,
|
||
# accuracy_score, classification_report, confusion_matrix, export_text (sklearn).
|
||
def train_technician_model(self, model_out=MODEL_FILE, imputer_out=IMPUTER_FILE, patterns_out=PATTERNS_FILE_JSON):
|
||
self.logger.info("Starte Training des Servicetechniker Decision Tree Modells...")
|
||
|
||
# 1. Daten vorbereiten
|
||
df_model_ready = self.prepare_data_for_modeling()
|
||
if df_model_ready is None or df_model_ready.empty:
|
||
self.logger.error("Datenvorbereitung fuer Modelltraining fehlgeschlagen oder keine Daten. Training abgebrochen.")
|
||
return
|
||
|
||
# Feature Spalten und Zielspalte definieren
|
||
identification_cols = ['name', 'Anzahl_Servicetechniker_Numeric']
|
||
target_column = 'Techniker_Bucket'
|
||
feature_columns_ml = [col for col in df_model_ready.columns if col not in identification_cols and col != target_column]
|
||
|
||
if not feature_columns_ml:
|
||
self.logger.critical("FEHLER: Keine Feature-Spalten nach Datenvorbereitung gefunden. Training nicht moeglich.")
|
||
return
|
||
|
||
X = df_model_ready[feature_columns_ml]
|
||
y = df_model_ready[target_column]
|
||
self.logger.info(f"Daten fuer Training vorbereitet. X Shape: {X.shape}, y Shape: {y.shape}")
|
||
|
||
# 2. Split in Training und Test Set
|
||
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)
|
||
self.logger.info(f"Daten gesplittet. Train Set: {len(X_train)} Zeilen, Test Set: {len(X_test)} Zeilen.")
|
||
|
||
# 3. Imputation (Fehlende Werte ersetzen)
|
||
imputer = SimpleImputer(strategy='median')
|
||
self.logger.info(f"Fitte Imputer mit Strategie '{imputer.strategy}' auf Trainingsdaten...")
|
||
imputer.fit(X_train)
|
||
|
||
# Speichern Sie den Imputer (wird fuer Vorhersagen benoetigt).
|
||
self.imputer = imputer # Speichern Sie ihn in der Instanz
|
||
try:
|
||
imputer_dir = os.path.dirname(imputer_out)
|
||
if imputer_dir and not os.path.exists(imputer_dir):
|
||
os.makedirs(imputer_dir, exist_ok=True)
|
||
with open(imputer_out, 'wb') as f:
|
||
pickle.dump(imputer, f)
|
||
self.logger.info(f"Imputer erfolgreich gespeichert in '{imputer_out}'.")
|
||
except Exception as e:
|
||
self.logger.error(f"FEHLER beim Speichern des Imputers in '{imputer_out}': {e}")
|
||
self.logger.debug(traceback.format_exc())
|
||
# Training sollte hier nicht unbedingt abbrechen, aber ein Hinweis ist wichtig
|
||
|
||
X_train_imputed = imputer.transform(X_train)
|
||
X_test_imputed = imputer.transform(X_test)
|
||
X_train_imputed = pd.DataFrame(X_train_imputed, columns=feature_columns_ml) # feature_columns_ml verwenden
|
||
X_test_imputed = pd.DataFrame(X_test_imputed, columns=feature_columns_ml) # feature_columns_ml verwenden
|
||
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# +++ ANPASSUNG HIER: GridSearchCV mit Pipeline für SMOTE & RandomForest +
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# 4. Erstellen einer Pipeline und Definieren des Parameter-Grids
|
||
|
||
# Schritt 1: SMOTE für Klassen-Balancierung
|
||
# Schritt 2: RandomForestClassifier als Modell
|
||
pipeline = ImbPipeline([
|
||
('smote', SMOTE(random_state=42)),
|
||
('classifier', RandomForestClassifier(random_state=42, n_jobs=-1)) # class_weight nicht nötig bei SMOTE
|
||
])
|
||
|
||
# Definieren der Hyperparameter, die getestet werden sollen.
|
||
# WICHTIG: Die Parameternamen müssen mit dem Namen des Schritts in der Pipeline beginnen (z.B. 'classifier__...')
|
||
param_grid = {
|
||
'classifier__n_estimators': [200, 300], # Anzahl der Bäume
|
||
'classifier__max_depth': [10, 20, None], # Maximale Tiefe der Bäume (None = unbegrenzt)
|
||
'classifier__min_samples_split': [2, 5], # Mindestanzahl Samples für einen Split
|
||
'classifier__min_samples_leaf': [1, 2] # Mindestanzahl Samples in einem Blatt
|
||
}
|
||
# HINWEIS: Dies sind 2 * 3 * 2 * 2 = 24 Kombinationen.
|
||
# Mit cv=3 (siehe unten) werden 24 * 3 = 72 Modelle trainiert. Dies kann dauern!
|
||
# Für einen schnellen Test können Sie die Anzahl der Optionen reduzieren.
|
||
|
||
# Initialisieren von GridSearchCV
|
||
# cv=3 bedeutet 3-fache Kreuzvalidierung.
|
||
# scoring='accuracy' bedeutet, dass die beste Kombination anhand der Genauigkeit ausgewählt wird.
|
||
# verbose=2 gibt detaillierte Log-Ausgaben während der Suche.
|
||
grid_search = GridSearchCV(estimator=pipeline, param_grid=param_grid, cv=3, scoring='accuracy', verbose=2, n_jobs=-1)
|
||
|
||
self.logger.info("Starte Hyperparameter-Tuning mit GridSearchCV...")
|
||
start_fit_time = time.time()
|
||
# Fitte das Grid auf den (noch nicht resampleten) Trainingsdaten.
|
||
# Die Pipeline kümmert sich intern darum, dass SMOTE nur auf die Trainings-Folds angewendet wird.
|
||
grid_search.fit(X_train_imputed, y_train)
|
||
end_fit_time = time.time()
|
||
self.logger.info(f"GridSearchCV-Suche abgeschlossen. Dauer: {end_fit_time - start_fit_time:.2f} Sekunden.")
|
||
|
||
# Beste Parameter und bestes Modell ausgeben und speichern
|
||
self.logger.info(f"Beste gefundene Parameter: {grid_search.best_params_}")
|
||
self.logger.info(f"Beste Cross-Validation Accuracy: {grid_search.best_score_:.4f}")
|
||
|
||
# Das beste Modell ist das, das mit den besten Parametern auf den *gesamten* Trainingsdaten trainiert wurde.
|
||
best_classifier = grid_search.best_estimator_
|
||
|
||
# Modell speichern
|
||
self.model = best_classifier # Zuweisung des besten gefundenen Modells
|
||
try:
|
||
model_dir = os.path.dirname(model_out)
|
||
if model_dir and not os.path.exists(model_dir):
|
||
os.makedirs(model_dir, exist_ok=True)
|
||
with open(model_out, 'wb') as f:
|
||
pickle.dump(best_classifier, f) # Das beste Modell speichern
|
||
self.logger.info(f"Bestes RandomForest Modell erfolgreich gespeichert in '{model_out}'.")
|
||
except Exception as e:
|
||
self.logger.error(f"FEHLER beim Speichern des besten Modells in '{model_out}': {e}")
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# +++ ENDE ANPASSUNG ++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
|
||
|
||
# Feature-Liste speichern (bleibt unverändert)
|
||
self._expected_features = feature_columns_ml
|
||
try:
|
||
# Wir verwenden die .classes_ vom besten gefundenen Modell
|
||
patterns_data = {"feature_columns": self._expected_features, "target_classes": list(best_classifier.classes_)} # << KORRIGIERT
|
||
patterns_dir = os.path.dirname(patterns_out)
|
||
if patterns_dir and not os.path.exists(patterns_dir):
|
||
os.makedirs(patterns_dir, exist_ok=True)
|
||
with open(patterns_out, 'w', encoding='utf-8') as f:
|
||
json.dump(patterns_data, f, indent=4, ensure_ascii=False)
|
||
self.logger.info(f"Erwartete Feature-Spalten und Klassen erfolgreich gespeichert in '{patterns_out}'.")
|
||
except Exception as e:
|
||
self.logger.error(f"FEHLER beim Speichern der Feature-Spalten in '{patterns_out}': {e}")
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
# +++ ENDE FEATURE-LISTEN SPEICHERUNG +++++++++++++++++++++++++++++++++++
|
||
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
|
||
# 5. Evaluation (Optional, aber empfohlen, um die Modellleistung zu bewerten)
|
||
self.logger.info("Starte Evaluation des besten Modells auf dem ungesehenen Testset...")
|
||
y_pred = best_classifier.predict(X_test_imputed) # << KORRIGIERT
|
||
accuracy = accuracy_score(y_test, y_pred)
|
||
self.logger.info(f"Finale Modell Genauigkeit auf dem Testset: {accuracy:.4f}")
|
||
|
||
class_report_labels = list(best_classifier.classes_) # << KORRIGIERT
|
||
class_report = classification_report(y_test, y_pred, zero_division=0, labels=class_report_labels, target_names=[str(c) for c in class_report_labels])
|
||
self.logger.info(f"Klassifikationsbericht auf dem Testset:\n{class_report}")
|
||
|
||
cm = confusion_matrix(y_test, y_pred, labels=class_report_labels)
|
||
self.logger.info(f"Konfusionsmatrix auf dem Testset (Zeilen=Wahr, Spalten=Vorhersage):\n{cm}")
|
||
|
||
# Block für Feature Importance
|
||
try:
|
||
# Greife auf den Schritt 'classifier' in der Pipeline zu, um das finale Modell zu bekommen
|
||
final_rf_model = best_classifier.named_steps['classifier']
|
||
self.logger.info("Feature Importance des besten Modells (Top 15):")
|
||
importances = final_rf_model.feature_importances_ # << KORRIGIERT
|
||
feature_importance_df = pd.DataFrame({
|
||
'Feature': feature_columns_ml,
|
||
'Importance': importances
|
||
}).sort_values(by='Importance', ascending=False)
|
||
|
||
self.logger.info(f"\n{feature_importance_df.head(15).to_string(index=False)}")
|
||
except Exception as e_feat_imp:
|
||
self.logger.warning(f"FEHLER beim Berechnen/Anzeigen der Feature Importance: {e_feat_imp}")
|
||
|
||
self.logger.info("Modelltraining und -evaluation abgeschlossen.")
|
||
|
||
# ==============================================================================
|
||
# Ende DataProcessor Klasse Utility: ML Prep & Training Block
|
||
# ==============================================================================
|
||
|
||
# ==========================================================================
|
||
# === Utility Methods (Other Specific Tasks) ===============================
|
||
# ==========================================================================
|
||
|
||
# --- Methode fuer experimentelle Website Details ---
|
||
# Diese Methode extrahiert Details von Websites fuer Zeilen mit 'x'.
|
||
# Basierend auf process_website_details_for_marked_rows aus Teil 12.
|
||
# Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter.
|
||
# Nutzt globale Helfer: COLUMN_MAP (Block 1), logger,
|
||
# scrape_website_details (Block 13).
|
||
# Nutzt die uebergeordnete sheet_handler Instanz (Block 14).
|
||
def process_website_details(self, start_sheet_row=None, end_sheet_row=None, limit=None):
|
||
"""
|
||
EXPERIMENTELL: Extrahiert Website-Details fuer Zeilen, die in Spalte A mit 'x' markiert sind.
|
||
Schreibt die Details in eine definierte Spalte (Website Details oder AR als Fallback).
|
||
Loescht NICHT das 'x'-Flag.
|
||
|
||
Args:
|
||
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (ab Zeile 7).
|
||
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
|
||
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht uebersprungener) Zeilen. Defaults to None (Unbegrenzt).
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
# Logge den Start des Modus auf Warning, da es experimentell ist.
|
||
self.logger.warning(f"Starte Modus (EXPERIMENTELL): Website Detail Extraction fuer Zeilen mit 'x' in Spalte A. Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT
|
||
self.logger.warning("Hinweis: Dieser Modus nutzt die globale Funktion 'scrape_website_details' (Block 13), deren Implementierung je nach Zielwebsites angepasst werden muss.") # <<< GEÄNDERT
|
||
|
||
|
||
# --- Daten laden ---
|
||
# Laden Sie Daten neu. Kein automatischer Startindex-Check noetig hier,
|
||
# da wir explizit nach dem 'x'-Flag suchen.
|
||
# Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2).
|
||
if not self.sheet_handler.load_data():
|
||
self.logger.error("Fehler beim Laden der Daten fuer Website Details Extraction.") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn das Laden fehlschlaegt
|
||
|
||
|
||
# Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler.
|
||
all_data = self.sheet_handler.get_all_data_with_headers();
|
||
# Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14).
|
||
header_rows = self.sheet_handler._header_rows;
|
||
total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet
|
||
|
||
|
||
# Standard Startzeile, wenn nicht manuell gesetzt
|
||
if start_sheet_row is None: start_sheet_row = header_rows + 1 # Standardmaessig ab erster Datenzeile (Zeile nach Headern)
|
||
|
||
# Berechne Endzeile, wenn nicht manuell gesetzt
|
||
if end_sheet_row is None: end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
|
||
|
||
|
||
# Logge den Suchbereich fuer das 'x'-Flag
|
||
self.logger.info(f"Suchbereich fuer 'x'-Flag: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT
|
||
|
||
# Pruefe, ob der Bereich gueltig ist
|
||
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
|
||
self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn der Bereich leer ist
|
||
|
||
|
||
# --- Indizes und Buchstaben ---
|
||
# Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind
|
||
required_keys = ["ReEval Flag", "CRM Website", "CRM Name"] # A, D, B
|
||
# Erstellen Sie ein Dictionary mit Schluesseln und Indizes
|
||
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
|
||
|
||
# Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden
|
||
if None in col_indices.values():
|
||
missing = [k for k, v in col_indices.items() if v is None]
|
||
self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_website_details: {missing}. Breche ab.") # <<< GEÄNDERT
|
||
return # Beende die Methode bei kritischem Fehler
|
||
|
||
# Ermitteln Sie die Indizes
|
||
reeval_col_idx = col_indices["ReEval Flag"] # A
|
||
website_col_idx = col_indices["CRM Website"] # D
|
||
|
||
# Bestimme die ZIELSPALTE fuer die Details (Website Details ODER AR als Fallback)
|
||
details_col_idx = COLUMN_MAP.get("Website Details") # Versuche zuerst die dedizierte Spalte (Block 1 Column Map)
|
||
details_col_key_for_logging = "Website Details" # Name fuer Logging
|
||
# Wenn die dedizierte Spalte nicht gefunden wurde
|
||
if details_col_idx is None:
|
||
# Fallback auf 'Website Rohtext' (AR)
|
||
details_col_idx = COLUMN_MAP.get("Website Rohtext") # Block 1 Column Map
|
||
details_col_key_for_logging = "Website Rohtext"
|
||
# Pruefen Sie, ob der Fallback-Schluessel gefunden wurde
|
||
if details_col_idx is None:
|
||
self.logger.critical("FEHLER: Weder 'Website Details' noch 'Website Rohtext' Spaltenindex in COLUMN_MAP gefunden.") # <<< GEÄNDERT
|
||
return # Beende die Methode bei kritischem Fehler
|
||
self.logger.warning(f"Keine Spalte 'Website Details' in COLUMN_MAP, nutze '{details_col_key_for_logging}' ({self.sheet_handler._get_col_letter(details_col_idx+1)}) als Fallback.") # <<< GEÄNDERT
|
||
else:
|
||
# Logge die Verwendung der dedizierten Spalte
|
||
self.logger.info(f"Nutze Spalte '{details_col_key_for_logging}' ({self.sheet_handler._get_col_letter(details_col_idx+1)}) fuer Website Details.") # <<< GEÄNDERT
|
||
|
||
|
||
# Ermitteln Sie den Spaltenbuchstaben der Zielspalte (nutzt interne Helfer _get_col_letter Block 14)
|
||
details_col_letter = self.sheet_handler._get_col_letter(details_col_idx + 1)
|
||
|
||
|
||
# --- Verarbeitung ---
|
||
# Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1).
|
||
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)
|
||
|
||
|
||
all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts)
|
||
|
||
|
||
processed_count = 0 # Zaehlt Zeilen, die fuer die Verarbeitung in Frage kommen und in den Batch aufgenommen werden (im Rahmen des Limits).
|
||
skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (nicht markiert oder fehlende URL).
|
||
|
||
|
||
# Iteriere durch die Datenzeilen im definierten Bereich (1-basierte Sheet-Zeilennummer)
|
||
for i in range(start_sheet_row, end_sheet_row + 1):
|
||
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
|
||
# Pruefen Sie, ob das Ende des Sheets erreicht wurde
|
||
if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht
|
||
|
||
|
||
row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile
|
||
|
||
|
||
# Stellen Sie sicher, dass die Zeile nicht leer ist
|
||
if not any(cell and isinstance(cell, str) and cell.strip() for cell in row):
|
||
#self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug
|
||
skipped_count += 1 # Zaehlen als uebersprungene Zeile
|
||
continue # Springe zur naechsten Zeile
|
||
|
||
|
||
# --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
|
||
# Kriterium: Zeile ist mit 'x' in Spalte A (ReEval Flag) markiert.
|
||
# UND Website URL (D) ist vorhanden und gueltig aussehend.
|
||
|
||
# Holen Sie den Wert aus Spalte A (ReEval Flag) (nutzt interne Helfer _get_cell_value_safe)
|
||
cell_a_value = self._get_cell_value_safe(row, "ReEval Flag").strip().lower() # Block 1 Column Map
|
||
# Pruefen Sie, ob die Zelle mit 'x' markiert ist.
|
||
is_marked_for_processing = cell_a_value == "x"
|
||
|
||
# Wenn die Zeile nicht mit 'x' markiert ist, ueberspringen
|
||
if not is_marked_for_processing:
|
||
skipped_count += 1 # Zaehlen als uebersprungene Zeile
|
||
continue # Springe zur naechsten Zeile
|
||
|
||
|
||
# Holen Sie den Wert aus Spalte D (CRM Website) (nutzt interne Helfer _get_cell_value_safe)
|
||
website_url = self._get_cell_value_safe(row, "CRM Website").strip() # Block 1 Column Map
|
||
# Pruefen Sie, ob die Website URL (D) vorhanden und gueltig aussehend ist.
|
||
website_url_is_valid_looking = website_url and isinstance(website_url, str) and website_url.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"] # Fuege "http:" hinzu basierend auf Log
|
||
|
||
|
||
# Verarbeitung ist noetig, wenn die Zeile mit 'x' markiert ist UND die Website URL gueltig ist.
|
||
processing_needed_for_row = is_marked_for_processing and website_url_is_valid_looking
|
||
|
||
|
||
# Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
|
||
log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
|
||
if log_check:
|
||
company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map
|
||
self.logger.debug(f"Zeile {i} ({company_name[:50]}... Website Details Check): A='x'? {is_marked_for_processing}, D gueltig? {website_url_is_valid_looking}. Benoetigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT
|
||
|
||
|
||
# Wenn die Verarbeitung fuer diese Zeile nicht noetig ist (trotz 'x' fehlte die URL)
|
||
if not processing_needed_for_row:
|
||
skipped_count += 1 # Zaehlen als uebersprungene Zeile
|
||
# Optionale Behandlung: Wenn mit 'x' markiert, aber URL fehlt, was tun?
|
||
# Derzeit wird sie uebersprungen. Ggf. Fehler in Spalte notieren?
|
||
continue # Springe zur naechsten Zeile
|
||
|
||
|
||
# --- Wenn Verarbeitung noetig: Fuehre Details-Extraktion aus ---
|
||
processed_count += 1 # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im Rahmen des Limits zaehlen)
|
||
|
||
# Pruefe das Limit fuer verarbeitete Zeilen
|
||
if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit:
|
||
# Wenn das Limit erreicht ist und es ein positives Limit gibt
|
||
self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_website_details erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
|
||
break # Brich die Schleife ab
|
||
|
||
|
||
self.logger.info(f"Zeile {i}: Extrahiere Website Details von {website_url[:100]}...") # <<< GEÄNDERT (war selflogger)
|
||
|
||
|
||
details = "FEHLER: Funktion 'scrape_website_details' nicht verfuegbar" # Default Fehler, falls die Funktion nicht existiert (Sollte nicht passieren, wenn Block 13 korrekt ist)
|
||
|
||
try:
|
||
# Rufe die globale Funktion scrape_website_details auf (Block 13).
|
||
# scrape_website_details ist mit retry_on_failure dekoriert (Block 2).
|
||
# Wenn scrape_website_details fehlschlaegt, wirft sie eine Exception oder gibt einen Fehlerwert zurueck.
|
||
details = scrape_website_details(website_url) # <<< Ruft globale Funktion (Block 13)
|
||
|
||
# Wenn die Funktion einen Fehler geloggt hat und einen Fehlerstring im Ergebnis zurueckgibt,
|
||
# wird dies in der 'details' Variable gespeichert.
|
||
if isinstance(details, str) and (details.startswith("k.A. (Fehler") or details.startswith("FEHLER:")):
|
||
# Fehler wurde bereits in scrape_website_details geloggt.
|
||
pass # Details enthaelt bereits den Fehlerstring.
|
||
|
||
elif not isinstance(details, str) or not details.strip():
|
||
# Wenn die Funktion keinen String oder einen leeren String zurueckgibt.
|
||
details = "k.A. (Extraktion leer oder ungueltig)" # Standard-Fehlerwert
|
||
|
||
|
||
except NameError:
|
||
# Dieser Fehler sollte nicht auftreten, wenn scrape_website_details in Block 13 ist.
|
||
self.logger.critical("FEHLER: Funktion 'scrape_website_details' ist nicht definiert! Kann Details nicht extrahieren.") # <<< GEÄNDERT
|
||
# Logge den Traceback.
|
||
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
|
||
details = "FEHLER: Funktion nicht definiert" # Setze spezifischen Fehlerwert
|
||
|
||
except Exception as e_detail:
|
||
# Fange andere unerwartete Fehler ab, die nicht von scrape_website_details behandelt wurden.
|
||
self.logger.exception(f"Unerwarteter Fehler bei scrape_website_details fuer {website_url[:100]}...: {type(e_detail).__name__} - {e_detail}") # <<< GEÄNDERT
|
||
details = f"k.A. (Unerwarteter Fehler: {str(e_detail)[:100]}...)" # Signalisiert Fehler (gekuerzt)
|
||
|
||
|
||
# Fuege Update fuer die Details-Spalte hinzu (nutzt interne Helfer _get_col_letter Block 14)
|
||
# Stellen Sie sicher, dass der Wert ein String ist.
|
||
updates_for_row = [] # Lokale Liste fuer Updates dieser Zeile
|
||
updates_for_row.append({'range': f'{details_col_letter}{i}', 'values': [[str(details)]]}) # Block 1 Column Map
|
||
self.logger.debug(f"Zeile {i}: Details extrahiert und zum Update fuer Spalte {details_col_key_for_logging} ({details_col_letter}{i}) hinzugefuegt.") # <<< GEÄNDERT
|
||
|
||
|
||
# Sammle die Updates fuer diese Zeile in der globalen Liste all_sheet_updates.
|
||
all_sheet_updates.extend(updates_for_row)
|
||
|
||
|
||
# Sende gesammelte Sheet Updates wenn das Update-Batch-Limit erreicht ist.
|
||
# update_batch_row_limit wird aus Config geholt (Block 1).
|
||
# Updates pro Zeile ist 1 in diesem Modus. Anzahl der Zeilen = len(all_sheet_updates).
|
||
if len(all_sheet_updates) >= update_batch_row_limit:
|
||
self.logger.debug(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
|
||
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
|
||
# Wenn es fehlschlaegt, wird es intern geloggt.
|
||
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
|
||
if success:
|
||
self.logger.info(f" Sheet-Update fuer {len(all_sheet_updates)} Zellen erfolgreich.") # <<< GEÄNDERT
|
||
# Der Fehlerfall wird von batch_update_cells geloggt
|
||
|
||
# Leere die gesammelten Updates nach dem Senden.
|
||
all_sheet_updates = []
|
||
|
||
|
||
# Kleine Pause nach jeder Extraktion (nutzt Config Block 1).
|
||
# Dieser Modus macht API calls (ueber scrape_website_details und dessen Helfer), also Pause einbauen.
|
||
pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.2
|
||
#self.logger.debug(f"Warte {pause_duration:.2f}s nach Extraktion...") # Zu viel Laerm im Debug
|
||
time.sleep(pause_duration)
|
||
|
||
|
||
# --- Finale Sheet Updates senden ---
|
||
# Sende alle verbleibenden gesammelten Updates in einem letzten Batch-Update.
|
||
if all_sheet_updates:
|
||
self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
|
||
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
|
||
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
|
||
if success:
|
||
self.logger.info(f"FINALES Sheet-Update erfolgreich.") # <<< GEÄNDERT
|
||
# Der Fehlerfall wird von batch_update_cells geloggt
|
||
|
||
|
||
# Logge den Abschluss des Modus
|
||
self.logger.info(f"Modus 'website_details' abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {skipped_count} Zeilen uebersprungen.") # <<< GEÄNDERT
|
||
# Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt.
|
||
|
||
|
||
# --- Methode zum Verarbeiten von Wiki-Updates basierend auf ChatGPT Vorschlaegen ---
|
||
# Diese Methode verarbeitet Zeilen, in denen S gesetzt ist (nicht in Endzustand),
|
||
# prueft ob U eine valide und andere Wiki-URL ist und fuehrt entsprechende Updates durch.
|
||
# Basierend auf process_wiki_updates_from_chatgpt aus Teil 4.
|
||
# Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter.
|
||
# Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), time,
|
||
# is_valid_wikipedia_article_url (Block 12).
|
||
# Nutzt die uebergeordnete sheet_handler Instanz (Block 14).
|
||
def process_wiki_updates_from_chatgpt(self, start_sheet_row=None, end_sheet_row=None, limit=None):
|
||
"""
|
||
Identifiziert Zeilen, in denen Status S gesetzt ist, aber NICHT auf einem Endzustand
|
||
(OK, X (UPDATED/COPIED/INVALID)), prueft ob U eine *valide* und *andere* Wiki-URL ist.
|
||
- Wenn ja: Kopiert U->M, markiert S='X (URL Copied)', U='URL uebernommen', loescht
|
||
abhaengige Wiki-Spalten (N-V, AN, AO, AP, AX), setzt ReEval-Flag A='x'.
|
||
- Wenn nein (U keine URL, U==M, oder U ungueltig): LOESCHT den Inhalt von U und
|
||
markiert S als 'X (Invalid Suggestion)'.
|
||
Verarbeitet maximal limit Zeilen.
|
||
|
||
Args:
|
||
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (ab Zeile 7).
|
||
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
|
||
limit (int, optional): Maximale Anzahl ZU PRUEFENDER Zeilen. Defaults to None (Unbegrenzt).
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
# Logge die Konfiguration des Modus
|
||
self.logger.info(f"Starte Modus 'wiki_updates_from_chatgpt' (S, U, M, N-V, AN, AO, AX, AP, A). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT
|
||
|
||
|
||
# --- Daten laden ---
|
||
# Laden Sie Daten neu. Kein automatischer Startindex-Check noetig hier,
|
||
# da wir nach Status S suchen.
|
||
# Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2).
|
||
if not self.sheet_handler.load_data():
|
||
self.logger.error("Fehler beim Laden der Daten fuer Wiki Updates.") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn das Laden fehlschlaegt
|
||
|
||
|
||
# Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler.
|
||
all_data = self.sheet_handler.get_all_data_with_headers()
|
||
# Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14).
|
||
header_rows = self.sheet_handler._header_rows
|
||
total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet
|
||
|
||
|
||
# Standard Startzeile, wenn nicht manuell gesetzt
|
||
if start_sheet_row is None: start_sheet_row = header_rows + 1 # Standardmaessig ab erster Datenzeile (Zeile nach Headern)
|
||
|
||
# Berechne Endzeile, wenn nicht manuell gesetzt
|
||
if end_sheet_row is None: end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
|
||
|
||
|
||
# Logge den Suchbereich fuer Status S
|
||
self.logger.info(f"Suchbereich fuer Status S: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT
|
||
|
||
# Pruefe, ob der Bereich gueltig ist
|
||
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
|
||
self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn der Bereich leer ist
|
||
|
||
|
||
# --- Indizes und Buchstaben ---
|
||
# Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind
|
||
required_keys = [
|
||
"Chat Wiki Konsistenzpruefung", "Chat Vorschlag Wiki Artikel", "Wiki URL", # S, U, M (Pruefkriterien / Daten)
|
||
"Wikipedia Timestamp", "Wiki Verif. Timestamp", "Timestamp letzte Pruefung", "Version", # AN, AX, AO, AP (Spalten zum Loeschen)
|
||
"ReEval Flag", # A (ReEval Flag setzen)
|
||
"Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", # N-R (Spalten zum Loeschen)
|
||
"Chat Begruendung Wiki Inkonsistenz", "Begruendung bei Abweichung", # T, V (Spalten zum Loeschen)
|
||
# AY (SerpAPI Wiki Search Timestamp) wird ebenfalls geleert, da abhaengig von M.
|
||
"SerpAPI Wiki Search Timestamp" # AY (Spalte zum Leeren)
|
||
]
|
||
# Erstellen Sie ein Dictionary mit Schluesseln und Indizes
|
||
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
|
||
|
||
# Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden
|
||
if None in col_indices.values():
|
||
missing = [k for k, v in col_indices.items() if v is None]
|
||
self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_wiki_updates_from_chatgpt: {missing}. Breche ab.") # <<< GEÄNDERT
|
||
return # Beende die Methode bei kritischem Fehler
|
||
|
||
|
||
# Ermitteln Sie die Spaltenbuchstaben fuer Updates/Leerung (nutzt interne Helfer _get_col_letter Block 14)
|
||
s_letter = self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzpruefung"] + 1) # Status S
|
||
u_letter = self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"] + 1) # Vorschlag U
|
||
m_letter = self.sheet_handler._get_col_letter(col_indices["Wiki URL"] + 1) # Wiki URL M
|
||
a_letter = self.sheet_handler._get_col_letter(col_indices["ReEval Flag"] + 1) # ReEval Flag A
|
||
|
||
# Spalten N-V leeren.
|
||
# N ist Wiki Absatz, V ist Begruendung bei Abweichung.
|
||
n_idx = col_indices["Wiki Absatz"]
|
||
v_idx = col_indices["Begruendung bei Abweichung"]
|
||
# Erstellen Sie den Bereichsnamen (z.B. "N:V")
|
||
n_letter = self.sheet_handler._get_col_letter(n_idx + 1)
|
||
v_letter = self.sheet_handler._get_col_letter(v_idx + 1)
|
||
nv_range_letter = f'{n_letter}:{v_letter}' # z.B. N:V
|
||
# Erstellen Sie eine Liste von leeren Strings fuer diesen Bereich
|
||
empty_nv_values = [''] * (v_idx - n_idx + 1) # Anzahl der Spalten = V_Index - N_Index + 1
|
||
|
||
|
||
# Timestamps AN, AO, AX, AP, AY leeren.
|
||
an_letter = self.sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"] + 1) # AN (Wiki Extraction TS)
|
||
ao_letter = self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Pruefung"] + 1) # AO (Chat Evaluation TS)
|
||
ap_letter = self.sheet_handler._get_col_letter(col_indices["Version"] + 1) # AP (Version)
|
||
ax_letter = self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"] + 1) # AX (Wiki Verif. TS)
|
||
ay_letter = self.sheet_handler._get_col_letter(col_indices["SerpAPI Wiki Search Timestamp"] + 1) # AY (SerpAPI Wiki TS)
|
||
|
||
|
||
# --- Verarbeitung ---
|
||
# Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1).
|
||
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)
|
||
|
||
|
||
all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts)
|
||
|
||
|
||
processed_rows_count = 0 # Zaehlt Zeilen, die geprueft werden (im Rahmen des Limits zaehlen).
|
||
skipped_count = 0 # Zaehlt Zeilen, die uebersprungen werden (Status S im Endzustand etc.).
|
||
updated_url_count = 0 # Zaehlt Zeilen, wo U -> M kopiert wurde.
|
||
cleared_suggestion_count = 0 # Zaehlt Zeilen, wo Vorschlag U geloescht wurde.
|
||
|
||
|
||
# Iteriere durch die Datenzeilen im definierten Bereich (1-basierte Sheet-Zeilennummer)
|
||
for i in range(start_sheet_row, end_sheet_row + 1):
|
||
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
|
||
# Pruefen Sie, ob das Ende des Sheets erreicht wurde
|
||
if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht
|
||
|
||
|
||
row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile
|
||
|
||
|
||
# Stellen Sie sicher, dass die Zeile nicht leer ist
|
||
if not any(cell and isinstance(cell, str) and cell.strip() for cell in row):
|
||
#self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug
|
||
skipped_count += 1 # Zaehlen als uebersprungene Zeile
|
||
continue # Springe zur naechsten Zeile
|
||
|
||
|
||
# --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
|
||
# Kriterium: Status S ist gesetzt (nicht leer) UND NICHT einer der Endzustaende.
|
||
# Endzustaende: "OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)"
|
||
|
||
# Holen Sie den Wert aus Spalte S (Chat Wiki Konsistenzpruefung) (nutzt interne Helfer _get_cell_value_safe)
|
||
s_value = self._get_cell_value_safe(row, "Chat Wiki Konsistenzpruefung").strip() # Block 1 Column Map
|
||
s_value_upper = s_value.upper()
|
||
|
||
# Definieren Sie die Endzustaende (Grossbuchstaben)
|
||
s_end_states = ["OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)"]
|
||
|
||
# Verarbeitung ist noetig, wenn S nicht leer ist UND S NICHT im Endzustand ist.
|
||
processing_needed_for_row = s_value and s_value_upper not in s_end_states
|
||
|
||
|
||
# Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
|
||
log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
|
||
if log_check:
|
||
self.logger.debug(f"Zeile {i} (Wiki Update Check): Status S='{s_value}'. Benoetigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT
|
||
|
||
|
||
# Wenn die Verarbeitung fuer diese Zeile nicht noetig ist
|
||
if not processing_needed_for_row:
|
||
skipped_count += 1 # Zaehlen als uebersprungene Zeile
|
||
continue # Springe zur naechsten Zeile
|
||
|
||
|
||
# --- Wenn Verarbeitung noetig: Pruefe Vorschlag U und handle ---
|
||
processed_rows_count += 1 # Zaehle die Zeile, die geprueft wird (im Rahmen des Limits zaehlen).
|
||
|
||
# Pruefe das Limit fuer verarbeitete Zeilen
|
||
if limit is not None and isinstance(limit, int) and limit > 0 and processed_rows_count > limit:
|
||
# Wenn das Limit erreicht ist und es ein positives Limit gibt
|
||
self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_wiki_updates_from_chatgpt erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
|
||
break # Brich die Schleife ab
|
||
|
||
|
||
# Holen Sie die Werte aus Spalte U (Chat Vorschlag Wiki Artikel) und M (Wiki URL) (nutzt interne Helfer _get_cell_value_safe)
|
||
vorschlag_u = self._get_cell_value_safe(row, "Chat Vorschlag Wiki Artikel").strip() # Block 1 Column Map
|
||
url_m = self._get_cell_value_safe(row, "Wiki URL").strip() # Block 1 Column Map
|
||
|
||
|
||
self.logger.info(f"Zeile {i}: Pruefe Wiki-Vorschlag U='{vorschlag_u[:100]}...' (aktuell M='{url_m[:100]}...')...") # <<< GEÄNDERT
|
||
|
||
is_update_candidate = False # Flag, ob U eine gueltige, neue URL ist, die uebernommen werden soll.
|
||
new_url = "" # Die URL, die ggf. in M kopiert wird.
|
||
|
||
|
||
# Kriterium 1: Ist Vorschlag U ueberhaupt ein String und sieht nach Wikipedia aus?
|
||
condition1_u_is_wiki_url = vorschlag_u and isinstance(vorschlag_u, str) and "wikipedia.org/wiki/" in vorschlag_u.lower() and vorschlag_u.lower().startswith(("http://", "https://")) # Check auf Schema hinzugefuegt
|
||
|
||
|
||
# Wenn der Vorschlag U wie eine Wikipedia-URL aussieht
|
||
if condition1_u_is_wiki_url:
|
||
new_url = vorschlag_u # Nehme den Vorschlag als potenzielle neue URL
|
||
# Kriterium 2: Unterscheidet sich der Vorschlag U von der aktuellen URL in M?
|
||
# Pruefe, ob die neue URL nicht identisch mit der aktuellen M-URL ist.
|
||
condition2_u_differs_m = new_url != url_m
|
||
|
||
# Wenn sich der Vorschlag U von der aktuellen M-URL unterscheidet
|
||
if condition2_u_differs_m:
|
||
self.logger.debug(f" -> Vorschlag U ({new_url[:100]}...) unterscheidet sich von M ({url_m[:100]}). Pruefe Validitaet...")
|
||
try:
|
||
# Nutze die globale Funktion 'is_valid_wikipedia_article_url' (definiert in Block 12)
|
||
# Diese Funktion ist bereits mit @retry_on_failure dekoriert.
|
||
condition3_u_is_valid = is_valid_wikipedia_article_url(new_url, lang=getattr(Config, 'LANG', 'de')) # lang Argument hinzugefügt für Konsistenz
|
||
|
||
if condition3_u_is_valid:
|
||
is_update_candidate = True
|
||
self.logger.debug(f" -> URL '{new_url[:100]}...' ist ein VALIDER Artikel laut API Check.") # <<< GEÄNDERT
|
||
else:
|
||
# Wenn die vorgeschlagene URL nicht valide ist
|
||
self.logger.debug(f" -> URL '{new_url[:100]}...' ist KEIN valider Artikel laut API Check.") # <<< GEÄNDERT
|
||
|
||
except Exception as e_validity_check:
|
||
# Wenn die Validierungsfunktion eine Exception wirft (nach Retries)
|
||
# Der Fehler wird bereits vom retry_on_failure Decorator geloggt.
|
||
self.logger.error(f"FEHLER bei Validitaetspruefung von Vorschlag U '{new_url[:100]}...': {e_validity_check}") # <<< GEÄNDERT
|
||
# Bei Fehler bleibt is_update_candidate False.
|
||
pass # Faert fort
|
||
|
||
|
||
else:
|
||
# Wenn der Vorschlag U identisch mit der aktuellen M-URL ist
|
||
self.logger.debug(f" -> Vorschlag U ist identisch mit URL M. Wird nicht uebernommen.") # <<< GEÄNDERT
|
||
|
||
else:
|
||
# Wenn der Vorschlag U nicht wie eine Wikipedia-URL aussieht
|
||
self.logger.debug(f" -> Vorschlag U ('{vorschlag_u[:100]}...') ist keine Wikipedia URL. Wird nicht uebernommen.") # <<< GEÄNDERT
|
||
|
||
|
||
# --- Verarbeitung des Kandidaten ODER Loeschen des ungueltigen Vorschlags ---
|
||
updates_for_row = [] # Lokale Liste fuer Updates DIESER Zeile
|
||
|
||
if is_update_candidate:
|
||
# Fall 1: Gueltiges Update durchfuehren (Vorschlag U wird in M kopiert)
|
||
self.logger.info(f"Zeile {i}: Update-Kandidat VALIDIERUNG ERFOLGREICH. Kopiere U->M, setze ReEval-Flag 'x', loesche abhaengige Spalten.") # <<< GEÄNDERT
|
||
updated_url_count += 1 # Zaehle die uebernommene URL
|
||
|
||
# Updates sammeln (M, S, U, N-V, AN, AO, AP, AX, AY, A) (nutzt interne Helfer _get_col_letter Block 14)
|
||
updates_for_row.append({'range': f'{m_letter}{i}', 'values': [[new_url]]}) # Setze die neue URL in Spalte M (Block 1 Column Map)
|
||
updates_for_row.append({'range': f'{s_letter}{i}', 'values': [["X (URL Copied)"]]}) # Setze Status S auf "X (URL Copied)" (Block 1 Column Map)
|
||
updates_for_row.append({'range': f'{u_letter}{i}', 'values': [["URL uebernommen"]]}) # Schreibe Info in Spalte U (Block 1 Column Map)
|
||
updates_for_row.append({'range': f'{a_letter}{i}', 'values': [["x"]]}) # Setze ReEval Flag (A) auf 'x' (Block 1 Column Map)
|
||
|
||
# Leere Spalten N-V.
|
||
# Fuege das Update zum Leeren des Bereichs V-Y hinzu, falls der Bereichsname ermittelt werden konnte.
|
||
if nv_range_letter: # Pruefe, ob der Bereichsname ermittelt werden konnte.
|
||
updates_for_row.append({'range': f'{n_letter}{i}:{v_letter}{i}', 'values': [empty_nv_values]}) # Block 1 Column Map, lokale Variable
|
||
else:
|
||
self.logger.warning(f"Konnte Spaltenbereich N-V ({n_letter}:{v_letter}) fuer Leerung nicht ermitteln fuer Zeile {i}. Leerung uebersprungen.") # <<< GEÄNDERT
|
||
|
||
|
||
# Leere Timestamps AN, AO, AP, AX, AY.
|
||
# Dies setzt die Zeile zurueck, damit andere Schritte sie spaeter bearbeiten.
|
||
updates_for_row.append({'range': f'{an_letter}{i}', 'values': [['']]}) # AN (Wiki Extraction TS) Block 1 Column Map
|
||
updates_for_row.append({'range': f'{ao_letter}{i}', 'values': [['']]}) # AO (Chat Evaluation TS) Block 1 Column Map
|
||
updates_for_row.append({'range': f'{ap_letter}{i}', 'values': [['']]}) # AP (Version) Block 1 Column Map
|
||
updates_for_row.append({'range': f'{ax_letter}{i}', 'values': [['']]}) # AX (Wiki Verif. TS) Block 1 Column Map
|
||
updates_for_row.append({'range': f'{ay_letter}{i}', 'values': [['']]}) # AY (SerpAPI Wiki TS) Block 1 Column Map
|
||
|
||
|
||
else:
|
||
# Fall 2: Ungueltigen Vorschlag loeschen/markieren
|
||
# Wenn der Vorschlag U nicht uebernommen wird (weil ungueltig oder identisch mit M).
|
||
self.logger.info(f"Zeile {i}: Vorschlag U ('{vorschlag_u[:100]}...') ist ungueltig/identisch. Loesche U und setze Status S auf 'X (Invalid Suggestion)'.") # <<< GEÄNDERT
|
||
cleared_suggestion_count += 1 # Zaehle den bereinigten Vorschlag
|
||
|
||
# Updates sammeln (S, U) (nutzt interne Helfer _get_col_letter Block 14)
|
||
updates_for_row.append({'range': f'{s_letter}{i}', 'values': [["X (Invalid Suggestion)"]]}) # Setze Status S auf "X (Invalid Suggestion)" (Block 1 Column Map)
|
||
updates_for_row.append({'range': f'{u_letter}{i}', 'values': [[""]]}) # Loesche den Vorschlag in Spalte U (Block 1 Column Map)
|
||
# KEIN ReEval-Flag (A) setzen in diesem Fall.
|
||
|
||
|
||
# Sammle die Updates fuer diese Zeile in der globalen Liste all_sheet_updates.
|
||
all_sheet_updates.extend(updates_for_row)
|
||
|
||
|
||
# Sende gesammelte Sheet Updates wenn das Update-Batch-Limit erreicht ist.
|
||
# update_batch_row_limit wird aus Config geholt (Block 1).
|
||
# Die Anzahl der Updates pro Zeile variiert stark (ca. 2 bei ungueltigem Vorschlag, ca. 10+ bei gueltigem).
|
||
# Pruefen Sie einfach die Laenge der gesammelten Liste.
|
||
if len(all_sheet_updates) >= update_batch_row_limit * 5: # Grobe Schaetzung: im Schnitt 5 Updates pro Zeile
|
||
self.logger.debug(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
|
||
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
|
||
# Wenn es fehlschlaegt, wird es intern geloggt.
|
||
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
|
||
if success:
|
||
self.logger.info(f" Sheet-Update fuer {len(all_sheet_updates)} Zellen erfolgreich.") # <<< GEÄNDERT
|
||
# Der Fehlerfall wird von batch_update_cells geloggt
|
||
|
||
# Leere die gesammelten Updates nach dem Senden.
|
||
all_sheet_updates = []
|
||
|
||
|
||
# Kleine Pause nach jeder geprueften Zeile (nutzt Config Block 1).
|
||
# Dieser Modus macht API calls (ueber is_valid_wikipedia_article_url), also Pause einbauen.
|
||
pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.2
|
||
#self.logger.debug(f"Warte {pause_duration:.2f}s nach Pruefung...") # Zu viel Laerm im Debug
|
||
time.sleep(pause_duration)
|
||
|
||
|
||
# --- Finale Sheet Updates senden ---
|
||
# Sende alle verbleibenden gesammelten Updates in einem letzten Batch-Update.
|
||
if all_sheet_updates:
|
||
self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
|
||
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
|
||
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
|
||
if success:
|
||
self.logger.info(f"FINALES Sheet-Update erfolgreich.") # <<< GEÄNDERT
|
||
# Der Fehlerfall wird von batch_update_cells geloggt
|
||
|
||
|
||
# Logge den Abschluss des Modus
|
||
self.logger.info(f"Modus 'wiki_updates_from_chatgpt' abgeschlossen. {processed_rows_count} Zeilen geprueft, {updated_url_count} URLs kopiert & fuer ReEval markiert, {cleared_suggestion_count} ungueltige Vorschlaege geloescht/markiert, {skipped_count} Zeilen uebersprungen.") # <<< GEÄNDERT
|
||
# Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt.
|
||
|
||
|
||
# --- Methode zur Re-Extraktion von Wiki-Daten bei fehlendem Timestamp AN ---
|
||
# Diese Methode identifiziert Zeilen mit M gefuellt und AN leer und fuehrt _process_single_row (Block 19) fuer diese aus.
|
||
# Nutzt interne Helfer: _get_cell_value_safe, _process_single_row.
|
||
# Nutzt globale Helfer: COLUMN_MAP (Block 1), logger.
|
||
# Nutzt die uebergeordnete sheet_handler Instanz (Block 14).
|
||
def process_wiki_reextract_missing_an(self, start_sheet_row=None, end_sheet_row=None, limit=None):
|
||
"""
|
||
Identifiziert Zeilen, bei denen eine Wiki URL (M) vorhanden ist, aber der
|
||
Wikipedia Timestamp (AN) fehlt. Fuehrt _process_single_row fuer diese Zeilen aus,
|
||
beschraenkt auf den 'wiki'-Schritt und mit force_reeval=True, um die Extraktion
|
||
erneut zu versuchen.
|
||
|
||
Args:
|
||
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AN).
|
||
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
|
||
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER Zeilen. Defaults to None (Unbegrenzt).
|
||
"""
|
||
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
|
||
# Logge die Konfiguration des Modus
|
||
self.logger.info(f"Starte Modus 'wiki_reextract_missing_an' (M gefuellt & AN leer). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT
|
||
|
||
|
||
# --- Daten laden und Startzeile ermitteln ---
|
||
# Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt.
|
||
# Dieser Modus sucht nach leeren AN mit gefuelltem M. Die automatische Startzeile
|
||
# basierend auf leeren AN ist ein guter Startpunkt.
|
||
if start_sheet_row is None:
|
||
self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AN...") # <<< GEÄNDERT
|
||
# Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AN (Block 1 Column Map).
|
||
# Standardmaessig ab Zeile 7
|
||
start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Wikipedia Timestamp", min_sheet_row=7)
|
||
|
||
# Wenn get_start_row_index -1 zurueckgibt (Fehler)
|
||
if start_data_index_no_header == -1:
|
||
self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche Modus ab.") # <<< GEÄNDERT
|
||
return # Beende die Methode
|
||
|
||
# Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten Daten-Index
|
||
start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut
|
||
self.logger.info(f"Automatisch ermittelte Startzeile (erste leere AN Zelle): {start_sheet_row}") # <<< GEÄNDERT
|
||
else:
|
||
# Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein.
|
||
# Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2).
|
||
if not self.sheet_handler.load_data():
|
||
self.logger.error("Fehler beim Laden der Daten fuer wiki_reextract_missing_an.") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn das Laden fehlschlaegt
|
||
|
||
|
||
# Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler.
|
||
all_data = self.sheet_handler.get_all_data_with_headers();
|
||
# Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14).
|
||
header_rows = self.sheet_handler._header_rows;
|
||
total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet
|
||
|
||
|
||
# Berechne Endzeile, wenn nicht manuell gesetzt
|
||
if end_sheet_row is None:
|
||
end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
|
||
|
||
|
||
# Logge den verarbeitungsbereich
|
||
self.logger.info(f"Suchbereich fuer M gefuellt & AN leer: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT
|
||
|
||
# Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen)
|
||
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
|
||
self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
|
||
return # Beende die Methode, wenn der Bereich leer ist
|
||
|
||
|
||
# --- Indizes ---
|
||
# Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind
|
||
required_keys = ["Wiki URL", "Wikipedia Timestamp", "CRM Name"] # M, AN, B (Pruefkriterien + Logging)
|
||
# Erstellen Sie ein Dictionary mit Schluesseln und Indizes
|
||
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
|
||
|
||
# Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden
|
||
if None in col_indices.values():
|
||
missing = [k for k, v in col_indices.items() if v is None]
|
||
self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer wiki_reextract_missing_an: {missing}. Breche ab.") # <<< GEÄNDERT
|
||
return # Beende die Methode bei kritischem Fehler
|
||
|
||
# Ermitteln Sie die Indizes
|
||
m_col_idx = col_indices["Wiki URL"]
|
||
an_col_idx = col_indices["Wikipedia Timestamp"]
|
||
|
||
|
||
# --- Verarbeitung ---
|
||
processed_count = 0 # Zaehlt Zeilen, die an _process_single_row uebergeben wurden (im Rahmen des Limits zaehlen).
|
||
skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden.
|
||
|
||
|
||
# Iteriere durch die Datenzeilen im definierten Bereich (1-basierte Sheet-Zeilennummer)
|
||
for i in range(start_sheet_row, end_sheet_row + 1):
|
||
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
|
||
# Pruefen Sie, ob das Ende des Sheets erreicht wurde
|
||
if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht
|
||
|
||
|
||
row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile
|
||
|
||
|
||
# Stellen Sie sicher, dass die Zeile nicht leer ist
|
||
if not any(cell and isinstance(cell, str) and cell.strip() for cell in row):
|
||
#self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug
|
||
skipped_count += 1 # Zaehlen als uebersprungene Zeile
|
||
continue # Springe zur naechsten Zeile
|
||
|
||
|
||
# --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
|
||
# Kriterium: Wiki URL (M) ist vorhanden und gueltig aussehend.
|
||
# UND Wikipedia Timestamp (AN) ist leer.
|
||
|
||
# Holen Sie die Werte aus den entsprechenden Spalten (nutzt interne Helfer _get_cell_value_safe)
|
||
m_value = self._get_cell_value_safe(row, "Wiki URL").strip() # Block 1 Column Map
|
||
an_value = self._get_cell_value_safe(row, "Wikipedia Timestamp").strip() # Block 1 Column Map
|
||
|
||
# Pruefen Sie, ob M gefuellt und gueltig aussieht.
|
||
is_m_valid_looking = m_value and isinstance(m_value, str) and "wikipedia.org/wiki/" in m_value.lower() and m_value.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"] # Fuege "http:" hinzu basierend auf Log
|
||
|
||
# Pruefen Sie, ob AN leer ist.
|
||
is_an_empty = not an_value
|
||
|
||
# Verarbeitung ist noetig, wenn M gueltig aussieht UND AN leer ist.
|
||
processing_needed_for_row = is_m_valid_looking and is_an_empty
|
||
|
||
|
||
# Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
|
||
log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row)
|
||
if log_check:
|
||
company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map
|
||
self.logger.debug(f"Zeile {i} ({company_name[:50]}... Wiki Re-extract Check): M ('{m_value[:50]}...') gueltig? {is_m_valid_looking}, AN leer? {is_an_empty}. Benötigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT
|
||
|
||
|
||
# Wenn die Verarbeitung fuer diese Zeile nicht noetig ist
|
||
if not processing_needed_for_row:
|
||
skipped_count += 1 # Zaehlen als uebersprungene Zeile
|
||
continue # Springe zur naechsten Zeile
|
||
|
||
|
||
# --- Wenn Verarbeitung noetig: Rufe _process_single_row auf ---
|
||
processed_count += 1 # Zaehle die Zeile, die an _process_single_row uebergeben wird (im Rahmen des Limits zaehlen)
|
||
|
||
# Pruefe das Limit fuer verarbeitete Zeilen
|
||
if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit:
|
||
# Wenn das Limit erreicht ist und es ein positives Limit gibt
|
||
self.logger.info(f"Verarbeitungslimit ({limit}) fuer wiki_reextract_missing_an erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
|
||
break # Brich die Schleife ab
|
||
|
||
|
||
self.logger.info(f"Zeile {i}: M gefuellt & AN leer. Versuche Wiki-Re-Extraktion ueber _process_single_row...") # <<< GEÄNDERT
|
||
|
||
try:
|
||
# RUFE _process_single_row AUF (Block 19).
|
||
# Mit steps_to_run={'wiki'} und force_reeval=True,
|
||
# damit nur der Wiki-Schritt ausgefuehrt wird und Timestamps ignoriert werden.
|
||
# Im Re-Extract Modus loeschen wir das 'x'-Flag NICHT automatisch.
|
||
self._process_single_row(
|
||
row_num_in_sheet = i,
|
||
row_data = row, # Uebergibt die aktuellen Rohdaten der Zeile
|
||
steps_to_run = {'wiki'}, # <<< NUR der Wiki-Schritt soll laufen
|
||
force_reeval = True, # <<< Erzwingt die Ausfuehrung des 'wiki' Schritts (ignoriert AN, S).
|
||
clear_x_flag = False # <<< 'x'-Flag wird in diesem Modus NICHT geloescht
|
||
)
|
||
# _process_single_row (Block 19) loggt intern den Abschluss und fuehrt das Sheet-Update durch.
|
||
|
||
except Exception as e_proc:
|
||
# Wenn _process_single_row einen Fehler wirft (nachdem interne Retries aufgaben),
|
||
# fangen wir ihn hier, loggen ihn und fahren mit der naechsten Zeile fort.
|
||
self.logger.exception(f"FEHLER bei Verarbeitung von Zeile {i} in wiki_reextract_missing_an: {e_proc}") # <<< GEÄNDERT
|
||
# Hier koennen Sie z.B. einen Fehlerindikator in eine spezielle Spalte im Sheet schreiben lassen.
|
||
# Dieses Update muesste dann separat oder im naechsten Lauf behandelt werden.
|
||
|
||
# _process_single_row beinhaltet bereits eine kleine Pause am Ende.
|
||
# Hier ist keine zusaetzliche Pause noetig, wenn _process_single_row erfolgreich war.
|
||
# Wenn _process_single_row eine Exception wirft, kann hier eine kurze Pause sinnvoll sein.
|
||
# time.sleep(0.1) # Optional: Kurze Pause bei Fehler nach Exception
|
||
|
||
|
||
# Logge den Abschluss des Modus
|
||
self.logger.info(f"Modus 'wiki_reextract_missing_an' abgeschlossen. {processed_count} Zeilen an _process_single_row uebergeben, {skipped_count} Zeilen uebersprungen.") # <<< GEÄNDERT
|
||
# Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt.
|
||
|
||
|
||
# ==============================================================================
|
||
# Ende DataProcessor Klasse Utility: Other Specific Tasks Block
|
||
# ==============================================================================
|
||
|
||
# ==============================================================================
|
||
# Ende DataProcessor Klasse
|
||
# ==============================================================================
|
||
|
||
# --- Ende der DataProcessor Klasse ---
|
||
# Ein pass statement, um die Klassendefinition abzuschliessen, falls keine weiteren Methoden folgen.
|
||
pass # <-- DIESES pass STATEMENT GEHOERT ZUM ENDE DER KLASSENDEFINITION
|
||
|
||
|
||
# ==============================================================================
|
||
# Hauptausfuehrungsblock & Globale Funktionen nach Klassen
|
||
# ==============================================================================
|
||
# Der naechste Block (Block 34) enthaelt die main Funktion und den Entry Point.
|
||
|
||
# ==============================================================================
|
||
# 6. MAIN FUNCTION (HAUPTEINSTIEGSPUNKT & UI DISPATCHER)
|
||
# ==============================================================================
|
||
|
||
# Der globale Root Logger wird in main() konfiguriert
|
||
# logger = logging.getLogger(__name__) # Diesen Logger gibt es schon, keine Neudefinition hier
|
||
|
||
def main():
|
||
"""
|
||
Haupteinstiegspunkt des Skripts.
|
||
Verarbeitet Kommandozeilen-Argumente, richtet Logging ein,
|
||
initialisiert Komponenten und dispatchet zu den passenden Modi.
|
||
"""
|
||
# WICHTIG: Globale Variable LOG_FILE wird benoetigt (Initialisierung Block 3)
|
||
global LOG_FILE
|
||
logger = logging.getLogger(__name__) # <<< JETZT AN DER RICHTIGEN STELLE
|
||
|
||
# --- Initial Logging Setup (Konfiguration von Level und Format) ---
|
||
# Diese Konfiguration wird wirksam, sobald die Handler hinzugefuegt werden.
|
||
# Standard-Logging Level festlegen (aus Config Block 1)
|
||
log_level = logging.DEBUG if getattr(Config, 'DEBUG', False) else logging.INFO
|
||
log_format = '%(asctime)s - %(levelname)-8s - %(name)-25s - %(message)s' # <<< DIESE ZEILE HINZUFÜGEN/KORRIGIEREN
|
||
|
||
# Root-Logger konfigurieren (mit Console Handler, File Handler wird spaeter hinzugefuegt)
|
||
# handlers=[] verhindert default Console Handler, wir fuegen ihn manuell hinzu fuer mehr Kontrolle
|
||
logging.basicConfig(level=log_level, format=log_format, handlers=[]) # log_format wird hier bereits verwendet
|
||
|
||
# Console Handler explizit hinzufuegen
|
||
console_handler = logging.StreamHandler()
|
||
console_handler.setLevel(log_level) # Nimm das globale Level
|
||
console_handler.setFormatter(logging.Formatter(log_format)) # Jetzt sollte log_format definiert sein
|
||
# Pruefen, ob nicht schon ein Console Handler vorhanden ist (z.B. bei wiederholten Aufrufen in Tests)
|
||
if not any(isinstance(h, logging.StreamHandler) for h in logging.getLogger('').handlers):
|
||
logging.getLogger('').addHandler(console_handler)
|
||
|
||
|
||
# Testnachricht (geht nur an Konsole, da File Handler noch fehlt)
|
||
logger.debug("DEBUG Logging initial konfiguriert (nur Konsole).")
|
||
logger.info("INFO Logging initial konfiguriert (nur Konsole).")
|
||
|
||
|
||
# --- Initialisierung (Argument Parser) ---
|
||
current_script_version = getattr(Config, 'VERSION', 'unknown') # Aus Config Block 1
|
||
|
||
parser = argparse.ArgumentParser(
|
||
description=f"Firmen-Datenanreicherungs-Skript {current_script_version}. Automatisiert Anreicherung und Validierung aus Google Sheets.",
|
||
formatter_class=argparse.RawTextHelpFormatter # Behaelt Formatierung im Help-Text
|
||
)
|
||
|
||
# Liste der gueltigen Modi - MUSS mit den elif-Zweigen unten uebereinstimmen!
|
||
# Kategorisiert fuer die Menue-Ausgabe
|
||
mode_categories = {
|
||
"Batch-Verarbeitung (Schritt-Optimiert)": [
|
||
"wiki_verify", # Uebereinstimmend mit process_verification_batch (Block 26)
|
||
"website_scraping", # Uebereinstimmend mit process_website_scraping_batch (Block 27)
|
||
"summarize_website", # Uebereinstimmend mit process_summarization_batch (Block 28)
|
||
"branch_eval", # Uebereinstimmend mit process_branch_batch (Block 29)
|
||
"suggest_parents",
|
||
],
|
||
"Sequentielle Verarbeitung (Zeilenweise)": [
|
||
"full_run", # Nutzt process_rows_sequentially (Block 24)
|
||
],
|
||
"Re-Evaluate Markierte Zeilen (Spalte A='x')": [
|
||
"reeval", # Nutzt process_reevaluation_rows (Block 25)
|
||
],
|
||
"Einzelne Dienstprogramme / Suchen": [
|
||
"find_wiki_serp", # Nutzt process_find_wiki_serp (Block 30)
|
||
"website_lookup", # Nutzt process_serp_website_lookup (Block 30)
|
||
"check_urls", # <<< NEUER MODUS HIER EINFÜGEN
|
||
"contacts", # Nutzt process_contact_search (Block 30)
|
||
"update_wiki_suggestions", # Nutzt process_wiki_updates_from_chatgpt (Block 32)
|
||
"wiki_reextract_missing_an", # Nutzt process_wiki_reextract_missing_an (Block 32)
|
||
"website_details", # EXPERIMENTELL - Nutzt process_website_details (Block 32)
|
||
"train_technician_model", # Nutzt train_technician_model (Block 31)
|
||
"alignment", # Nutzt globale alignment_demo (Block 14)
|
||
"reparatur_sitz",
|
||
"plausi_check_data" # NEUER MODUS HIER
|
||
],
|
||
"Kombinierte Laeufe (Vordefiniert)": [
|
||
"combined_all", # Definiert eine Sequenz von Batch-Modi
|
||
]
|
||
}
|
||
# Erstellen Sie eine flache Liste aller validen Modi fuer die Validierung
|
||
valid_modes = [mode for modes in mode_categories.values() for mode in modes]
|
||
|
||
|
||
# Dynamisch generieren des Help-Textes fuer den Modus
|
||
mode_help_text = "Betriebsmodus. Waehlen Sie einen der folgenden:\n"
|
||
for category, modes in mode_categories.items():
|
||
mode_help_text += f"\n{category}:\n"
|
||
for mode in modes:
|
||
mode_help_text += f" - {mode}\n"
|
||
|
||
parser.add_argument("--mode", type=str, help=mode_help_text)
|
||
# Hilfsargument fuer die CLI-basierte Modusauswahl (wenn --mode gesetzt ist)
|
||
parser.add_argument("-m", "--cli-mode", dest="mode", action="store_const", const=valid_modes[0] if valid_modes else None, help=argparse.SUPPRESS) # Unterdruecke in --help
|
||
|
||
parser.add_argument("--limit", type=int, help="Maximale Anzahl zu verarbeitender Zeilen in den meisten Modi (prueft Zeilen VOR Ueberspringung/Filterung).", default=None)
|
||
# start_sheet_row wird primaer fuer full_run verwendet, kann aber auch fuer Bereiche in Batch nuetzlich sein
|
||
parser.add_argument("--start_sheet_row", type=int, help="Startzeile im Sheet (1-basiert) fuer 'full_run' und einige Batch-Modi. Standard: Automatische Ermittlung basierend auf Timestamp.", default=None)
|
||
# end_sheet_row fuer Bereiche
|
||
parser.add_argument("--end_sheet_row", type=int, help="Endzeile im Sheet (1-basiert) fuer 'full_run' und einige Batch-Modi. Standard: Ende des Sheets.", default=None)
|
||
|
||
|
||
# Argument fuer den Re-Eval und Full-Run Modus zur Auswahl der Schritte
|
||
# Moegliche Werte fuer die Schritte: 'wiki', 'chat', 'web', 'ml_predict', etc. (entsprechend den step_type Schluesseln in _process_single_row Block 19)
|
||
# Default ist 'all' fuer alle Schritte, oder eine spezifische Liste
|
||
# Dies sind die Schluessel, die _process_single_row (Block 19) in steps_to_run Set erwartet.
|
||
valid_single_row_steps = ['wiki', 'chat', 'web', 'ml_predict'] # Fuegen Sie hier weitere Schritt-Schluessel hinzu, die _process_single_row versteht
|
||
single_row_steps_help = f"Komma-getrennte Liste der Schritte im 'reeval' und 'full_run' Modus (z.B. 'wiki,chat').\nMögliche Schritte: {', '.join(valid_single_row_steps)}.\nStandard: {'all' if valid_single_row_steps else 'keine'}" # Standard: alle verfuegbaren Schritte
|
||
|
||
# Standardwert fuer --steps: Alle gueltigen Single-Row Schritte, wenn es welche gibt
|
||
default_steps_arg = ','.join(valid_single_row_steps) if valid_single_row_steps else ''
|
||
parser.add_argument("--steps", type=str, help=single_row_steps_help, default=default_steps_arg)
|
||
|
||
|
||
# Argumente fuer find_wiki_serp (falls ueber CLI gesteuert)
|
||
parser.add_argument("--min_umsatz", type=float, help="Mindestumsatz in MIO € (CRM Spalte J) fuer find_wiki_serp Filter.", default=200.0) # Float fuer Konsistenz
|
||
parser.add_argument("--min_employees", type=int, help="Mindestmitarbeiterzahl (CRM Spalte K) fuer find_wiki_serp Filter.", default=500)
|
||
|
||
|
||
# Argumente fuer train_technician_model (Pfade fuer Output-Dateien)
|
||
parser.add_argument("--model_out", type=str, default=MODEL_FILE, help=f"Pfad fuer das trainierte Modell (.pkl). Standard: {MODEL_FILE}") # Block 1 Konstante
|
||
parser.add_argument("--imputer_out", type=str, default=IMPUTER_FILE, help=f"Pfad fuer den trainierten Imputer (.pkl). Standard: {IMPUTER_FILE}") # Block 1 Konstante
|
||
parser.add_argument("--patterns_out", type=str, default=PATTERNS_FILE_JSON, help=f"Pfad fuer die Feature-Spaltenliste (.json). Standard: {PATTERNS_FILE_JSON}") # Block 1 Konstante
|
||
|
||
# TODO: Fuegen Sie hier weitere CLI-Argumente hinzu, falls andere Modi Parameter benoetigen
|
||
|
||
args = parser.parse_args()
|
||
|
||
|
||
# --- Konfiguration laden ---
|
||
Config.load_api_keys() # Nutzt jetzt logging intern (print am Anfang Block 1)
|
||
|
||
|
||
# --- Logdatei-Konfiguration abschliessen ---
|
||
# Bestimmen Sie den Log-Modus Namen basierend auf CLI oder Interaktion
|
||
# Wir nutzen den CLI Modus Namen, wenn --mode gesetzt ist, sonst "interactive".
|
||
log_mode_name = args.mode if args.mode else "interactive"
|
||
LOG_FILE = create_log_filename(log_mode_name) # Nutzt globale Funktion (Block 3)
|
||
|
||
# Wenn die Logdatei erfolgreich erstellt wurde
|
||
if LOG_FILE:
|
||
try:
|
||
# Erstellen Sie den FileHandler fuer die Logdatei
|
||
# mode='a' zum Anhaengen, encoding='utf-8' fuer Unicode
|
||
file_handler = logging.FileHandler(LOG_FILE, mode='a', encoding='utf-8')
|
||
file_handler.setLevel(log_level) # Nimm das globale Level
|
||
# Verwenden Sie denselben Formatter wie fuer den Console Handler
|
||
file_handler.setFormatter(logging.Formatter(log_format))
|
||
# Fuege FileHandler zum Root-Logger hinzu
|
||
# Pruefen, ob nicht schon ein File Handler mit demselben Pfad vorhanden ist (z.B. bei wiederholten Aufrufen in Tests)
|
||
if not any(isinstance(h, logging.FileHandler) and h.baseFilename == os.path.abspath(LOG_FILE) for h in logging.getLogger('').handlers):
|
||
logging.getLogger('').addHandler(file_handler)
|
||
logger.info(f"Logging wird jetzt auch in Datei geschrieben: {LOG_FILE}")
|
||
except Exception as e:
|
||
# Logge Fehler nur auf Konsole, da FileHandler fehlgeschlagen ist
|
||
# logger.exception loggt auch an die Konsole, wenn kein FileHandler da ist
|
||
logger.error(f"Konnte FileHandler fuer Logdatei '{LOG_FILE}' nicht erstellen: {e}")
|
||
# Optional: Entfernen Sie evtl. den fehlerhaften Handler aus der Liste
|
||
logging.getLogger('').handlers = [h for h in logging.getLogger('').handlers if not isinstance(h, logging.FileHandler) or h.baseFilename == os.path.abspath(LOG_FILE)] # Entferne nur den fehlerhaften Handler
|
||
|
||
|
||
# --- JETZT die Startmeldungen loggen (gehen jetzt in Konsole UND Datei) ---
|
||
logger.info(f"===== Skript gestartet =====")
|
||
logger.info(f"Version: {current_script_version}")
|
||
# Logge den tatsaechlichen Pfad der Logdatei oder die Fehlermeldung
|
||
logger.info(f"Logdatei: {LOG_FILE if LOG_FILE else 'FEHLER - Keine Logdatei erstellt'}")
|
||
# Loggen Sie relevante CLI Argumente zur Dokumentation des Laufs
|
||
logger.info(f"CLI Argumente: {args}")
|
||
|
||
|
||
# --- Vorbereitung (Schema, Handler etc.) ---
|
||
# Laden Sie das Ziel-Branchenschema (Block 6)
|
||
# load_target_schema ist mit retry_on_failure dekoriert (Block 2).
|
||
load_target_schema()
|
||
|
||
|
||
# Initialisiere GoogleSheetHandler (Block 14)
|
||
sheet_handler = None # Initialisiere Variable
|
||
try:
|
||
# Der GoogleSheetHandler Init (_init_ Methode) baut die Verbindung auf und laedt Daten.
|
||
# Fehler werden dort gefangen und als ConnectionError erneut geworfen.
|
||
sheet_handler = GoogleSheetHandler() #<- Zeile 11362
|
||
logger.info("GoogleSheetHandler erfolgreich initialisiert.")
|
||
except ConnectionError as e:
|
||
# Wenn die Initialisierung des SheetHandlers fehlschlaegt (Verbindungs-/Ladefehler)
|
||
logger.critical(f"FATAL: Initialisierung des GoogleSheetHandlers fehlgeschlagen: {e}")
|
||
logger.critical(f"Bitte ueberpruefen Sie Ihre Google Sheets URL, Credentials und Berechtigungen.")
|
||
logger.critical(f"Bitte Logdatei pruefen fuer Details: {LOG_FILE}")
|
||
return # Beende Skript, wenn Sheet nicht geladen werden kann
|
||
except Exception as e:
|
||
# Fangen Sie andere unerwartete Fehler bei der Initialisierung ab
|
||
logger.critical(f"FATAL: Unerwarteter Fehler bei Initialisierung von GoogleSheetHandler: {e}")
|
||
logger.debug(traceback.format_exc())
|
||
logger.critical(f"Bitte Logdatei pruefen fuer Details: {LOG_FILE}")
|
||
return # Beende Skript
|
||
|
||
|
||
# Initialisiere WikipediaScraper (Block 14)
|
||
wiki_scraper = None # Initialisiere Variable
|
||
try:
|
||
# Der WikipediaScraper Init (_init_ Methode) konfiguriert die Bibliothek und Requests.
|
||
# Fehler werden dort gefangen und erneut geworfen.
|
||
wiki_scraper = WikipediaScraper()
|
||
logger.info("WikipediaScraper erfolgreich initialisiert.")
|
||
except Exception as e:
|
||
# Wenn die Initialisierung des WikipediaScrapers fehlschlaegt
|
||
logger.critical(f"FATAL: Initialisierung des WikipediaScrapers fehlgeschlagen: {e}")
|
||
logger.debug(traceback.format_exc())
|
||
logger.critical(f"Bitte Logdatei pruefen fuer Details: {LOG_FILE}")
|
||
# Das Skript kann ohne Wiki Scraper viele Modi nicht sinnvoll laufen
|
||
return # Beende Skript
|
||
|
||
|
||
# TODO: Initialisieren Sie hier weitere Worker-Instanzen, falls Sie separate Klassen haben (z.B. OpenAIHandler, SerpAPIHandler)
|
||
# openai_handler = OpenAIHandler()
|
||
# serpapi_handler = SerpAPIHandler()
|
||
|
||
logger.info("===== Spalten-Alignment Check =====")
|
||
if sheet_handler and sheet_handler.sheet_values and len(sheet_handler.sheet_values) > 0:
|
||
sheet_headers = sheet_handler.sheet_values[0]
|
||
logger.info(f"Header aus Google Sheet (erste {len(sheet_headers)} Spalten): {sheet_headers}")
|
||
|
||
# Ausgabe der COLUMN_MAP für den Abgleich (gekürzt für Lesbarkeit im Log)
|
||
logger.info("Aktuelle COLUMN_MAP (Auszug):")
|
||
for i, (key, value) in enumerate(COLUMN_MAP.items()):
|
||
if i < 5 or i > len(COLUMN_MAP) - 6 : # Zeige erste 5 und letzte 5
|
||
logger.info(f" '{key}': {value}")
|
||
elif i == 5:
|
||
logger.info(" ...")
|
||
else:
|
||
logger.warning("Konnte Header nicht aus Google Sheet laden für Alignment Check.")
|
||
logger.info("===================================")
|
||
|
||
# Initialisiere DataProcessor Instanz (Block 15) mit Handlern
|
||
# Uebergeben Sie alle benoetigten Handler an den DataProcessor.
|
||
# Die __init__ Methode des DataProcessor (Block 15) prueft die Typen und wirft Value Error, wenn falsch.
|
||
try:
|
||
data_processor = DataProcessor(sheet_handler=sheet_handler, wiki_scraper=wiki_scraper)
|
||
logger.info("DataProcessor erfolgreich initialisiert.")
|
||
except Exception as e:
|
||
# Fangen Sie Fehler bei der DataProcessor Initialisierung ab.
|
||
logger.critical(f"FATAL: Initialisierung des DataProcessors fehlgeschlagen: {e}")
|
||
logger.debug(traceback.format_exc())
|
||
logger.critical(f"Bitte Logdatei pruefen fuer Details: {LOG_FILE}")
|
||
return # Beende Skript
|
||
|
||
|
||
# --- Modusauswahl und Ausfuehrung ---
|
||
start_process_time = time.time() # Zeitmessung fuer die Verarbeitung starten
|
||
logger.info(f"Starte Verarbeitung um {datetime.now().strftime('%H:%M:%S')}...")
|
||
|
||
|
||
selected_mode = None # Variable fuer den tatsaechlich auszufuehrenden Modus
|
||
|
||
# --- Ermitteln des zu fuehrenden Modus (CLI hat Prioritaet vor interaktiver Auswahl) ---
|
||
# Wenn das --mode Argument ueber die Kommandozeile gesetzt wurde
|
||
if args.mode:
|
||
selected_mode = args.mode.lower() # Konvertiere zu Kleinbuchstaben
|
||
# Pruefen Sie, ob der gewaehlte Modus in der Liste der validen Modi enthalten ist
|
||
if selected_mode not in valid_modes:
|
||
# Logge einen Fehler und beende das Skript, wenn der Modus ungueltig ist.
|
||
logger.error(f"Ungueltiger Modus '{args.mode}' ueber Kommandozeile angegeben. Gueltige Modi: {', '.join(valid_modes)}")
|
||
print(f"Fehler: Ungueltiger Modus '{args.mode}'. Bitte ueberpruefen Sie die Liste der gueltigen Modi (siehe --help).")
|
||
return # Skript beenden
|
||
logger.info(f"Betriebsmodus (CLI gewaehlt): {selected_mode}")
|
||
|
||
# Wenn das --mode Argument NICHT ueber die Kommandozeile gesetzt wurde
|
||
else:
|
||
# --- Interaktive Modusauswahl ueber die Konsole ---
|
||
print("\nBitte waehlen Sie den Betriebsmodus:")
|
||
# Zeigen Sie die Liste der validen Modi kategorisiert an, mit Nummern.
|
||
mode_options_map = {} # Dictionary zum Abbilden von Zahl/Name auf Modusname
|
||
option_counter = 1 # Zaehler fuer die numerischen Optionen
|
||
# Iteriere durch die Kategorien und Modi
|
||
for category, modes in mode_categories.items():
|
||
print(f"\n{category}:")
|
||
for mode in modes:
|
||
print(f" {option_counter}: {mode}")
|
||
mode_options_map[str(option_counter)] = mode # Bilde die numerische Option auf den Modusnamen ab
|
||
mode_options_map[mode] = mode # Bilde den Modusnamen (kleingeschrieben) auf sich selbst ab (fuer direkte Eingabe)
|
||
option_counter += 1 # Erhoehe den Zaehler
|
||
|
||
|
||
# Fuegen Sie eine Option zum Abbrechen hinzu
|
||
print(f"\n 0: Abbrechen")
|
||
mode_options_map['0'] = 'exit' # Bilde 0 auf den speziellen 'exit' Modus ab
|
||
|
||
|
||
# Schleife, bis ein gueltiger Modus gewaehlt wurde oder der Benutzer abbricht
|
||
while selected_mode is None:
|
||
try:
|
||
# Lesen Sie die Eingabe vom Benutzer
|
||
mode_input = input(f"Geben Sie den Modusnamen oder die Zahl ein: ").strip().lower()
|
||
|
||
# Pruefen Sie, ob die Eingabe einer Option in der Map entspricht
|
||
if mode_input in mode_options_map:
|
||
selected_mode = mode_options_map[mode_input] # Setzen Sie den gewaehlten Modusnamen
|
||
|
||
# Wenn der 'exit' Modus gewaehlt wurde
|
||
if selected_mode == 'exit':
|
||
logger.info("Modus 'exit' gewaehlt. Skript wird beendet.")
|
||
print("Abgebrochen durch Benutzer.")
|
||
return # Beende das Skript
|
||
|
||
# Logge den gewaehlten Modus
|
||
logger.info(f"Betriebsmodus (interaktiv gewaehlt): {selected_mode}")
|
||
|
||
else:
|
||
# Wenn die Eingabe keinem gueltigen Modus entspricht
|
||
print("Ungueltige Eingabe. Bitte waehlen Sie eine gueltige Option aus der Liste.")
|
||
|
||
# Wenn selected_mode immer noch None ist, laeuft die Schleife weiter
|
||
|
||
|
||
except EOFError: # Benutzer hat Ctrl+D gedrueckt (End-of-File)
|
||
# Fangen Sie das EOFError ab und beenden Sie das Skript sauber.
|
||
logger.warning("Interaktive Modus-Eingabe abgebrochen (EOFError). Skript wird beendet.")
|
||
print("\nEingabe abgebrochen.")
|
||
return # Beende das Skript
|
||
except Exception as e:
|
||
# Fangen Sie andere unerwartete Fehler bei der Eingabe ab
|
||
logger.error(f"Fehler bei interaktiver Modus-Eingabe: {e}")
|
||
logger.debug(traceback.format_exc())
|
||
print(f"Ein Fehler ist bei der Modus-Eingabe aufgetreten ({e}). Bitte pruefen Sie die Logdatei.")
|
||
return # Beende das Skript bei unerwartetem Fehler
|
||
|
||
# ==============================================================================
|
||
# === NEUER BLOCK: Interaktive Limit-Abfrage ===
|
||
# ==============================================================================
|
||
limit_arg_cli = args.limit # ursprüngliches Limit vom CLI Argument
|
||
final_limit_to_use = limit_arg_cli # Standardmäßig das CLI-Limit verwenden
|
||
|
||
# Wenn kein Limit über CLI gesetzt wurde UND der Modus nicht einer der ist,
|
||
# bei denen ein Zeilenlimit typischerweise keinen Sinn macht.
|
||
# (Für 'alignment' und 'train_technician_model' ist ein Zeilen-Durchlauf-Limit meist nicht relevant)
|
||
# 'check_urls' und andere Batch-Modi können aber von einem Limit profitieren.
|
||
skippable_limit_modes = ['alignment', 'train_technician_model']
|
||
|
||
if final_limit_to_use is None and selected_mode not in skippable_limit_modes:
|
||
while True:
|
||
try:
|
||
limit_input_str = input(f"Maximale Anzahl zu verarbeitender Zeilen für Modus '{selected_mode}' (Enter für Unbegrenzt, aktuell: Unbegrenzt): ").strip()
|
||
if not limit_input_str: # Benutzer drückt Enter
|
||
final_limit_to_use = None
|
||
logger.info("Kein Limit für Zeilenverarbeitung gesetzt (interaktiv).")
|
||
break
|
||
|
||
# Versuche, in Integer umzuwandeln
|
||
temp_limit = int(limit_input_str)
|
||
|
||
if temp_limit <= 0:
|
||
logger.warning(f"Ungültiges Limit '{temp_limit}' (<=0) eingegeben, wird als Unbegrenzt behandelt.")
|
||
final_limit_to_use = None
|
||
else:
|
||
final_limit_to_use = temp_limit
|
||
logger.info(f"Limit für Zeilenverarbeitung (interaktiv gesetzt): {final_limit_to_use}")
|
||
break # Gültige Eingabe oder Entscheidung für "Unbegrenzt"
|
||
except ValueError:
|
||
print("Ungültige Eingabe. Bitte eine ganze Zahl eingeben oder Enter für Unbegrenzt.")
|
||
except EOFError:
|
||
logger.warning("Interaktive Limit-Eingabe abgebrochen. Nutze kein Limit (oder CLI-Vorgabe, falls vorhanden).")
|
||
# final_limit_to_use behält den Wert von limit_arg_cli (also None, wenn hierher gekommen)
|
||
break
|
||
except Exception as e_limit_input:
|
||
logger.error(f"Unerwarteter Fehler bei interaktiver Limit-Eingabe: {e_limit_input}")
|
||
logger.debug(traceback.format_exc())
|
||
print("Ein Fehler ist bei der Limit-Eingabe aufgetreten. Nutze kein Limit.")
|
||
final_limit_to_use = None
|
||
break
|
||
elif final_limit_to_use is not None: # Wenn ein Limit via CLI gesetzt wurde
|
||
logger.info(f"Verwende Limit aus CLI-Argument: {final_limit_to_use}")
|
||
elif selected_mode in skippable_limit_modes and final_limit_to_use is None:
|
||
logger.info(f"Modus '{selected_mode}' benötigt typischerweise kein Zeilenlimit. Limit-Abfrage übersprungen.")
|
||
|
||
|
||
# Die Variable `final_limit_to_use` enthält nun das anzuwendende Limit (entweder von CLI, interaktiv oder None)
|
||
# ==============================================================================
|
||
# === ENDE NEUER BLOCK ===
|
||
# ==============================================================================
|
||
|
||
|
||
# --- Ausfuehrung des gewaehlten Modus ---
|
||
try:
|
||
# Holen Sie die CLI-Argumente fuer Start/End/Limit/Steps
|
||
limit_arg = args.limit
|
||
start_row_arg = args.start_sheet_row
|
||
end_row_arg = args.end_sheet_row
|
||
|
||
# Sonderbehandlung fuer --steps Argument (relevant fuer reeval und full_run)
|
||
steps_to_run_set = set() # Initialisiere ein leeres Set
|
||
# Pruefen Sie, ob das --steps Argument gesetzt ist und nicht "all" (case-insensitive)
|
||
if args.steps and isinstance(args.steps, str) and args.steps.strip().lower() != 'all':
|
||
# Teilen Sie den String in Schritte auf und bereinigen Sie Leerzeichen
|
||
steps_list = [step.strip().lower() for step in args.steps.split(',') if step.strip()]
|
||
# Filtern Sie nur erlaubte Schritte (die von _process_single_row verstanden werden Block 19)
|
||
steps_to_run_set = set(step for step in steps_list if step in valid_single_row_steps) # valid_single_row_steps wurde oben definiert
|
||
|
||
# Logge eine Warnung, wenn ungueltige Schritte angegeben wurden
|
||
if len(steps_to_run_set) != len(steps_list):
|
||
invalid_steps = [step for step in steps_list if step not in valid_single_row_steps]
|
||
logger.warning(f"Ignoriere ungueltige Schritte im --steps Argument: {invalid_steps}. Fuehre nur {steps_to_run_set} aus.")
|
||
|
||
# Wenn nach der Filterung keine gueltigen Schritte uebrig sind
|
||
if not steps_to_run_set:
|
||
logger.error("Keine gueltigen Schritte im --steps Argument gefunden. Re-Eval/Full-Run kann nicht gestartet werden.")
|
||
print("Fehler: Keine gueltigen Schritte fuer den Modus ausgewaehlt. Bitte ueberpruefen Sie das --steps Argument.")
|
||
return # Skript beenden, wenn keine Schritte ausgewaehlt sind
|
||
|
||
# Wenn das --steps Argument 'all' ist oder nicht gesetzt
|
||
else:
|
||
# Fuhren Sie standardmaessig alle gueltigen Single-Row Schritte aus.
|
||
steps_to_run_set = set(valid_single_row_steps) # valid_single_row_steps wurde oben definiert
|
||
# Logge, welche Schritte ausgewaehlt wurden, wenn es der Standard ist
|
||
if default_steps_arg: # Wenn es ueberhaupt gueltige Schritte gibt
|
||
logger.debug(f"--steps Argument 'all' oder nicht gesetzt. Standard Schritte: {steps_to_run_set}.")
|
||
|
||
|
||
# Dispatching basierend auf dem gewaehlten Modus (selected_mode)
|
||
logger.info(f"Starte Ausfuehrung des Modus: {selected_mode}")
|
||
|
||
# ---- Kombinierte LÄUFE ----
|
||
if selected_mode == "combined_all":
|
||
# Führt die wichtigsten Batch-Modi nacheinander aus
|
||
logger.info("--- Start Kombinierter Modus: wiki_verify ---")
|
||
# Rufe die Methode der DataProcessor Instanz auf (Block 26)
|
||
data_processor.process_verification_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)
|
||
logger.info("--- Start Kombinierter Modus: website_scraping ---")
|
||
# Rufe die Methode der DataProcessor Instanz auf (Block 27)
|
||
data_processor.process_website_scraping_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)
|
||
logger.info("--- Start Kombinierter Modus: summarize_website ---")
|
||
# Rufe die Methode der DataProcessor Instanz auf (Block 28)
|
||
data_processor.process_summarization_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)
|
||
logger.info("--- Start Kombinierter Modus: branch_eval ---")
|
||
# Rufe die Methode der DataProcessor Instanz auf (Block 29)
|
||
data_processor.process_branch_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)
|
||
# TODO: Fuegen Sie hier weitere Batch-Modi hinzu, falls sie im kombinierten Lauf enthalten sein sollen
|
||
logger.info("--- Kombinierter Modus abgeschlossen ---")
|
||
|
||
|
||
# ---- Batch-VERARBEITUNG (Schritt-Optimiert) ----
|
||
elif selected_mode == "wiki_verify": # Entspricht dem Batch-Modus Wiki Verifizierung (AX)
|
||
# Rufe die Methode der DataProcessor Instanz auf (Block 26)
|
||
data_processor.process_verification_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)
|
||
|
||
elif selected_mode == "website_scraping": # Entspricht dem Batch-Modus Website Scraping (AR, AT, AP)
|
||
# Rufe die Methode der DataProcessor Instanz auf (Block 27)
|
||
data_processor.process_website_scraping_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)
|
||
|
||
elif selected_mode == "summarize_website": # Entspricht dem Batch-Modus Website Summarization (AS, AP)
|
||
# Rufe die Methode der DataProcessor Instanz auf (Block 28)
|
||
data_processor.process_summarization_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)
|
||
|
||
elif selected_mode == "branch_eval": # Entspricht dem Batch-Modus Branchen-Einstufung (W-Y, AO, AP)
|
||
# Rufe die Methode der DataProcessor Instanz auf (Block 29)
|
||
data_processor.process_branch_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg)
|
||
|
||
|
||
# ---- Sequentielle VERARBEITUNG (Zeilenweise) ----
|
||
elif selected_mode == "full_run": # Nutzt process_rows_sequentially (Block 24)
|
||
# Full_run verarbeitet sequentiell einen Bereich.
|
||
# Startzeile wird vom CLI Argument oder automatisch ermittelt (erste leere AO).
|
||
# Endzeile vom CLI Argument oder bis Ende Sheet.
|
||
# Limit begrenzt die Anzahl der *verarbeiteten* Zeilen im Bereich.
|
||
|
||
calculated_start_sheet_row = start_row_arg # Beginne mit CLI Argument start_sheet_row
|
||
# Wenn start_sheet_row nicht ueber CLI gesetzt wurde
|
||
if calculated_start_sheet_row is None:
|
||
# Automatische Ermittlung der Startzeile (erste Zeile ohne AO)
|
||
logger.info("Automatische Ermittlung der Startzeile fuer sequenzielle Verarbeitung (erste Zeile ohne AO)...")
|
||
# get_start_row_index (Block 14) gibt 0-basierten Index in Daten (ohne Header) zurueck.
|
||
# Prueft auf leeren AO (Block 1 Column Map).
|
||
start_data_index_no_header = sheet_handler.get_start_row_index(check_column_key="Timestamp letzte Pruefung", min_sheet_row=7)
|
||
|
||
# Wenn get_start_row_index -1 zurueckgibt (Fehler)
|
||
if start_data_index_no_header == -1:
|
||
logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Kann Full-Run nicht starten.")
|
||
return # Beende das Skript
|
||
|
||
# Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten Daten-Index
|
||
calculated_start_sheet_row = start_data_index_no_header + sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut
|
||
|
||
|
||
# Berechnen Sie die tatsaechliche Anzahl der zu verarbeitenden Zeilen im Bereich.
|
||
# (basierend auf Endzeile und Limit)
|
||
total_sheet_rows = len(sheet_handler.get_all_data_with_headers()) # Block 14 SheetHandler
|
||
calculated_end_sheet_row = end_row_arg if end_row_arg is not None else total_sheet_rows
|
||
# Stellen Sie sicher, dass die Endzeile nicht vor der Startzeile liegt
|
||
calculated_end_sheet_row = max(calculated_start_sheet_row - 1, calculated_end_sheet_row)
|
||
|
||
|
||
# Die Anzahl der Zeilen im betrachteten Bereich
|
||
rows_in_range = max(0, calculated_end_sheet_row - calculated_start_sheet_row + 1)
|
||
|
||
# num_to_process ist das Limit, angewendet auf die Zeilen im Bereich.
|
||
num_to_process_calc = rows_in_range # Standard: alle Zeilen im Bereich
|
||
# Wenn ein Limit ueber CLI gesetzt wurde und es gueltig ist
|
||
if limit_arg is not None and isinstance(limit_arg, int) and limit_arg >= 0:
|
||
num_to_process_calc = min(rows_in_range, limit_arg)
|
||
|
||
|
||
# Wenn es Zeilen zu verarbeiten gibt
|
||
if num_to_process_calc > 0:
|
||
logger.info(f"'full_run': Verarbeite {num_to_process_calc} Zeilen im Sheet-Bereich [{calculated_start_sheet_row}, {calculated_end_sheet_row}].")
|
||
# Rufe die sequentielle Verarbeitungsmethode auf (Block 24)
|
||
# _process_single_row (Block 19) wird intern aufgerufen.
|
||
data_processor.process_rows_sequentially(
|
||
start_sheet_row = calculated_start_sheet_row,
|
||
num_to_process = num_to_process_calc,
|
||
# Uebergeben Sie die aus dem --steps Argument ermittelten Flags (steps_to_run_set)
|
||
process_wiki_steps='wiki' in steps_to_run_set,
|
||
process_chatgpt_steps='chat' in steps_to_run_set,
|
||
process_website_steps='web' in steps_to_run_set,
|
||
process_ml_steps='ml_predict' in steps_to_run_set
|
||
# TODO: Weitere Schritt-Flags hier uebergeben
|
||
# force_reeval_in_single_row=False # Normalerweise kein Re-Eval im Full-Run
|
||
# clear_x_flag=False # Normalerweise kein X loeschen im Full-Run
|
||
)
|
||
else:
|
||
# Wenn keine Zeilen zu verarbeiten sind
|
||
logger.info(f"Keine Zeilen fuer 'full_run' zu verarbeiten im Bereich [{calculated_start_sheet_row}, {calculated_end_sheet_row}] mit Limit {limit_arg}.")
|
||
|
||
|
||
# ---- Re-EVALUATE Markierte Zeilen ----
|
||
elif selected_mode == "reeval": # Nutzt process_reevaluation_rows (Block 25)
|
||
# reeval Modus nutzt immer force_reeval=True in _process_single_row.
|
||
# Das 'x'-Flag wird von _process_single_row (Block 21) geloescht, wenn clear_flag=True uebergeben wird.
|
||
# Das Limit wird direkt an process_reevaluation_rows uebergeben und dort gehandhabt.
|
||
if limit_arg is not None and isinstance(limit_arg, int) and limit_arg <= 0:
|
||
# Wenn ein Limit von 0 oder weniger angegeben wurde
|
||
logger.info(f"Limit {limit_arg} angegeben im Re-Eval Modus. Ueberspringe Verarbeitung.")
|
||
else:
|
||
# Rufe die Methode der DataProcessor Instanz auf (Block 25)
|
||
data_processor.process_reevaluation_rows(
|
||
row_limit=limit_arg, # Uebergibt das Limit (kann None sein)
|
||
clear_flag=True, # Standardmaessig das 'x'-Flag loeschen
|
||
# Uebergeben Sie die aus dem --steps Argument ermittelten Schritte (steps_to_run_set)
|
||
process_wiki_steps='wiki' in steps_to_run_set,
|
||
process_chatgpt_steps='chat' in steps_to_run_set,
|
||
process_website_steps='web' in steps_to_run_set,
|
||
process_ml_steps='ml_predict' in steps_to_run_set
|
||
# TODO: Weitere Schritt-Flags hier uebergeben
|
||
)
|
||
|
||
|
||
# ---- Einzelne DIENSTPROGRAMME / SUCHEN ----
|
||
elif selected_mode == "find_wiki_serp": # Nutzt process_find_wiki_serp (Block 30)
|
||
# find_wiki_serp sucht leere AY mit Groessenfilter. Nutzt limit, min_employees, min_umsatz.
|
||
# Start/Endzeile koennen manuell gesetzt werden oder werden automatisch ermittelt (erste leere AY).
|
||
data_processor.process_find_wiki_serp(
|
||
start_sheet_row=start_row_arg, # Kann manuell gesetzt werden
|
||
end_sheet_row=end_row_arg, # Kann manuell gesetzt werden
|
||
limit=limit_arg, # Kann manuell gesetzt werden
|
||
min_employees=args.min_employees, # Aus CLI Argument
|
||
min_umsatz=args.min_umsatz # Aus CLI Argument
|
||
)
|
||
|
||
elif selected_mode == "website_lookup": # Nutzt process_serp_website_lookup (Block 30)
|
||
# website_lookup sucht leere D. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden.
|
||
data_processor.process_serp_website_lookup(
|
||
start_sheet_row=start_row_arg, # Kann manuell gesetzt werden
|
||
end_sheet_row=end_row_arg, # Kann manuell gesetzt werden
|
||
limit=limit_arg # Kann manuell gesetzt werden
|
||
)
|
||
|
||
elif selected_mode == "check_urls":
|
||
data_processor.process_url_check(
|
||
start_sheet_row=start_row_arg,
|
||
end_sheet_row=end_row_arg,
|
||
limit=limit_arg
|
||
)
|
||
|
||
elif selected_mode == "contacts": # Nutzt process_contact_search (Block 30)
|
||
# contacts sucht leere AM. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden.
|
||
data_processor.process_contact_search(
|
||
start_sheet_row=start_row_arg, # Kann manuell gesetzt werden
|
||
end_sheet_row=end_row_arg, # Kann manuell gesetzt werden
|
||
limit=limit_arg # Kann manuell gesetzt werden
|
||
)
|
||
|
||
elif selected_mode == "update_wiki_suggestions": # Nutzt process_wiki_updates_from_chatgpt (Block 32)
|
||
# update_wiki_suggestions prueft Status S. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden.
|
||
data_processor.process_wiki_updates_from_chatgpt(
|
||
start_sheet_row=start_row_arg, # Kann manuell gesetzt werden
|
||
end_sheet_row=end_row_arg, # Kann manuell gesetzt werden
|
||
limit=limit_arg # Kann manuell gesetzt werden
|
||
)
|
||
|
||
elif selected_mode == "wiki_reextract_missing_an": # Nutzt process_wiki_reextract_missing_an (Block 32)
|
||
# wiki_reextract_missing_an sucht M gefuellt & AN leer. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden.
|
||
# Ruft intern _process_single_row mit steps={'wiki'} und force_reeval=True auf.
|
||
data_processor.process_wiki_reextract_missing_an(
|
||
start_sheet_row=start_row_arg, # Kann manuell gesetzt werden
|
||
end_sheet_row=end_row_arg, # Kann manuell gesetzt werden
|
||
limit=limit_arg # Kann manuell gesetzt werden
|
||
)
|
||
|
||
|
||
elif selected_mode == "website_details": # EXPERIMENTELL - Nutzt process_website_details (Block 32)
|
||
# website_details sucht 'x' in A. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden.
|
||
data_processor.process_website_details(
|
||
start_sheet_row=start_row_arg, # Kann manuell gesetzt werden
|
||
end_sheet_row=end_row_arg, # Kann manuell gesetzt werden
|
||
limit=limit_arg # Kann manuell gesetzt werden
|
||
)
|
||
|
||
|
||
elif selected_mode == "train_technician_model": # Nutzt train_technician_model (Block 31)
|
||
# training braucht keine Zeilenlimits im Sinne eines Bereichs oder der Anzahl zu verarbeitender Zeilen im Sheet.
|
||
# Es nutzt prepare_data_for_modeling (Block 31), die alle relevanten Zeilen filtert.
|
||
# Die output-Pfade werden aus CLI Argumenten genommen (args).
|
||
data_processor.train_technician_model(
|
||
model_out=args.model_out, # Aus CLI Argument
|
||
imputer_out=args.imputer_out, # Aus CLI Argument
|
||
patterns_out=args.patterns_out # Aus CLI Argument (JSON Datei)
|
||
)
|
||
|
||
elif selected_mode == "alignment": # Nutzt globale alignment_demo (Block 14)
|
||
# alignment_demo ist eine globale Funktion, die das sheet Objekt braucht.
|
||
# Sie braucht keine Zeilenlimits oder Start/Ende.
|
||
if sheet_handler and sheet_handler.sheet:
|
||
alignment_demo(sheet_handler.sheet)
|
||
else:
|
||
logger.error("Sheet-Handler oder Sheet-Objekt nicht verfuegbar fuer Alignment-Demo.")
|
||
|
||
elif selected_mode == "reparatur_sitz": # NEUER BLOCK
|
||
# Hier können Sie Start, Ende und Limit aus args verwenden, falls Sie dafür CLI-Optionen hinzufügen möchten
|
||
# oder feste Werte / interaktive Abfragen für diesen Modus implementieren.
|
||
# Für den Anfang ein kompletter Durchlauf (ab Datenstart):
|
||
data_processor.process_repair_sitz_data(
|
||
start_sheet_row=None, # Beginnt nach den Headern
|
||
end_sheet_row=None, # Bis zum Ende des Sheets
|
||
limit=final_limit_to_use # Verwendet das global ermittelte Limit
|
||
)
|
||
|
||
elif selected_mode == "plausi_check_data":
|
||
data_processor.run_plausibility_checks_batch(
|
||
start_sheet_row=args.start_sheet_row, # Nimmt CLI-Argumente für Bereich
|
||
end_sheet_row=args.end_sheet_row,
|
||
limit=final_limit_to_use # VERWENDE das ermittelte Limit
|
||
)
|
||
|
||
|
||
elif selected_mode == "suggest_parents": # <<< NEUER ELIF-BLOCK
|
||
data_processor.process_parent_suggestion_batch(
|
||
start_sheet_row=args.start_sheet_row,
|
||
end_sheet_row=args.end_sheet_row,
|
||
limit=final_limit_to_use, # Nutzt das ggf. interaktiv abgefragte Limit
|
||
re_evaluate_question_mark=True # Beispiel: Standardmäßig Fragezeichen neu bewerten
|
||
# Sie können hierfür auch ein CLI Argument hinzufügen
|
||
)
|
||
|
||
# ---- Modus nicht gefunden (sollte durch Validierung oben abgefangen werden) ----
|
||
else:
|
||
# Dieser Zweig sollte aufgrund der Validierung am Anfang nie erreicht werden.
|
||
logger.error(f"Unerwarteter Modus '{selected_mode}' erreichte das Ausfuehrungsende des Dispatchers.")
|
||
print(f"Interner Fehler: Unbekannter Modus '{selected_mode}'.")
|
||
|
||
|
||
# --- Ausnahmebehandlung fuer den gesamten Ausfuehrungsblock ---
|
||
except KeyboardInterrupt:
|
||
# Wenn der Benutzer das Skript manuell unterbricht (Ctrl+C)
|
||
logger.warning("Skript durch Benutzer unterbrochen (KeyboardInterrupt).")
|
||
print("\n! Skript wurde manuell beendet.")
|
||
except Exception as e:
|
||
# Dieser Block faengt alle unerwarteten Exceptions ab, die in den aufgerufenen
|
||
# Funktionen/Methoden passieren und nicht intern gefangen und behandelt werden.
|
||
logger.critical(f"FATAL: Unerwarteter Fehler waehrend der Ausfuehrung von Modus '{selected_mode}': {e}")
|
||
# exception() loggt den Fehlertyp, die Nachricht und den vollständigen Traceback.
|
||
logger.exception("Traceback des kritischen Fehlers:")
|
||
# Gebe eine Fehlermeldung an die Konsole aus, die auf das Log verweist.
|
||
print(f"\n! Ein kritischer Fehler ist aufgetreten: {type(e).__name__} - {e}")
|
||
print(f"Bitte pruefen Sie die Logdatei fuer Details: {LOG_FILE}")
|
||
|
||
|
||
# --- Abschluss der Skriptausfuehrung ---
|
||
end_process_time = time.time() # Ende der Zeitmessung
|
||
duration = end_process_time - start_process_time # Berechne die Gesamtdauer
|
||
logger.info(f"Verarbeitung abgeschlossen um {datetime.now().strftime('%H:%M:%S')}.")
|
||
logger.info(f"Gesamtdauer: {duration:.2f} Sekunden.")
|
||
logger.info(f"===== Skript beendet =====")
|
||
|
||
# Schliesse Logging Handler explizit
|
||
# Dies stellt sicher, dass alle gepufferten Logmeldungen in die Datei geschrieben werden.
|
||
logging.shutdown()
|
||
|
||
# Logfile Pfad fuer den Nutzer auf der Konsole ausgeben
|
||
if LOG_FILE:
|
||
print(f"\nVerarbeitung abgeschlossen. Logfile: {LOG_FILE}")
|
||
else:
|
||
print("\nVerarbeitung abgeschlossen. Es konnte keine Logdatei erstellt werden.")
|
||
|
||
|
||
# ==============================================================================
|
||
# 7. ENTRY POINT
|
||
# ==============================================================================
|
||
|
||
# Fuehrt die main-Funktion aus, wenn das Skript direkt gestartet wird.
|
||
if __name__ == '__main__':
|
||
# Die main() Funktion enthaltet nun die gesamte Logik und Initialisierung.
|
||
# Alle globalen imports und Funktionen MÜSSEN VOR diesem Block definiert sein.
|
||
# Die Klassen MÜSSEN VOR diesem Block definiert sein.
|
||
|
||
main()
|
||
|
||
|
||
# ==============================================================================
|
||
# Ende DataProcessor Klasse
|
||
# ==============================================================================
|
||
|
||
# --- Ende der DataProcessor Klasse ---
|
||
# Ein pass statement, um die Klassendefinition abzuschliessen, falls keine weiteren Methoden folgen.
|
||
pass # <-- DIESES pass STATEMENT GEHOERT ZUM ENDE DER KLASSENDEFINITION
|