Files
Brancheneinstufung2/brancheneinstufung.py
2025-04-17 10:05:07 +00:00

3737 lines
200 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
v1.6.3: Beschleunige Website-Scraping durch gebündelte Sheet-Updates
Git-Änderungsbeschreibung:
- Überarbeite `process_website_batch` zur Leistungssteigerung.
- Implementiere das Sammeln von Zell-Updates (`AR`, `AS`, `AT`, `AP`) für mehrere Zeilen in einer Liste (`all_sheet_updates`).
- Sende die gesammelten Updates gebündelt über einen einzigen `batch_update_cells`-Aufruf an Google Sheets, wenn ein Limit (`update_batch_row_limit`) erreicht ist oder die Schleife endet.
- Ziel: Reduzierung der Anzahl von Google Sheets API-Aufrufen und Beschleunigung des Website-Scraping-Prozesses.
- Stelle sicher, dass auch ein letzter, unvollständiger Batch nach der Hauptschleife gesendet wird.
"""
import os
import time
import re
import gspread
import wikipedia
import requests
import openai
from bs4 import BeautifulSoup
from oauth2client.service_account import ServiceAccountCredentials
from datetime import datetime
from difflib import SequenceMatcher
import unicodedata
import csv
import gender_guesser.detector as gender
from urllib.parse import urlparse, urlencode
import argparse
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.impute import SimpleImputer
from sklearn.tree import DecisionTreeClassifier, export_text
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
import json # Zum Speichern der Muster als JSON
import pickle # Zum Speichern des trainierten Modells und Imputers
import concurrent.futures
import threading
# Optional: tiktoken für Token-Zählung (Modus 8)
try:
import tiktoken
except ImportError:
tiktoken = None
# ==================== KONSTANTEN ====================
CREDENTIALS_FILE = "service_account.json"
API_KEY_FILE = "api_key.txt"
SERP_API_KEY_FILE = "serpApiKey.txt"
GENDERIZE_API_KEY_FILE = "genderize_API_Key.txt"
BRANCH_MAPPING_FILE = "ziel_Branchenschema.csv"
LOG_DIR = "Log"
# ==================== KONFIGURATION ====================
class Config:
VERSION = "v1.6.3" # Behalte Version bei, da es ein Bugfix ist
LANG = "de"
SHEET_URL = "https://docs.google.com/spreadsheets/d/1u_gHr9JUfmV1-iviRzbSe3575QEp7KLhK5jFV_gJcgo"
MAX_RETRIES = 3
RETRY_DELAY = 5
SIMILARITY_THRESHOLD = 0.65
DEBUG = True
WIKIPEDIA_SEARCH_RESULTS = 5
HTML_PARSER = "html.parser"
TOKEN_MODEL = "gpt-3.5-turbo"
# --- Konfiguration für Batching & Parallelisierung ---
BATCH_SIZE = 10 # Batch-Größe für Wiki Verification (_process_batch)
PROCESSING_BATCH_SIZE = 20 # Wie viele Zeilen pro Verarbeitungs-Batch sammeln (Website Scraping)
OPENAI_BATCH_SIZE_LIMIT = 8 # Max. Texte pro OpenAI Call in summarize_batch_openai
MAX_SCRAPING_WORKERS = 10 # Threads für paralleles Website-Scraping
UPDATE_BATCH_ROW_LIMIT = 50 # Zeilen sammeln für gebündelte Sheet Updates
API_KEYS = {}
@classmethod
def load_api_keys(cls): # unverändert
cls.API_KEYS['openai'] = cls._load_key_from_file(API_KEY_FILE)
cls.API_KEYS['serpapi'] = cls._load_key_from_file(SERP_API_KEY_FILE)
cls.API_KEYS['genderize'] = cls._load_key_from_file(GENDERIZE_API_KEY_FILE)
if cls.API_KEYS.get('openai'): openai.api_key = cls.API_KEYS['openai']
else: debug_print("⚠️ OpenAI API Key konnte nicht geladen werden.")
@staticmethod
def _load_key_from_file(filepath): # unverändert
try:
with open(filepath, "r") as f: return f.read().strip()
except Exception as e: debug_print(f"Fehler Keys aus '{filepath}': {e}"); return None
# Globales Mapping-Dictionary und Schema-String
BRANCH_MAPPING = {}
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar."
ALLOWED_TARGET_BRANCHES = []
# Globales Spalten-Mapping (Beispiel basierend auf Zeile 4 - Kurze Beschreibung)
# TODO: Dieses Mapping vervollständigen und durchgängig verwenden!
COLUMN_MAP = {
"ReEval Flag": 0, # A
"CRM Name": 1, # B
"CRM Kurzform": 2, # C
"CRM Website": 3, # D
"CRM Ort": 4, # E
"CRM Beschreibung": 5, # F
"CRM Branche": 6, # G
"CRM Beschreibung Branche extern": 7, # H
"CRM Anzahl Techniker": 8, # I
"CRM Umsatz": 9, # J
"CRM Anzahl Mitarbeiter": 10, # K
"CRM Vorschlag Wiki URL": 11, # L
"Wiki URL": 12, # M
"Wiki Absatz": 13, # N
"Wiki Branche": 14, # O
"Wiki Umsatz": 15, # P
"Wiki Mitarbeiter": 16, # Q
"Wiki Kategorien": 17, # R
"Chat Wiki Konsistenzprüfung": 18, # S
"Chat Begründung Wiki Inkonsistenz": 19, # T
"Chat Vorschlag Wiki Artikel": 20, # U
"Begründung bei Abweichung": 21, # V
"Chat Vorschlag Branche": 22, # W
"Chat Konsistenz Branche": 23, # X
"Chat Begründung Abweichung Branche": 24, # Y
"Chat Prüfung FSM Relevanz": 25, # Z
"Chat Begründung für FSM Relevanz": 26, # AA
"Chat Schätzung Anzahl Mitarbeiter": 27, # AB
"Chat Konsistenzprüfung Mitarbeiterzahl": 28, # AC
"Chat Begründung Abweichung Mitarbeiterzahl": 29, # AD
"Chat Einschätzung Anzahl Servicetechniker": 30, # AE
"Chat Begründung Abweichung Anzahl Servicetechniker": 31, # AF
"Chat Schätzung Umsatz": 32, # AG
"Chat Begründung Abweichung Umsatz": 33, # AH
"Linked Serviceleiter gefunden": 34, # AI
"Linked It-Leiter gefunden": 35, # AJ
"Linked Management gefunden": 36, # AK
"Linked Disponent gefunden": 37, # AL
"Contact Search Timestamp": 38, # AM
"Wikipedia Timestamp": 39, # AN (Zeitpunkt der Datenextraktion)
"Timestamp letzte Prüfung": 40, # AO (Zeitpunkt der Branch-Einschätzung)
"Version": 41, # AP
"Tokens": 42, # AQ
"Website Rohtext": 43, # AR
"Website Zusammenfassung": 44, # AS
"Website Scrape Timestamp": 45, # AT
"Geschätzter Techniker Bucket": 46, # AU
"Finaler Umsatz (Wiki>CRM)": 47,# AV
"Finaler Mitarbeiter (Wiki>CRM)": 48, # AW
"Wiki Verif. Timestamp": 49 # AX (NEU)
}
# Hinweis: Index ist 0-basiert, Spaltenbuchstaben sind 1-basiert (A=1, AW=49)
# Annahme: COLUMN_MAP ist global definiert und enthält mindestens:
# "CRM Name", "CRM Branche", "CRM Umsatz", "Wiki Umsatz",
# "CRM Anzahl Mitarbeiter", "Wiki Mitarbeiter", "CRM Anzahl Techniker" (oder wo immer die bekannte Technikerzahl steht)
# Stelle sicher, dass die globale COLUMN_MAP verfügbar ist
# Beispielhafte Definition (bitte an deine Spalten anpassen!)
# COLUMN_MAP = { ... dein komplettes Mapping ... }
def prepare_data_for_modeling(sheet_handler):
"""
Lädt Daten aus dem Google Sheet, bereitet sie für das Decision Tree Modell vor:
- Wählt relevante Spalten aus.
- Konsolidiert Umsatz/Mitarbeiter (Wiki > CRM Priorität).
- Filtert nach gültiger Technikerzahl (> 0).
- Erstellt die Zielvariable (Techniker-Bucket).
- Bereitet Features auf (One-Hot Encoding für Branche).
- Behält NaNs in numerischen Features für spätere Imputation.
Args:
sheet_handler (GoogleSheetHandler): Instanz mit geladenen Sheet-Daten.
Returns:
pandas.DataFrame: Vorbereiteter DataFrame für Training/Test-Split,
oder None bei Fehlern.
"""
debug_print("Starte Datenvorbereitung für Modellierung...")
try:
# --- 1. Daten laden & Spalten auswählen ---
all_data = sheet_handler.get_all_data_with_headers()
if len(all_data) <= 5: # Annahme: 5 Header-Zeilen
debug_print("Fehler: Nicht genügend Datenzeilen im Sheet gefunden.")
return None
headers = all_data[0] # Nimm die erste Zeile als Header für Pandas
data_rows = all_data[5:] # Daten ohne die ersten 5 Header-Zeilen
# Erstelle DataFrame
df = pd.DataFrame(data_rows, columns=headers)
debug_print(f"DataFrame erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.")
# Wähle benötigte Spalten aus ( passe die Schlüssel an deine COLUMN_MAP an!)
required_cols_keys = [
"CRM Name", # Zur Identifikation, wird später entfernt
"CRM Branche",
"CRM Umsatz",
"Wiki Umsatz",
"CRM Anzahl Mitarbeiter",
"Wiki Mitarbeiter",
"CRM Anzahl Techniker" # ÄNDERE DIESEN SCHLÜSSEL, falls die bekannte Zahl woanders steht!
]
# Finde die tatsächlichen Spaltennamen aus den Headern basierend auf COLUMN_MAP Beschreibung (Zeile 4)
# ODER verwende direkt die Spaltennamen, wenn sie stabil sind.
# Hier vereinfacht angenommen, dass die Schlüssel oben die Spaltennamen sind:
try:
# Konvertiere Spaltennamen aus COLUMN_MAP zu echten Spaltennamen im DataFrame (falls nötig)
# Dies ist ein Platzhalter - im echten Code müsstest du die Header-Zeilen parsen
# oder dich darauf verlassen, dass die Schlüssel oben die exakten Spaltennamen sind.
df_subset = df[required_cols_keys].copy() # Kopie erstellen, um SettingWithCopyWarning zu vermeiden
except KeyError as e:
debug_print(f"FEHLER: Benötigte Spalte nicht im DataFrame gefunden: {e}. Verfügbare Spalten: {list(df.columns)}")
return None
debug_print(f"Benötigte Spalten ausgewählt.")
# --- 2. Features konsolidieren (Umsatz, Mitarbeiter) ---
# Hilfsfunktion zur Validierung und Konvertierung
def get_valid_numeric(value_str):
if value_str is None or pd.isna(value_str): return np.nan
try:
# Versuche direkt float zu konvertieren
val = float(value_str)
return val if val > 0 else np.nan # Nur Werte > 0 sind gültig
except (ValueError, TypeError):
# Wenn nicht direkt float, versuche es über extract_numeric_value
# Diese Funktion muss dafür angepasst werden, float oder np.nan zurückzugeben
# num_val_str = extract_numeric_value(str(value_str), is_umsatz=True) # Bsp. Umsatz
# if num_val_str != "k.A.":
# try:
# val = float(num_val_str)
# return val if val > 0 else np.nan
# except ValueError: return np.nan
# else: return np.nan
# --- VEREINFACHUNG für jetzt: Nur direkt konvertierbare Werte ---
cleaned_str = re.sub(r'[^\d.,]', '', str(value_str)).replace(',', '.') # Einfache Reinigung
try:
val = float(cleaned_str)
return val if val > 0 else np.nan
except ValueError:
return np.nan
# Konvertiere Quellen-Spalten und wende Priorisierung an
cols_to_process = {
'Umsatz': ('Wiki Umsatz', 'CRM Umsatz', 'Finaler_Umsatz'),
'Mitarbeiter': ('Wiki Mitarbeiter', 'CRM Anzahl Mitarbeiter', 'Finaler_Mitarbeiter')
}
for base_name, (wiki_col, crm_col, final_col) in cols_to_process.items():
debug_print(f"Verarbeite '{base_name}' (Wiki: {wiki_col}, CRM: {crm_col})...")
wiki_numeric = df_subset[wiki_col].apply(get_valid_numeric)
crm_numeric = df_subset[crm_col].apply(get_valid_numeric)
# Priorisierung: Wiki > CRM
df_subset[final_col] = np.where(
wiki_numeric.notna() & (wiki_numeric > 0), # Wenn Wiki gültig
wiki_numeric,
np.where(
crm_numeric.notna() & (crm_numeric > 0), # Sonst, wenn CRM gültig
crm_numeric,
np.nan # Sonst NaN
)
)
# Logge, wie viele Werte gefunden wurden
debug_print(f" -> {df_subset[final_col].notna().sum()} gültige '{final_col}' Werte erstellt.")
# Entferne die Originalspalten (optional)
# df_subset = df_subset.drop(columns=[wiki_col, crm_col])
# --- 3. Zielvariable vorbereiten (Technikerzahl) ---
techniker_col = "CRM Anzahl Techniker" # ÄNDERE DAS WENN NÖTIG!
debug_print(f"Verarbeite Zielvariable '{techniker_col}'...")
# Konvertiere zu Numerisch (Fehler -> NaN)
df_subset['Anzahl_Servicetechniker_Numeric'] = pd.to_numeric(df_subset[techniker_col], errors='coerce')
# Filtere Zeilen: Behalte nur die mit gültiger, positiver Technikerzahl
initial_rows = len(df_subset)
df_filtered = df_subset[
df_subset['Anzahl_Servicetechniker_Numeric'].notna() &
(df_subset['Anzahl_Servicetechniker_Numeric'] > 0)
].copy()
filtered_rows = len(df_filtered)
debug_print(f"{initial_rows - filtered_rows} Zeilen entfernt aufgrund fehlender/ungültiger Technikerzahl.")
debug_print(f"Verbleibende Zeilen für Modellierung: {filtered_rows}")
if filtered_rows == 0:
debug_print("FEHLER: Keine Zeilen mit gültiger Technikerzahl übrig!")
return None
# --- 4. Techniker-Buckets erstellen ---
bins = [-1, 0, 19, 49, 99, 249, 499, float('inf')] # -1 um 0 einzuschließen
# Labels sollten keine Sonderzeichen enthalten, die Probleme machen könnten
labels = ['Bucket_1_0', 'Bucket_2_<20', 'Bucket_3_<50', 'Bucket_4_<100', 'Bucket_5_<250', 'Bucket_6_<500', 'Bucket_7_>499']
df_filtered['Techniker_Bucket'] = pd.cut(
df_filtered['Anzahl_Servicetechniker_Numeric'],
bins=bins,
labels=labels,
right=True # 19 gehört zu <20, 49 zu <50 etc.
)
debug_print("Techniker-Buckets erstellt.")
debug_print(f"Verteilung der Buckets:\n{df_filtered['Techniker_Bucket'].value_counts()}")
# --- 5. Kategoriale Features vorbereiten (Branche) ---
branche_col = "CRM Branche" # Annahme: CRM Branche ist die zu verwendende
debug_print(f"Verarbeite kategoriales Feature '{branche_col}'...")
# Stelle sicher, dass die Spalte String ist und fülle evtl. NaNs
df_filtered[branche_col] = df_filtered[branche_col].astype(str).fillna('Unbekannt')
# One-Hot Encoding
df_encoded = pd.get_dummies(df_filtered, columns=[branche_col], prefix='Branche', dummy_na=False) # dummy_na=False: keine extra Spalte für NaN
debug_print(f"One-Hot Encoding für Branche durchgeführt. Neue Spaltenanzahl: {len(df_encoded.columns)}")
# --- 6. Finale Auswahl der Features für das Modell ---
# Liste aller Feature-Spalten (One-Hot Branchen + numerische)
feature_columns = [col for col in df_encoded.columns if col.startswith('Branche_')]
feature_columns.extend(['Finaler_Umsatz', 'Finaler_Mitarbeiter'])
# Zielspalte
target_column = 'Techniker_Bucket'
# Erstelle den finalen DataFrame
df_model_ready = df_encoded[feature_columns + [target_column]].copy()
# Optional: Spalten auf einfache Typen reduzieren (kann Speicher sparen)
for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter']:
df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce')
# Reset Index für saubere Verarbeitung im nächsten Schritt
df_model_ready = df_model_ready.reset_index(drop=True)
debug_print("Datenvorbereitung abgeschlossen.")
debug_print(f"Finaler DataFrame für Modellierung hat {len(df_model_ready)} Zeilen und {len(df_model_ready.columns)} Spalten.")
debug_print(f"Feature-Spalten: {feature_columns}")
debug_print(f"Ziel-Spalte: {target_column}")
# WICHTIG: Dieser DataFrame enthält noch NaNs in 'Finaler_Umsatz'/'Finaler_Mitarbeiter'!
# Die Imputation sollte NACH dem Train/Test Split erfolgen.
nan_counts = df_model_ready[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].isna().sum()
debug_print(f"Fehlende Werte in numerischen Features:\n{nan_counts}")
return df_model_ready
except Exception as e:
debug_print(f"FEHLER während der Datenvorbereitung: {e}")
import traceback
debug_print(traceback.format_exc())
return None
# --- Beispielhafter Aufruf (zum Testen) ---
# if __name__ == '__main__':
# # Annahme: Config, COLUMN_MAP, debug_print sind definiert
# # Annahme: GoogleSheetHandler existiert und verbindet sich
# Config.load_api_keys() # Nur falls für extract_numeric_value nötig
# LOG_FILE = create_log_filename("dataprep_test")
# debug_print("Starte Test der Datenvorbereitung...")
# try:
# sheet_handler_instance = GoogleSheetHandler()
# prepared_df = prepare_data_for_modeling(sheet_handler_instance)
# if prepared_df is not None:
# print("\n--- Vorbereiteter DataFrame (erste 5 Zeilen): ---")
# print(prepared_df.head())
# print("\n--- DataFrame Info: ---")
# prepared_df.info()
# print("\n--- Beschreibung numerischer Features: ---")
# print(prepared_df[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].describe())
# else:
# print("Datenvorbereitung fehlgeschlagen.")
# except Exception as e:
# print(f"Fehler beim Testaufruf: {e}")
# ==================== RETRY-DECORATOR ====================
def retry_on_failure(func):
def wrapper(*args, **kwargs):
func_name = func.__name__
# Versuche, das 'self' Argument für Methoden zu extrahieren
self_arg = args[0] if args and hasattr(args[0], func_name) else None
effective_func_name = f"{self_arg.__class__.__name__}.{func_name}" if self_arg else func_name
for attempt in range(Config.MAX_RETRIES):
try:
return func(*args, **kwargs)
except Exception as e:
error_msg = str(e)
# Spezifische Fehlerbehandlung (Beispiel)
if isinstance(e, gspread.exceptions.APIError):
if e.response.status_code == 429: # Rate Limit
wait_time = Config.RETRY_DELAY * (attempt + 1) # Exponential backoff
print(f"🚦 Rate Limit bei {effective_func_name} (Versuch {attempt+1}). Warte {wait_time}s... Fehler: {error_msg[:100]}")
time.sleep(wait_time)
continue # Direkt zum nächsten Versuch
else:
print(f"⚠️ Google API Fehler bei {effective_func_name} (Versuch {attempt+1}): {error_msg[:100]}")
elif isinstance(e, requests.exceptions.RequestException):
print(f"⚠️ Netzwerkfehler bei {effective_func_name} (Versuch {attempt+1}): {error_msg[:100]}")
elif isinstance(e, openai.error.OpenAIError):
print(f"⚠️ OpenAI Fehler bei {effective_func_name} (Versuch {attempt+1}): {error_msg[:100]}")
else:
print(f"⚠️ Unbekannter Fehler bei {effective_func_name} (Versuch {attempt+1}): {type(e).__name__} - {error_msg[:100]}")
if attempt < Config.MAX_RETRIES - 1:
time.sleep(Config.RETRY_DELAY)
else:
print(f"❌ Endgültiger Fehler bei {effective_func_name} nach {Config.MAX_RETRIES} Versuchen.")
return None # Oder eine spezifische Fehlerkennung zurückgeben
return None # Sollte nicht erreicht werden, aber zur Sicherheit
return wrapper
# ==================== LOGGING & HELPER FUNCTIONS ====================
LOG_FILE = None # Wird in main() gesetzt
def create_log_filename(mode):
if not os.path.exists(LOG_DIR):
os.makedirs(LOG_DIR)
now = datetime.now().strftime("%d-%m-%Y_%H-%M")
ver_short = Config.VERSION.replace(".", "")
return os.path.join(LOG_DIR, f"{now}_{ver_short}_Modus{mode}.txt")
def debug_print(message):
global LOG_FILE
log_message = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {message}"
if Config.DEBUG:
print(log_message)
if LOG_FILE:
try:
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(log_message + "\n")
except Exception as e:
print(f"[CRITICAL] Log-Schreibfehler: {e}")
def simple_normalize_url(url):
"""Normalisiert URL zu www.domain.tld oder k.A."""
if not url or not isinstance(url, str):
return "k.A."
url = url.strip()
if not url:
return "k.A."
# Falls kein Schema vorhanden ist, hinzufügen
if not url.lower().startswith(("http://", "https://")):
url = "https://" + url
try:
parsed = urlparse(url)
domain_part = parsed.netloc
# Entferne einen eventuellen Port (z.B. ":8080")
domain_part = domain_part.split(":", 1)[0]
# Wenn die Domain nicht mit "www." beginnt, hinzufügen (außer bei sehr kurzen Domains)
if not domain_part.lower().startswith("www.") and '.' in domain_part:
# Ausnahme für IP-Adressen oder ungewöhnliche Namen ohne TLD
if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", domain_part):
pass # IP-Adresse behalten
else:
domain_part = "www." + domain_part
return domain_part.lower() # Einheitliche Kleinschreibung
except Exception as e:
debug_print(f"Fehler bei URL-Normalisierung '{url}': {e}")
return "k.A."
def normalize_string(s):
"""Normalisiert Umlaute und Sonderzeichen."""
if not s or not isinstance(s, str):
return ""
replacements = {
'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue', 'ß': 'ss', 'ä': 'ae', 'ö': 'oe', 'ü': 'ue',
'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Å': 'A', 'Æ': 'AE', 'à': 'a', 'á': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'æ': 'ae',
'Ç': 'C', 'ç': 'c', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e',
'Ì': 'I', 'Í': 'I', 'Î': 'I', 'Ï': 'I', 'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i', 'Ñ': 'N', 'ñ': 'n',
'Ò': 'O', 'Ó': 'O', 'Ô': 'O', 'Õ': 'O', 'Ø': 'O', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', 'ø': 'o', 'Œ': 'OE', 'œ': 'oe',
'Š': 'S', 'š': 's', 'Ž': 'Z', 'ž': 'z', 'Ý': 'Y', 'ý': 'y', 'ÿ': 'y', 'Đ': 'D', 'đ': 'd',
'č': 'c', 'Č': 'C', 'ć': 'c', 'Ć': 'C', 'ł': 'l', 'Ł': 'L', 'ğ': 'g', 'Ğ': 'G', 'ş': 's', 'Ş': 'S',
'ă': 'a', 'Ă': 'A', 'ı': 'i', 'İ': 'I', 'ň': 'n', 'Ň': 'N', 'ř': 'r', 'Ř': 'R',
'ő': 'o', 'Ő': 'O', 'ű': 'u', 'Ű': 'U', 'ț': 't', 'Ț': 'T', 'ș': 's', 'Ș': 'S'
}
# unicodedata Normalisierung zuerst (kann einige Akzente entfernen)
try:
# Versuche NFKD Normalisierung, um Kompatibilitätszeichen zu zerlegen
s = unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode('ascii')
except:
# Fallback, wenn NFKD fehlschlägt (sollte selten sein)
pass
# Dann manuelle Ersetzungen
for src, target in replacements.items():
s = s.replace(src, target)
return s
def clean_text(text):
"""Bereinigt Text von Wikipedia etc."""
if not text:
return "k.A."
try:
text = str(text) # Sicherstellen, dass es ein String ist
text = unicodedata.normalize("NFKC", text) # Normalisiert Whitespace, Ligaturen etc.
text = re.sub(r'\[\d+\]', '', text) # Entfernt [1], [2] etc.
text = re.sub(r'\s+', ' ', text).strip() # Reduziert multiple Leerzeichen
return text if text else "k.A."
except Exception as e:
debug_print(f"Fehler bei clean_text: {e}")
return "k.A."
def normalize_company_name(name):
"""Entfernt Rechtsformzusätze etc. für Vergleiche."""
if not name: return ""
name = clean_text(name) # Vorab bereinigen
# Umfassendere Liste von Rechtsformen und Zusätzen
forms = [
r'gmbh', r'ges\.?\s*m\.?\s*b\.?\s*h\.?', r'gesellschaft mit beschränkter haftung',
r'ug', r'u\.g\.', r'unternehmergesellschaft', r'haftungsbeschränkt',
r'ag', r'a\.g\.', r'aktiengesellschaft',
r'ohg', r'o\.h\.g\.', r'offene handelsgesellschaft',
r'kg', r'k\.g\.', r'kommanditgesellschaft',
r'gmbh\s*&\s*co\.?\s*kg', r'ges\.?\s*m\.?\s*b\.?\s*h\.?\s*&\s*co\.?\s*k\.g\.?',
r'ag\s*&\s*co\.?\s*kg', r'a\.g\.?\s*&\s*co\.?\s*k\.g\.?',
r'e\.k\.', r'e\.kfm\.', r'e\.kfr\.', r'eingetragene[rn]? kauffrau', r'eingetragene[rn]? kaufmann',
r'ltd\.?', r'limited',
r'ltd\s*&\s*co\.?\s*kg',
r's\.?a\.?r\.?l\.?', r'sàrl', r'sagl',
r's\.?a\.?', r'société anonyme', r'sociedad anónima',
r's\.?p\.?a\.?', r'società per azioni',
r'b\.?v\.?', r'besloten vennootschap',
r'n\.?v\.?', r'naamloze vennootschap',
r'plc\.?', r'public limited company',
r'inc\.?', r'incorporated',
r'corp\.?', r'corporation',
r'llc\.?', r'limited liability company',
r'kgaa', r'kommanditgesellschaft auf aktien',
r'se', r'societas europaea',
r'e\.?g\.?', r'eingetragene genossenschaft', r'genossenschaft', r'genmbh',
r'e\.?v\.?', r'eingetragener verein', r'verein',
r'stiftung', r'ggmbh', r'gemeinnützige gmbh', r'gug',
r'partg', r'partnerschaftsgesellschaft', r'partgmbb',
r'og', r'o\.g\.', r'offene gesellschaft',
r'e\.u\.', r'eingetragenes unternehmen',
r'ges\.?n\.?b\.?r\.?', r'gesellschaft nach bürgerlichem recht',
r'kollektivgesellschaft', r'einzelfirma',
# Zusätzliche generische Begriffe am Ende
r'gruppe', r'holding', r'international', r'systeme', r'technik', r'logistik',
r'solutions', r'services', r'management', r'consulting', r'produktion',
r'vertrieb', r'entwicklung', r'maschinenbau', r'anlagenbau'
]
# Pattern für ganze Wörter (case-insensitive)
pattern = r'\b(' + '|'.join(forms) + r')\b'
normalized = re.sub(pattern, '', name, flags=re.IGNORECASE)
# Interpunktion entfernen/ersetzen (außer evtl. &)
normalized = re.sub(r'[.,;:]', '', normalized)
normalized = re.sub(r'[\-/]', ' ', normalized) # Bindestriche etc. durch Leerzeichen ersetzen
normalized = re.sub(r'\s+', ' ', normalized).strip() # Multiple Leerzeichen reduzieren
return normalized.lower()
def extract_numeric_value(raw_value, is_umsatz=False):
"""Extrahiert und normalisiert Zahlenwerte (Umsatz in Mio, Mitarbeiter)."""
if not raw_value or not isinstance(raw_value, str): return "k.A."
raw_value = clean_text(raw_value)
if raw_value == "k.A.": return "k.A."
# Entferne Präfixe wie ca., über, etc. und Währungssymbole (€, $, etc.) und Punkte als Tausendertrenner
processed_value = re.sub(r'(?i)\b(ca\.?|circa|über|unter|rund|etwa|mehr als|weniger als|bis zu)\b', '', raw_value)
processed_value = re.sub(r'[€$£¥]', '', processed_value)
processed_value = processed_value.replace('.', '') # Tausenderpunkte entfernen
processed_value = processed_value.replace(',', '.') # Komma als Dezimaltrenner
# Finde die erste Zahl (kann Dezimalpunkt enthalten)
match = re.search(r'([\d\.]+)', processed_value)
if not match:
debug_print(f"Keine numerischen Zeichen gefunden in Rohtext: '{raw_value}'")
return "k.A."
num_str = match.group(1)
try:
num = float(num_str)
except ValueError:
debug_print(f"Fehler bei Float-Umwandlung von '{num_str}' (aus '{raw_value}')")
return "k.A." # Gib k.A. zurück, wenn die Zahl selbst ungültig ist
# Multiplikatoren anwenden (Groß/Kleinschreibung ignorieren)
raw_lower = raw_value.lower()
multiplier = 1.0
if "mrd" in raw_lower or "milliarden" in raw_lower or "billion" in raw_lower: # Englisch Billion = Deutsch Milliarde
multiplier = 1000.0 # Für Umsatz: Ergebnis wird in Mio sein
elif "mio" in raw_lower or "millionen" in raw_lower or "mill." in raw_lower:
multiplier = 1.0 # Für Umsatz: Ergebnis ist bereits in Mio
elif "tsd" in raw_lower or "tausend" in raw_lower:
multiplier = 0.001 # Für Umsatz: Umrechnung Tausend in Mio
num = num * multiplier
if is_umsatz:
# Umsatz immer auf Millionen runden (Ganzzahl)
return str(int(round(num)))
else:
# Mitarbeiter als Ganzzahl
return str(int(round(num)))
def get_gender(firstname):
"""Ermittelt Geschlecht via gender-guesser und Fallback Genderize API."""
if not firstname or not isinstance(firstname, str): return "unknown"
firstname = firstname.strip().split(" ")[0] # Nur ersten Teil des Vornamens verwenden
if not firstname: return "unknown"
d = gender.Detector(case_sensitive=False)
result = d.get_gender(firstname, 'germany') # Land hinzufügen kann helfen
if result in ["andy", "unknown", "mostly_male", "mostly_female"]:
genderize_key = Config.API_KEYS.get('genderize')
if not genderize_key:
debug_print("Genderize API-Schlüssel nicht verfügbar, Fallback nicht möglich.")
return result if result not in ["andy", "unknown"] else "unknown" # Gib mostly_ zurück
params = {"name": firstname, "apikey": genderize_key, "country_id": "DE"}
try:
response = requests.get("https://api.genderize.io", params=params, timeout=5)
response.raise_for_status() # Fehler bei HTTP-Status != 200
data = response.json()
# Genderize gibt 'male'/'female' oder null zurück
api_gender = data.get("gender")
probability = data.get("probability", 0)
if api_gender and probability > 0.6: # Nur bei ausreichender Sicherheit übernehmen
return api_gender
else:
# Wenn Genderize unsicher ist, behalte das Ergebnis von gender-guesser, wenn es "mostly_" war
return result if result not in ["andy", "unknown"] else "unknown"
except requests.exceptions.RequestException as e:
debug_print(f"Fehler bei der Genderize API-Anfrage für '{firstname}': {e}")
return result if result not in ["andy", "unknown"] else "unknown"
except Exception as e:
debug_print(f"Allgemeiner Fehler bei Genderize für '{firstname}': {e}")
return result if result not in ["andy", "unknown"] else "unknown"
else: # male, female
return result
def get_email_address(firstname, lastname, website):
"""Generiert E-Mail: vorname.nachname@domain.tld."""
if not all([firstname, lastname, website]) or not all(isinstance(x, str) for x in [firstname, lastname, website]):
return ""
domain = simple_normalize_url(website)
if domain == "k.A." or not '.' in domain: # Einfache Domain-Validierung
return ""
# Domain von 'www.' befreien, falls simple_normalize_url es nicht schon getan hat
if domain.startswith("www."):
domain = domain[4:]
# Vor- und Nachname normalisieren (Umlaute etc.), Kleinbuchstaben, keine Sonderzeichen außer '.' und '-' erlauben
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 oder Bindestrich sind
normalized_first = re.sub(r'[^\w\-]+', '', normalized_first)
normalized_last = re.sub(r'[^\w\-]+', '', normalized_last)
if normalized_first and normalized_last and domain:
return f"{normalized_first}.{normalized_last}@{domain}"
else:
return ""
def fuzzy_similarity(str1, str2):
"""Berechnet Ähnlichkeit zwischen 0 und 1."""
if not str1 or not str2: return 0.0
return SequenceMatcher(None, str(str1).lower(), str(str2).lower()).ratio()
# ==================== BRANCH MAPPING & SCHEMA ====================
import re # Sicherstellen, dass re importiert ist
# Annahmen:
# - Die globalen Variablen ALLOWED_TARGET_BRANCHES und TARGET_SCHEMA_STRING werden
# durch load_target_schema() korrekt befüllt (enthalten nur Kurzformen).
# - Die Funktion call_openai_chat(prompt, temperature) existiert und funktioniert.
# - Die Funktion debug_print(message) existiert.
# - Die globale Variable Config.API_KEYS['openai'] ist verfügbar.
def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kategorien, website_summary):
"""
Ordnet das Unternehmen basierend auf den angegebenen Informationen exakt einer Branche
aus dem Ziel-Branchenschema (nur Kurzformen) zu. Validiert den ChatGPT-Vorschlag
strikt gegen die erlaubten Kurzformen und führt einen Fallback auf die (extrahierte)
CRM-Kurzform durch, falls der Vorschlag ungültig ist.
Args:
crm_branche (str): Branche laut CRM (kann noch Präfix enthalten).
beschreibung (str): Unternehmensbeschreibung (CRM).
wiki_branche (str): Branche aus Wikipedia (falls vorhanden).
wiki_kategorien (str): Wikipedia-Kategorien.
website_summary (str): Zusammenfassung des Website-Inhalts.
Returns:
dict: Enthält "branch" (die finale, gültige Kurzform oder Fehler),
"consistency" ('ok', 'X', 'fallback_crm_valid', 'fallback_invalid') und
"justification" (Begründung von ChatGPT oder Fallback-Info).
"""
# Globale Variablen für Schema und erlaubte Branches verwenden
global ALLOWED_TARGET_BRANCHES, TARGET_SCHEMA_STRING
# Grundlegende Prüfung: Ist das Schema überhaupt geladen?
if not ALLOWED_TARGET_BRANCHES:
debug_print("FEHLER in evaluate_branche_chatgpt: Ziel-Branchenschema (ALLOWED_TARGET_BRANCHES) ist leer. Abbruch.")
# Gib den CRM-Wert zurück, aber markiere als Fehler
return {"branch": crm_branche, "consistency": "error_schema_missing", "justification": "Fehler: Ziel-Schema nicht geladen"}
# Erstelle ein Set/Dict der erlaubten Branches in Kleinbuchstaben für effizientes Nachschlagen
# Speichert die Originalschreibweise als Wert.
allowed_branches_lookup = {b.lower(): b for b in ALLOWED_TARGET_BRANCHES}
# --- Prompt für ChatGPT erstellen ---
# Beginne mit den Regeln und der Liste der gültigen Kurzformen
prompt_parts = [TARGET_SCHEMA_STRING] # TARGET_SCHEMA_STRING sollte bereits die klare Anweisung enthalten
prompt_parts.append("\nOrdne das Unternehmen anhand folgender Angaben exakt einer Branche des Ziel-Branchenschemas (Kurzformen) zu:")
# Füge nur vorhandene Informationen hinzu und kürze sie ggf.
if crm_branche and crm_branche != "k.A.": prompt_parts.append(f"- CRM-Branche (Referenz): {crm_branche}")
if beschreibung and beschreibung != "k.A.": prompt_parts.append(f"- Beschreibung: {beschreibung[:500]}") # Kürzen
if wiki_branche and wiki_branche != "k.A.": prompt_parts.append(f"- Wikipedia-Branche: {wiki_branche}")
if wiki_kategorien and wiki_kategorien != "k.A.": prompt_parts.append(f"- Wikipedia-Kategorien: {wiki_kategorien[:500]}") # Kürzen
if website_summary and website_summary != "k.A.": prompt_parts.append(f"- Website-Zusammenfassung: {website_summary[:500]}") # Kürzen
# Fallback, wenn gar keine spezifischen Infos da sind
if len(prompt_parts) <= 2:
debug_print("Warnung in evaluate_branche_chatgpt: Zu wenige Informationen für Branchenevaluierung.")
return {"branch": crm_branche, "consistency": "error_no_info", "justification": "Fehler: Zu wenige Informationen für eine Einschätzung"}
# Füge die strengen Anweisungen für das Antwortformat hinzu
prompt_parts.append("\nWICHTIG: Antworte NUR mit dem exakten Kurznamen einer Branche aus der obigen Liste. Verwende KEINE Präfixe wie 'Hersteller / Produzenten >' oder 'Service provider (Dienstleister) >'.")
prompt_parts.append("\nAntworte ausschließlich im folgenden Format (keine Einleitung, kein Schlusssatz):")
prompt_parts.append("Branche: <Exakter Kurzname der Branche aus der Liste>")
prompt_parts.append("Übereinstimmung: <ok oder X (Vergleich deines Vorschlags mit der extrahierten Kurzform der CRM-Referenz)>")
prompt_parts.append("Begründung: <Sehr kurze Begründung für deinen Branchenvorschlag>")
prompt = "\n".join(prompt_parts)
# --- ChatGPT aufrufen ---
chat_response = call_openai_chat(prompt, temperature=0.0) # Niedrige Temperatur für konsistente Zuordnung
if not chat_response:
debug_print("Fehler in evaluate_branche_chatgpt: Keine Antwort von OpenAI erhalten.")
return {"branch": crm_branche, "consistency": "error_api_no_response", "justification": "Fehler: Keine Antwort von API"}
# --- Antwort parsen ---
lines = chat_response.strip().split("\n")
result = {"branch": None, "consistency": None, "justification": ""} # Initialisiere mit None
suggested_branch = ""
for line in lines:
line_lower = line.lower()
if line_lower.startswith("branche:"):
suggested_branch = line.split(":", 1)[1].strip()
# Entferne mögliche Anführungszeichen
suggested_branch = suggested_branch.strip('"\'')
elif line_lower.startswith("übereinstimmung:"):
# Wir überschreiben die Konsistenz später basierend auf unserer Logik
pass
elif line_lower.startswith("begründung:"):
result["justification"] = line.split(":", 1)[1].strip()
if not suggested_branch:
debug_print(f"Fehler in evaluate_branche_chatgpt: Konnte 'Branche:' nicht aus Antwort parsen: {chat_response}")
return {"branch": crm_branche, "consistency": "error_parsing", "justification": f"Fehler: Parsing der API Antwort fehlgeschlagen. Antwort: {chat_response}"}
# --- Validierung des ChatGPT-Vorschlags ---
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] # Nimm korrekte Schreibweise
debug_print(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist gültig ('{final_branch}').")
# Konsistenz wird später gesetzt
result["consistency"] = "pending_comparison" # Temporärer Status
else:
# --- Fallback-Logik ---
debug_print(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist NICHT im Ziel-Schema ({len(ALLOWED_TARGET_BRANCHES)} Einträge) enthalten. Starte Fallback...")
# Versuche Kurzform aus CRM-Branche zu extrahieren
crm_short_branch = "k.A."
if crm_branche and ">" in crm_branche:
crm_short_branch = crm_branche.split(">", 1)[1].strip()
elif crm_branche and crm_branche != "k.A.": # Wenn CRM schon Kurzform sein könnte
crm_short_branch = crm_branche.strip()
debug_print(f"Fallback Debug: Prüfe CRM-Kurzform.")
debug_print(f" -> Extrahierte CRM-Kurzform: '{crm_short_branch}' (Typ: {type(crm_short_branch)})")
crm_short_branch_lower = crm_short_branch.lower()
debug_print(f" -> CRM-Kurzform (lower): '{crm_short_branch_lower}'")
# Zeige einige Lookup-Keys (nur wenn nicht zu viele)
lookup_keys_sample = list(allowed_branches_lookup.keys())
if len(lookup_keys_sample) < 20:
debug_print(f" -> Prüfe gegen Lookup-Keys: {lookup_keys_sample}")
else:
debug_print(f" -> Prüfe gegen Lookup-Keys (erste 10): {lookup_keys_sample[:10]}")
# Der eigentliche Check
if crm_short_branch != "k.A." and crm_short_branch_lower in allowed_branches_lookup:
debug_print(f" -> ERFOLG: '{crm_short_branch_lower}' in allowed_branches_lookup gefunden!") # NEU
final_branch = allowed_branches_lookup[crm_short_branch_lower] # Nimm korrekte Schreibweise
result["consistency"] = "fallback_crm_valid" # Setze Fallback-Status
# Kombiniere ChatGPT Begründung (falls vorhanden) mit Fallback-Info
fallback_reason = f"Fallback: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}'). Gültige CRM-Kurzform '{final_branch}' verwendet."
result["justification"] = f"{fallback_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})"
debug_print(f"Fallback auf gültige CRM-Kurzform erfolgreich: '{final_branch}'")
else:
debug_print(f" -> FEHLER: '{crm_short_branch_lower}' NICHT in allowed_branches_lookup gefunden!") # NEU
# Wenn auch CRM-Kurzform ungültig oder nicht extrahierbar
final_branch = suggested_branch # Behalte ungültigen Vorschlag
result["consistency"] = "fallback_invalid" # Setze Fehler-Fallback-Status
error_reason = f"Fehler: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}') und keine gültige CRM-Kurzform ('{crm_short_branch}') als Fallback verfügbar."
result["justification"] = f"{error_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})"
debug_print(f"Fallback fehlgeschlagen. Ungültiger Vorschlag: '{final_branch}', Ungültige CRM-Kurzform: '{crm_short_branch}'")
# Alternativ: Gib einen speziellen Fehlerwert zurück
# final_branch = "FEHLER - UNGÜLTIGE ZUWEISUNG"
# Setze den finalen Branch im Ergebnis-Dictionary
result["branch"] = final_branch if final_branch else "FEHLER"
# --- Konsistenzprüfung (Finale Bewertung) ---
# Extrahiere CRM-Kurzform für den Vergleich (erneut oder Variable von oben)
crm_short_to_compare = "k.A."
if crm_branche and ">" in crm_branche:
crm_short_to_compare = crm_branche.split(">", 1)[1].strip()
elif crm_branche and crm_branche != "k.A.":
crm_short_to_compare = crm_branche.strip()
# Vergleiche finalen Branch (falls nicht FEHLER) mit CRM-Kurzform (case-insensitive)
if result["branch"] != "FEHLER" and result["branch"].lower() == crm_short_to_compare.lower():
# Wenn sie übereinstimmen UND *kein* Fallback stattgefunden hat, ist es 'ok'.
if result["consistency"] == "pending_comparison":
result["consistency"] = "ok"
# Wenn Fallback auf gültige CRM stattfand (Status 'fallback_crm_valid'), bleibt dieser Status.
elif result["consistency"] == "pending_comparison":
# Wenn sie nicht übereinstimmen und kein Fallback stattfand, ist es 'X'.
result["consistency"] = "X"
# Wenn der Status bereits 'fallback_crm_valid' oder 'fallback_invalid' ist, bleibt er unverändert.
elif result["consistency"] is None: # Sollte nicht passieren, aber zur Sicherheit
result["consistency"] = "error_unknown_state"
# Entferne den temporären Status, falls er noch da ist
if result["consistency"] == "pending_comparison":
result["consistency"] = "error_comparison_failed"
# Debug-Ausgabe des finalen Ergebnisses vor Rückgabe
debug_print(f"Finale Branch-Evaluation: {result}")
return result
def load_target_schema(csv_filepath=BRANCH_MAPPING_FILE):
"""Lädt Liste erlaubter Ziele (Kurzformen) aus Spalte A der CSV."""
global BRANCH_MAPPING, TARGET_SCHEMA_STRING, ALLOWED_TARGET_BRANCHES
# BRANCH_MAPPING wird nicht mehr benötigt, wenn wir nur die Ziele laden
BRANCH_MAPPING = {}
allowed_branches_set = set()
debug_print(f"Versuche, Ziel-Schema (Kurzformen) aus '{csv_filepath}' Spalte A zu laden...") # NEU
line_count = 0
try:
with open(csv_filepath, encoding="utf-8-sig") as f:
reader = csv.reader(f)
# Optional: Header überspringen
# next(reader, None)
for row in reader:
line_count += 1
if line_count <= 10 or line_count % 100 == 0:
debug_print(f"Schema-Laden: Lese Zeile {line_count}: {row}")
if len(row) >= 1: # Nur Spalte A (Index 0) wird benötigt
target = row[0].strip()
if target: # Nur nicht-leere Einträge hinzufügen
allowed_branches_set.add(target)
if line_count <= 10: # Logge die ersten 10 hinzugefügten
debug_print(f" -> '{target}' zum Set hinzugefügt.")
except FileNotFoundError:
debug_print(f"Fehler: Schema-Datei '{csv_filepath}' nicht gefunden.")
ALLOWED_TARGET_BRANCHES = []
except Exception as e:
debug_print(f"Fehler beim Laden des Ziel-Schemas aus '{csv_filepath}' (Zeile {line_count}): {e}")
ALLOWED_TARGET_BRANCHES = []
ALLOWED_TARGET_BRANCHES = sorted(list(allowed_branches_set), key=str.lower)
debug_print(f"Ziel-Schema geladen. {len(ALLOWED_TARGET_BRANCHES)} eindeutige Zielbranchen gefunden.") # NEU: Zählung der Branches
# Logge die ersten paar geladenen Branches zur Kontrolle
if ALLOWED_TARGET_BRANCHES:
debug_print(f"Erste 10 geladene Zielbranchen: {ALLOWED_TARGET_BRANCHES[:10]}")
schema_lines = ["Ziel-Branchenschema: Folgende Branchenbereiche sind gültig (Kurzformen):"] # Klarstellung
schema_lines.extend(f"- {branch}" for branch in ALLOWED_TARGET_BRANCHES)
schema_lines.append("Bitte ordne das Unternehmen ausschließlich in einen dieser Bereiche ein. Gib NUR den Kurznamen der Branche zurück (keine Präfixe wie 'Hersteller / Produzenten >').") # Strengere Anweisung
TARGET_SCHEMA_STRING = "\n".join(schema_lines)
else:
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar (Datei leer oder Fehler)."
ALLOWED_TARGET_BRANCHES = []
def map_external_branch(external_branch):
"""
Versucht, eine externe Branchenbezeichnung mithilfe des Mappings in das Ziel-Schema zu überführen.
Nutzt Normalisierung und Teilstring-Matching als Fallback.
"""
if not external_branch or not isinstance(external_branch, str) or not BRANCH_MAPPING:
return external_branch # Gib Original zurück, wenn kein Input oder kein Mapping
norm_external = normalize_string(external_branch).lower()
# 1. Exaktes Match (nach Normalisierung)
if norm_external in BRANCH_MAPPING:
return BRANCH_MAPPING[norm_external]
# 2. Teilstring-Match (prüfe, ob ein Mapping-Key im normalisierten Input enthalten ist)
# Sortiere Keys nach Länge (absteigend), um spezifischere Treffer zu bevorzugen
sorted_keys = sorted(BRANCH_MAPPING.keys(), key=len, reverse=True)
for key in sorted_keys:
if key in norm_external:
debug_print(f"Teilstring-Match für Branche: '{key}' in '{norm_external}' -> '{BRANCH_MAPPING[key]}'")
return BRANCH_MAPPING[key]
# 3. Kein Mapping gefunden
debug_print(f"Kein Mapping für externe Branche '{external_branch}' (normalisiert: '{norm_external}') gefunden.")
return external_branch # Gib Original zurück, wenn kein Mapping passt
# ==================== TOKEN COUNT FUNCTION ====================
@retry_on_failure
def token_count(text):
"""Zählt Tokens via tiktoken oder schätzt über Leerzeichen."""
if not text or not isinstance(text, str): return 0
if tiktoken:
try:
# Cache encoding object per model
if not hasattr(token_count, 'enc_cache'):
token_count.enc_cache = {}
if Config.TOKEN_MODEL not in token_count.enc_cache:
token_count.enc_cache[Config.TOKEN_MODEL] = tiktoken.encoding_for_model(Config.TOKEN_MODEL)
enc = token_count.enc_cache[Config.TOKEN_MODEL]
return len(enc.encode(text))
except Exception as e:
debug_print(f"Fehler beim Token-Counting mit tiktoken für Modell '{Config.TOKEN_MODEL}': {e}")
# Fallback zur Schätzung
return len(text.split())
else:
# Fallback Schätzung
return len(text.split())
# ==================== GOOGLE SHEET HANDLER ====================
# Annahmen:
# - Globale Variablen/Konstanten: retry_on_failure, Config, CREDENTIALS_FILE, Config.SHEET_URL, debug_print, COLUMN_MAP
# - COLUMN_MAP enthält den Schlüssel "Website Scrape Timestamp" mit dem korrekten Index (45)
class GoogleSheetHandler:
def __init__(self):
# ... (init und _connect unverändert) ...
self.sheet = None
self.sheet_values = []
self.headers = []
try: self._connect();
except Exception as e: raise ConnectionError(f"Google Sheet Handler Init failed: {e}")
if self.sheet: self.load_data() # Lade Daten initial
@retry_on_failure
def _connect(self):
# ... (unverändert) ...
self.sheet = None; debug_print("Verbinde mit Google Sheets...")
try:
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; debug_print("Verbindung zu Google Sheets erfolgreich.")
except Exception as e: debug_print(f"FEHLER Connect: {e}"); raise e
@retry_on_failure
def load_data(self):
# ... (unverändert) ...
if not self.sheet: return False
debug_print("Lade Daten aus Google Sheet...")
try:
self.sheet_values = self.sheet.get_all_values()
if not self.sheet_values: self.headers=[]; return True
if len(self.sheet_values) >= 1: self.headers = self.sheet_values[0]
else: self.headers = []
debug_print(f"Daten neu geladen: {len(self.sheet_values)} Zeilen insgesamt.")
return True
except Exception as e: debug_print(f"FEHLER Laden: {e}"); raise e
def get_data(self):
# ... (unverändert) ...
header_rows = 5
if not self.sheet_values or len(self.sheet_values) <= header_rows: return []
return self.sheet_values[header_rows:]
def get_all_data_with_headers(self):
# ... (unverändert) ...
if not self.sheet_values: return []
return self.sheet_values
def _get_col_letter(self, col_idx_1_based):
# ... (unverändert) ...
string = ""; n = col_idx_1_based;
if n < 1: return None
while n > 0: n, remainder = divmod(n - 1, 26); string = chr(65 + remainder) + string
return string
# Prüft jetzt auf Werte in der `empty_values` Liste (case-insensitive)
def get_start_row_index(self, check_column_key, min_sheet_row=7, empty_values=None):
"""Findet erste Zeile, deren Wert in check_column_key als leer gilt."""
# --- KORRIGIERT: Standardwerte für leere Strings ---
if empty_values is None:
empty_values = ["", "k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]
if not self.load_data(): return -1
header_rows = 5
data_rows = self.get_data()
if not data_rows: return 0
check_column_index = COLUMN_MAP.get(check_column_key)
if check_column_index is None:
debug_print(f"FEHLER: Schlüssel '{check_column_key}' nicht in COLUMN_MAP gefunden!")
return -1
actual_col_letter = self._get_col_letter(check_column_index + 1)
search_start_index_in_data = max(0, min_sheet_row - header_rows - 1)
debug_print(f"get_start_row_index: Suche ab Daten-Index {search_start_index_in_data} nach Wert in {empty_values} in Spalte '{check_column_key}' ({actual_col_letter})...")
if search_start_index_in_data >= len(data_rows):
debug_print(f"Start-Suchindex ({search_start_index_in_data}) >= Datenlänge ({len(data_rows)}). Alle geprüft.")
return len(data_rows)
for i in range(search_start_index_in_data, len(data_rows)):
row = data_rows[i]
current_sheet_row = i + header_rows + 1
cell_value_str_lower = "FEHLER_INDEX" # Fallback
is_considered_empty = True # Annahme: Ist leer
if len(row) > check_column_index:
cell_value_str_lower = str(row[check_column_index]).strip().lower()
if cell_value_str_lower not in empty_values:
is_considered_empty = False
# else: is_considered_empty bleibt True (Spalte zu kurz = leer)
# Logge nur relevante Prüfungen
if i == search_start_index_in_data or i % 1000 == 0 or is_considered_empty:
debug_print(f" -> Prüfe Daten-Index {i} (Sheet {current_sheet_row}): Wert in {actual_col_letter}='{cell_value_str_lower}'. Gilt als leer? {is_considered_empty}")
if is_considered_empty:
debug_print(f"Erste Zeile ab {min_sheet_row} mit leerem Wert in Spalte {actual_col_letter} gefunden: Zeile {current_sheet_row} (Daten-Index {i})")
return i
last_index = len(data_rows)
debug_print(f"Alle Zeilen ab Daten-Index {search_start_index_in_data} haben einen nicht-leeren Wert in Spalte {actual_col_letter}. Nächster Daten-Index wäre {last_index}.")
return last_index
@retry_on_failure
def batch_update_cells(self, update_data):
# ... (unverändert) ...
if not self.sheet: return False
if not update_data: return True
try: self.sheet.batch_update(update_data, value_input_option='USER_ENTERED'); return True
except Exception as e: debug_print(f"FEHLER Batch Update: {e}"); raise e
# --- Ende GoogleSheetHandler Klasse ---
# ==================== WIKIPEDIA SCRAPER ====================
class WikipediaScraper:
def __init__(self):
try:
wikipedia.set_lang(Config.LANG)
except Exception as e:
debug_print(f"Fehler beim Setzen der Wikipedia-Sprache: {e}")
def _get_full_domain(self, website):
"""Extrahiert Domain (ohne www, ohne Pfad) aus URL."""
if not website or not isinstance(website, str): return ""
# Nutze die normalisierte URL
normalized_url = simple_normalize_url(website)
if normalized_url == "k.A.": return ""
# Entferne 'www.' falls vorhanden
if normalized_url.startswith("www."):
return normalized_url[4:]
return normalized_url
def _generate_search_terms(self, company_name, website):
"""Generiert Suchbegriffe für Wikipedia."""
terms = set() # Verwende Set, um Duplikate zu vermeiden
# 1. Domain (ohne www)
full_domain = self._get_full_domain(website)
if full_domain:
terms.add(full_domain.split('.')[0]) # Nur der Domain-Name selbst
# 2. Normalisierter Firmenname (verschiedene Längen)
normalized_name = normalize_company_name(company_name)
if normalized_name:
name_parts = normalized_name.split()
if len(name_parts) > 0:
terms.add(name_parts[0]) # Erstes Wort
if len(name_parts) > 1:
terms.add(" ".join(name_parts[:2])) # Erste zwei Worte
terms.add(normalized_name) # Ganzer normalisierter Name
# 3. Original Firmenname (falls abweichend und nicht zu lang)
original_name_cleaned = clean_text(company_name).lower()
if original_name_cleaned != normalized_name and len(original_name_cleaned) < 50:
terms.add(original_name_cleaned)
# Filter leere Strings und konvertiere zu Liste
final_terms = [term for term in terms if term]
debug_print(f"Generierte Wikipedia-Suchbegriffe für '{company_name}': {final_terms}")
return final_terms
@retry_on_failure
def _fetch_page_content(self, page_title):
"""Lädt eine Wikipedia-Seite sicher."""
try:
# Nutze page() mit auto_suggest=False und preload=True für Effizienz
page = wikipedia.page(page_title, auto_suggest=False, preload=True)
return page
except wikipedia.exceptions.PageError:
debug_print(f"Wikipedia PageError: Seite '{page_title}' nicht gefunden.")
return None
except wikipedia.exceptions.DisambiguationError as e:
debug_print(f"Wikipedia DisambiguationError für '{page_title}': {e.options[:5]}")
# Optional: Versuche, die erste Option automatisch zu wählen?
# try:
# return wikipedia.page(e.options[0], auto_suggest=False, preload=True)
# except Exception as inner_e:
# debug_print(f"Fehler beim Laden der ersten Disambiguation-Option: {inner_e}")
# return None
return None # Vorerst keine automatische Auswahl
except Exception as e:
debug_print(f"Allgemeiner Fehler beim Laden der Wikipedia-Seite '{page_title}': {e}")
return None
@retry_on_failure
def _fetch_page_html(self, page_url):
""" Lädt HTML einer Seite für manuelles Parsing. """
try:
response = requests.get(page_url, timeout=10)
response.raise_for_status()
return response.text
except requests.exceptions.RequestException as e:
debug_print(f"Fehler beim Abrufen von HTML von {page_url}: {e}")
return None
def _validate_article(self, page, company_name, website):
"""Prüft Ähnlichkeit Titel vs. Name und ob Domain im Artikel vorkommt."""
if not page: return False
page_title = page.title
normalized_title = normalize_company_name(page_title)
normalized_company = normalize_company_name(company_name)
# 1. Ähnlichkeitsprüfung der Namen
name_similarity = fuzzy_similarity(normalized_title, normalized_company)
debug_print(f"Namensähnlichkeit für '{page_title}': {name_similarity:.2f} ('{normalized_title}' vs '{normalized_company}')")
# 2. Domain-Prüfung
full_domain = self._get_full_domain(website)
domain_found = False
if full_domain:
try:
# Prüfe externe Links zuerst (effizienter)
if hasattr(page, 'externallinks'):
for ext_link in page.externallinks:
if full_domain in ext_link.lower():
debug_print(f"Domain '{full_domain}' in externem Link gefunden: {ext_link}")
domain_found = True
break
# Wenn nicht gefunden, prüfe Infobox (aufwändiger, erfordert HTML-Parsing)
if not domain_found:
html_content = self._fetch_page_html(page.url)
if html_content:
soup = BeautifulSoup(html_content, Config.HTML_PARSER)
infobox = soup.find('table', class_=lambda c: c and 'infobox' in c.lower())
if infobox:
links = infobox.find_all('a', href=True)
for link in links:
href = link.get('href', '').lower()
# Suche nach der Domain in externen Links innerhalb der Infobox
if full_domain in href and ('http://' in href or 'https://' in href):
debug_print(f"Domain '{full_domain}' in Infobox-Link gefunden: {href}")
domain_found = True
break
except Exception as e:
debug_print(f"Fehler bei der Domain-Validierung für '{page_title}': {e}")
# 3. Entscheidung
# Hohe Ähnlichkeit ODER moderate Ähnlichkeit UND Domain gefunden
threshold = Config.SIMILARITY_THRESHOLD
if name_similarity >= threshold + 0.1: # Bei sehr hoher Ähnlichkeit
debug_print(f"Validierung OK (Hohe Namensähnlichkeit): {page_title}")
return True
if name_similarity >= threshold - 0.1 and domain_found: # Bei moderater Ähnlichkeit, wenn Domain passt
debug_print(f"Validierung OK (Moderate Ähnlichkeit + Domain gefunden): {page_title}")
return True
debug_print(f"Validierung fehlgeschlagen für '{page_title}' (Ähnlichkeit: {name_similarity:.2f}, Domain gefunden: {domain_found})")
return False
def extract_first_paragraph(self, page_content):
"""Extrahiert den ersten sinnvollen Absatz aus dem Seiteninhalt."""
if not page_content: return "k.A."
# Nutze page.summary, da dies oft der erste Absatz ist
summary = clean_text(page_content)
if len(summary) > 50:
# Begrenze Länge, um nicht zu viel Text zu haben
return summary[:1000] # Max 1000 Zeichen
return "k.A."
def _extract_infobox_data(self, page_url):
"""Extrahiert Branche, Umsatz, Mitarbeiter aus der Infobox (via HTML)."""
data = {'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.'}
html_content = self._fetch_page_html(page_url)
if not html_content: return data
try:
soup = BeautifulSoup(html_content, Config.HTML_PARSER)
# Finde Infobox (flexiblere Suche nach Klassen)
infobox = soup.find('table', class_=lambda c: c and any(kw in c.lower() for kw in ['infobox', 'vcard', 'unternehmen']))
if not infobox: return data
# Definiere Keywords für jede Information
keywords_map = {
'branche': ['branche', 'industrie', 'tätigkeit', 'geschäftsfeld', 'sektor', 'produkte', 'leistungen', 'wirtschaftszweig'],
'umsatz': ['umsatz', 'jahresumsatz', 'erlöse', 'umsatzerlöse', 'einnahmen', 'ergebnis'],
'mitarbeiter': ['mitarbeiter', 'beschäftigte', 'personal', 'mitarbeiterzahl', 'angestellte', 'belegschaft']
}
rows = infobox.find_all('tr')
for row in rows:
header = row.find('th')
value_cell = row.find('td')
if header and value_cell:
header_text = clean_text(header.get_text()).lower()
raw_value_text = value_cell.get_text(separator=' ', strip=True) # Text aus der Zelle holen
# Suche nach Keywords in der Kopfzeile
for key, keywords in keywords_map.items():
if any(kw in header_text for kw in keywords):
# Wenn ein Keyword passt, verarbeite den Wert
if key == 'branche':
# Für Branche: Bereinige Referenzen und Klammern, bevor clean_text
cleaned_branch = re.sub(r'\[.*?\]|\(.*?\)', '', raw_value_text)
data['branche'] = clean_text(cleaned_branch)
elif key == 'umsatz':
data['umsatz'] = extract_numeric_value(raw_value_text, is_umsatz=True)
elif key == 'mitarbeiter':
data['mitarbeiter'] = extract_numeric_value(raw_value_text, is_umsatz=False)
# Optional: break, wenn ein Wert für diese Zeile gefunden wurde?
# break # Verhindert, dass z.B. "Umsatz" auch als "Ergebnis" interpretiert wird, falls beide Keywords passen
# Fallback: Manchmal steht die Branche ohne explizites th da
if data['branche'] == 'k.A.':
possible_branches = infobox.select('tr > td[colspan="2"]') # Suche nach Zellen über 2 Spalten
for pb in possible_branches:
pb_text = clean_text(pb.get_text())
# Prüfe, ob Text nach Branche aussieht (keine Zahlen, nicht zu lang)
if pb_text and not any(char.isdigit() for char in pb_text) and len(pb_text) < 100:
is_likely_branch = True
for kw_list in keywords_map.values(): # Nicht mit anderen Keywords verwechseln
if any(kw in pb_text.lower() for kw in kw_list):
is_likely_branch = False
break
if is_likely_branch:
data['branche'] = pb_text
break
except Exception as e:
debug_print(f"Fehler beim Parsen der Infobox von {page_url}: {e}")
return data
def extract_categories(self, page_url):
"""Extrahiert Kategorien (via HTML)."""
html_content = self._fetch_page_html(page_url)
if not html_content: return "k.A."
try:
soup = BeautifulSoup(html_content, Config.HTML_PARSER)
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')]
# Filtere leere Kategorien und Standardkategorien
cats = [cat for cat in cats if cat and cat != "Kategorien:" and "Wikipedia:" not in cat]
return ", ".join(cats) if cats else "k.A."
except Exception as e:
debug_print(f"Fehler beim Extrahieren der Kategorien von {page_url}: {e}")
return "k.A."
def extract_company_data(self, page_url):
"""Extrahiert alle relevanten Daten von einer Wikipedia-Seite."""
default_data = {
'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 page_url == 'k.A.':
return default_data
# Lade Seiteninhalt über die wikipedia library (für summary)
page = self._fetch_page_content(page_url.split('/')[-1]) # Nutze Titel aus URL
if not page:
# Wenn Seite nicht geladen werden kann, HTML trotzdem versuchen zu parsen
debug_print(f"Konnte Seite '{page_url}' nicht über Wikipedia-Lib laden, versuche HTML-Parsing.")
# Extrahiere Daten, die HTML benötigen
infobox_data = self._extract_infobox_data(page_url)
categories_val = self.extract_categories(page_url)
# Extrahiere Absatz (nutze page.summary wenn verfügbar, sonst leer)
first_paragraph = self.extract_first_paragraph(page.summary) if page else "k.A."
# Kombiniere Ergebnisse
company_data = {
'url': page_url,
'first_paragraph': first_paragraph,
'branche': infobox_data['branche'],
'umsatz': infobox_data['umsatz'],
'mitarbeiter': infobox_data['mitarbeiter'],
'categories': categories_val
}
# debug_print(f"Extrahierte Wiki-Daten für {page_url}: {company_data}")
return company_data
# retry_on_failure ist hier schon drauf
def search_company_article(self, company_name, website):
"""Sucht nach einem passenden Wikipedia-Artikel."""
search_terms = self._generate_search_terms(company_name, website)
if not search_terms:
debug_print("Keine Suchbegriffe generiert, Wikipedia-Suche übersprungen.")
return None
for term in search_terms:
try:
# wikipedia.search gibt Titel zurück
results = wikipedia.search(term, results=Config.WIKIPEDIA_SEARCH_RESULTS)
debug_print(f"Wikipedia-Suchergebnisse für '{term}': {results}")
for title in results:
page = self._fetch_page_content(title)
if page and self._validate_article(page, company_name, website):
debug_print(f"Passenden Wikipedia-Artikel gefunden: {page.url}")
return page # Gib das Page-Objekt zurück
except Exception as e:
# Fehler bei der Suche selbst (Netzwerk etc.)
debug_print(f"Fehler während der Wikipedia-Suche für '{term}': {e}")
# Hier nicht abbrechen, sondern nächsten Suchbegriff versuchen
continue # Zum nächsten Suchbegriff
debug_print(f"Kein passender Wikipedia-Artikel für '{company_name}' gefunden.")
return None
# ==================== WEBSITE SCRAPING ====================
@retry_on_failure
def get_website_raw(url, max_length=1000, verify_cert=False):
"""Holt Textinhalt von einer Website, versucht Cookie-Banner zu umgehen."""
if not url or not isinstance(url, str) or url.strip().lower() == 'k.a.':
return "k.A."
if not url.lower().startswith("http"):
url = "https://" + url
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36"
}
try:
response = requests.get(url, timeout=15, headers=headers, verify=verify_cert) # Etwas längerer Timeout
response.raise_for_status()
response.encoding = response.apparent_encoding
soup = BeautifulSoup(response.text, Config.HTML_PARSER)
# --- Versuch 1: Hauptinhalt-Tags finden ---
content_area = (
soup.find('main') or # Suche <main>
soup.find('article') or # Oder <article>
soup.find(id='content') or # Oder <div id="content">
soup.find(id='main-content') or # Oder <div id="main-content">
soup.find(class_='main-content') or # Oder <div class="main-content">
soup.find(class_='content') # Oder <div class="content">
# Füge ggf. weitere spezifische Selektoren hinzu, die du oft siehst
)
if content_area:
debug_print(f"Gezielten Inhaltsbereich gefunden ({content_area.name}#{content_area.get('id')} oder .{content_area.get('class')}) für {url}")
else:
# --- Fallback: Body nehmen, ABER Banner versuchen zu entfernen ---
debug_print(f"Kein spezifischer Inhaltsbereich gefunden für {url}. Nutze Body und versuche Banner zu entfernen.")
content_area = soup.find('body')
if content_area:
# Versuche, häufige Cookie-Banner Strukturen zu entfernen
banner_selectors = [
'[id*="cookie"]', # IDs die "cookie" enthalten
'[class*="cookie"]', # Klassen die "cookie" enthalten
'[id*="consent"]', # IDs die "consent" enthalten
'[class*="consent"]', # Klassen die "consent" enthalten
'[id*="banner"]', # IDs die "banner" enthalten (vorsichtig!)
'[class*="banner"]', # Klassen die "banner" enthalten (vorsichtig!)
'[role="dialog"]' # Oft für Popups/Banner genutzt (vorsichtig!)
]
banners_removed_count = 0
for selector in banner_selectors:
try:
potential_banners = content_area.select(selector)
for banner in potential_banners:
# Zusätzliche Prüfung: Enthält das Element typischen Banner-Text?
banner_text = banner.get_text(" ", strip=True).lower()
keywords = ["cookie", "zustimm", "ablehnen", "einverstanden", "datenschutz", "privacy", "akzeptier"]
if any(keyword in banner_text for keyword in keywords):
debug_print(f"Entferne potenzielles Banner ({selector}) mit Text: {banner_text[:100]}...")
banner.decompose() # Entferne das Element aus dem Baum
banners_removed_count += 1
except Exception as e_select:
debug_print(f"Fehler beim Versuch Banner mit Selektor '{selector}' zu entfernen: {e_select}")
if banners_removed_count > 0:
debug_print(f"{banners_removed_count} potenzielle Banner-Elemente entfernt.")
# --- Text extrahieren aus gefundenem Bereich (oder Body) ---
if content_area:
for script_or_style in content_area(["script", "style"]): # Skripte/Styles entfernen
script_or_style.decompose()
text = content_area.get_text(separator=' ', strip=True)
text = re.sub(r'\s+', ' ', text) # Normalisiere Whitespace
# --- Zusätzliche Prüfung: Ist der extrahierte Text *nur* Banner-Text? ---
banner_keywords_strict = ["cookie", "zustimmen", "ablehnen", "einverstanden", "datenschutz", "privacy", "akzeptier", "einstellung", "partner", "analyse", "marketing"]
text_lower = text.lower()
keyword_hits = sum(1 for keyword in banner_keywords_strict if keyword in text_lower)
# Heuristik: Wenn der Text kurz ist UND viele Banner-Keywords enthält -> Verwerfen
if len(text) < 500 and keyword_hits >= 3:
debug_print(f"WARNUNG: Extrahierter Text für {url} scheint nur Cookie-Banner zu sein (Länge {len(text)}, {keyword_hits} Keywords). Verwerfe Text.")
return "k.A. (Nur Cookie-Banner erkannt)"
result = text[:max_length]
debug_print(f"Website {url} erfolgreich gescrapt. Extrahierter Text (Länge {len(result)}): {result[:100]}...")
return result
else:
debug_print(f"Kein <body> oder spezifischer Inhaltsbereich gefunden in {url}")
return "k.A."
except requests.exceptions.SSLError as e:
debug_print(f"SSL-Fehler beim Abrufen der Website {url}: {e}. Versuche ohne Zertifikatsprüfung...")
if verify_cert:
return get_website_raw(url, max_length, verify_cert=False)
else:
return "k.A."
except requests.exceptions.RequestException as e:
debug_print(f"Netzwerk-/HTTP-Fehler beim Abrufen der Website {url}: {e}")
return "k.A."
except Exception as e:
debug_print(f"Allgemeiner Fehler beim Scraping von {url}: {e}")
return "k.A."
# Die Hilfsfunktion summarize_batch_openai wird weiterhin benötigt
# (Code dafür bleibt wie in der Antwort von 16:24 Uhr)
@retry_on_failure
def summarize_batch_openai(tasks_data):
# ... (Implementierung wie zuvor) ...
if not tasks_data: return {}
valid_tasks = [t for t in tasks_data if t.get("raw_text") and t["raw_text"] != "k.A." and t["raw_text"].strip()]
if not valid_tasks: return {t['row_num']: "k.A. (Kein gültiger Rohtext)" for t in tasks_data}
debug_print(f"Starte Batch-Zusammenfassung für {len(valid_tasks)} gültige Texte...")
prompt_parts = [
"Du bist ein KI-Assistent...", # Gekürzt für Lesbarkeit
"RESULTAT <Zeilennummer>: <Zusammenfassung für diese Zeilennummer>",
"\n--- Texte zur Zusammenfassung ---"
]
text_block = ""
row_numbers_in_batch = []
total_chars = 0
max_chars_per_batch = 6000
for task in valid_tasks:
row_num = task['row_num']
raw_text = task['raw_text']
entry_text = f"\n--- TEXT Zeile {row_num} ---\n{raw_text}\n--- ENDE TEXT Zeile {row_num} ---\n"
if total_chars + len(entry_text) > max_chars_per_batch:
debug_print(f"WARNUNG: Batch-Zeichenlimit ({max_chars_per_batch}) erreicht bei Zeile {row_num}.")
continue
text_block += entry_text
total_chars += len(entry_text)
row_numbers_in_batch.append(row_num)
if not row_numbers_in_batch:
debug_print("Keine Texte im Batch nach Längenprüfung für OpenAI.")
return {t['row_num']: "k.A. (Batch-Limit erreicht)" for t in valid_tasks}
prompt_parts.append(text_block)
prompt_parts.append("--- Ende der Texte ---")
prompt_parts.append("Bitte gib NUR die 'RESULTAT <Zeilennummer>: ...' Zeilen zurück.")
final_prompt = "\n".join(prompt_parts)
chat_response = call_openai_chat(final_prompt, temperature=0.2)
summaries = {row_num: "k.A. (Keine Antwort geparst)" for row_num in row_numbers_in_batch}
if chat_response:
lines = chat_response.strip().split('\n')
parsed_count = 0
for line in lines:
match = re.match(r"RESULTAT (\d+): (.*)", line.strip())
if match:
row_num = int(match.group(1))
summary_text = match.group(2).strip()
if row_num in summaries: summaries[row_num] = summary_text; parsed_count += 1
debug_print(f"Batch-Zusammenfassung: {parsed_count} von {len(row_numbers_in_batch)} erfolgreich geparst.")
if parsed_count < len(row_numbers_in_batch): debug_print(f"WARNUNG: Nicht alle Zusammenfassungen geparst. Antwort: {chat_response[:500]}...")
else: debug_print("Fehler: Keine gültige Antwort von OpenAI für Batch-Zusammenfassung.")
# Füge k.A. für Tasks hinzu, die ursprünglich gültigen Text hatten, aber evtl. wegen Limit nicht im Batch waren
for task in valid_tasks:
if task['row_num'] not in summaries: summaries[task['row_num']] = "k.A. (Nicht im OpenAI-Batch)"
# Füge k.A. für Tasks hinzu, die ungültigen Rohtext hatten
for task in tasks_data:
if task['row_num'] not in summaries: summaries[task['row_num']] = "k.A. (Ungültiger Rohtext)"
return summaries
@retry_on_failure
def scrape_website_details(url):
"""Extrahiert Title, Description, H1-H3 von einer Website."""
if not url or not isinstance(url, str) or url.strip().lower() == 'k.a.':
return "k.A."
if not url.lower().startswith("http"):
url = "https://" + url
headers = {"User-Agent": "Mozilla/5.0"}
try:
response = requests.get(url, timeout=10, headers=headers, verify=False) # Oft nötig bei vielen Seiten
response.raise_for_status()
response.encoding = response.apparent_encoding
soup = BeautifulSoup(response.text, Config.HTML_PARSER)
# Title
title_tag = soup.find("title")
title = clean_text(title_tag.get_text()) if title_tag else "k.A."
# Description
meta_tag = soup.find("meta", attrs={"name": lambda x: x and x.lower() == "description"})
description = clean_text(meta_tag["content"]) if meta_tag and meta_tag.get("content") else "k.A."
# Headers H1-H3
headers_data = {}
for tag in ["h1", "h2", "h3"]:
elements = soup.find_all(tag)
header_texts = [clean_text(el.get_text()) for el in elements]
header_texts = [h for h in header_texts if h != "k.A." and len(h) > 2] # Filtern
headers_data[tag] = ", ".join(header_texts[:5]) if header_texts else "k.A." # Max 5 pro Typ
combined = (
f"Title: {title} | Description: {description} | "
f"H1: {headers_data['h1']} | H2: {headers_data['h2']} | H3: {headers_data['h3']}"
)
# Kürze ggf. das Gesamtergebnis
return combined[:1500] # Limit Gesamtstring
except requests.exceptions.RequestException as e:
debug_print(f"Netzwerk-/HTTP-Fehler beim Detail-Scraping von {url}: {e}")
return "k.A."
except Exception as e:
debug_print(f"Allgemeiner Fehler beim Detail-Scraping von {url}: {e}")
return "k.A."
# ==================== OPENAI / CHATGPT FUNCTIONS ====================
@retry_on_failure
def call_openai_chat(prompt, temperature=0.3, model=None):
"""Zentrale Funktion für OpenAI Chat API Aufrufe."""
if not Config.API_KEYS.get('openai'):
debug_print("Fehler: OpenAI API Key nicht konfiguriert.")
return None
if not prompt:
debug_print("Fehler: Leerer Prompt für OpenAI.")
return None
current_model = model if model else Config.TOKEN_MODEL
try:
# Token zählen vor dem Senden (optional, aber gut für Debugging)
# prompt_tokens = token_count(prompt)
# debug_print(f"Sende Prompt an OpenAI ({current_model}, {prompt_tokens} Tokens)...")
response = openai.ChatCompletion.create(
model=current_model,
messages=[{"role": "user", "content": prompt}],
temperature=temperature
)
result = response.choices[0].message.content.strip()
# Token zählen für die Antwort
# completion_tokens = token_count(result)
# total_tokens = response.usage.total_tokens
# debug_print(f"OpenAI Antwort erhalten ({completion_tokens} Completion Tokens, {total_tokens} Gesamt).")
return result
except openai.error.InvalidRequestError as e:
debug_print(f"OpenAI Invalid Request Error: {e}")
# Hier könnte man prüfen, ob es am Token Limit liegt
if "maximum context length" in str(e):
debug_print("Fehler scheint Token Limit zu sein. Prompt evtl. zu lang.")
# TODO: Strategie für zu lange Prompts (kürzen, splitten?)
return None
except openai.error.OpenAIError as e: # Fängt RateLimitError, APIError etc. ab
debug_print(f"OpenAI API Fehler: {e}")
raise # Fehler weitergeben, damit retry_on_failure greifen kann
except Exception as e:
debug_print(f"Allgemeiner Fehler bei OpenAI-Aufruf: {e}")
raise # Fehler weitergeben
def summarize_website_content(raw_text):
"""Erstellt Zusammenfassung von Website-Rohtext via OpenAI."""
if not raw_text or raw_text == "k.A." or raw_text.strip() == "":
return "k.A."
# Kürze den Rohtext, falls er sehr lang ist, um Token zu sparen/Limits zu vermeiden
max_raw_length = 3000 # Zeichenlimit für den Input der Zusammenfassung
if len(raw_text) > max_raw_length:
debug_print(f"Kürze Rohtext für Zusammenfassung von {len(raw_text)} auf {max_raw_length} Zeichen.")
raw_text = raw_text[:max_raw_length]
prompt = (
"Du bist ein KI-Assistent, der Webinhalte analysiert.\n"
"Fasse den folgenden Text einer Unternehmenswebsite prägnant zusammen. "
"Konzentriere dich auf:\n"
"- Haupttätigkeitsfeld des Unternehmens\n"
"- Wichtigste Produkte und/oder Dienstleistungen\n"
"- Zielgruppe (falls erkennbar)\n\n"
f"Website-Text:\n```\n{raw_text}\n```\n\n"
"Zusammenfassung (max. 100 Wörter):"
)
summary = call_openai_chat(prompt, temperature=0.2)
return summary if summary else "k.A."
def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kategorien, website_summary):
"""
Ordnet das Unternehmen basierend auf den angegebenen Informationen exakt einer Branche
aus dem Ziel-Branchenschema (nur Kurzformen) zu. Validiert den ChatGPT-Vorschlag
strikt gegen die erlaubten Kurzformen und führt einen Fallback auf die (extrahierte)
CRM-Kurzform durch, falls der Vorschlag ungültig ist.
Args:
crm_branche (str): Branche laut CRM (kann noch Präfix enthalten).
beschreibung (str): Unternehmensbeschreibung (CRM).
wiki_branche (str): Branche aus Wikipedia (falls vorhanden).
wiki_kategorien (str): Wikipedia-Kategorien.
website_summary (str): Zusammenfassung des Website-Inhalts.
Returns:
dict: Enthält "branch" (die finale, gültige Kurzform oder Fehler),
"consistency" ('ok', 'X', 'fallback_crm_valid', 'fallback_invalid') und
"justification" (Begründung von ChatGPT oder Fallback-Info).
"""
# Globale Variablen für Schema und erlaubte Branches verwenden
global ALLOWED_TARGET_BRANCHES, TARGET_SCHEMA_STRING
# Grundlegende Prüfung: Ist das Schema überhaupt geladen?
if not ALLOWED_TARGET_BRANCHES:
debug_print("FEHLER in evaluate_branche_chatgpt: Ziel-Branchenschema (ALLOWED_TARGET_BRANCHES) ist leer. Abbruch.")
# Gib den CRM-Wert zurück, aber markiere als Fehler
return {"branch": crm_branche, "consistency": "error_schema_missing", "justification": "Fehler: Ziel-Schema nicht geladen"}
# Erstelle ein Set/Dict der erlaubten Branches in Kleinbuchstaben für effizientes Nachschlagen
# Speichert die Originalschreibweise als Wert.
allowed_branches_lookup = {b.lower(): b for b in ALLOWED_TARGET_BRANCHES}
# --- Prompt für ChatGPT erstellen ---
# Beginne mit den Regeln und der Liste der gültigen Kurzformen
prompt_parts = [TARGET_SCHEMA_STRING] # TARGET_SCHEMA_STRING sollte bereits die klare Anweisung enthalten
prompt_parts.append("\nOrdne das Unternehmen anhand folgender Angaben exakt einer Branche des Ziel-Branchenschemas (Kurzformen) zu:")
# Füge nur vorhandene Informationen hinzu und kürze sie ggf.
if crm_branche and crm_branche != "k.A.": prompt_parts.append(f"- CRM-Branche (Referenz): {crm_branche}")
if beschreibung and beschreibung != "k.A.": prompt_parts.append(f"- Beschreibung: {beschreibung[:500]}") # Kürzen
if wiki_branche and wiki_branche != "k.A.": prompt_parts.append(f"- Wikipedia-Branche: {wiki_branche}")
if wiki_kategorien and wiki_kategorien != "k.A.": prompt_parts.append(f"- Wikipedia-Kategorien: {wiki_kategorien[:500]}") # Kürzen
if website_summary and website_summary != "k.A.": prompt_parts.append(f"- Website-Zusammenfassung: {website_summary[:500]}") # Kürzen
# Fallback, wenn gar keine spezifischen Infos da sind
if len(prompt_parts) <= 2:
debug_print("Warnung in evaluate_branche_chatgpt: Zu wenige Informationen für Branchenevaluierung.")
return {"branch": crm_branche, "consistency": "error_no_info", "justification": "Fehler: Zu wenige Informationen für eine Einschätzung"}
# Füge die strengen Anweisungen für das Antwortformat hinzu
prompt_parts.append("\nWICHTIG: Antworte NUR mit dem exakten Kurznamen einer Branche aus der obigen Liste. Verwende KEINE Präfixe wie 'Hersteller / Produzenten >' oder 'Service provider (Dienstleister) >'.")
prompt_parts.append("\nAntworte ausschließlich im folgenden Format (keine Einleitung, kein Schlusssatz):")
prompt_parts.append("Branche: <Exakter Kurzname der Branche aus der Liste>")
prompt_parts.append("Übereinstimmung: <ok oder X (Vergleich deines Vorschlags mit der extrahierten Kurzform der CRM-Referenz)>")
prompt_parts.append("Begründung: <Sehr kurze Begründung für deinen Branchenvorschlag>")
prompt = "\n".join(prompt_parts)
# --- ChatGPT aufrufen ---
chat_response = call_openai_chat(prompt, temperature=0.0) # Niedrige Temperatur für konsistente Zuordnung
if not chat_response:
debug_print("Fehler in evaluate_branche_chatgpt: Keine Antwort von OpenAI erhalten.")
return {"branch": crm_branche, "consistency": "error_api_no_response", "justification": "Fehler: Keine Antwort von API"}
# --- Antwort parsen ---
lines = chat_response.strip().split("\n")
result = {"branch": None, "consistency": None, "justification": ""} # Initialisiere mit None
suggested_branch = ""
for line in lines:
line_lower = line.lower()
if line_lower.startswith("branche:"):
suggested_branch = line.split(":", 1)[1].strip()
# Entferne mögliche Anführungszeichen
suggested_branch = suggested_branch.strip('"\'')
elif line_lower.startswith("übereinstimmung:"):
# Wir überschreiben die Konsistenz später basierend auf unserer Logik
pass
elif line_lower.startswith("begründung:"):
result["justification"] = line.split(":", 1)[1].strip()
if not suggested_branch:
debug_print(f"Fehler in evaluate_branche_chatgpt: Konnte 'Branche:' nicht aus Antwort parsen: {chat_response}")
# Optional: Versuche Begründung als Branche zu nehmen? Eher nicht.
return {"branch": crm_branche, "consistency": "error_parsing", "justification": f"Fehler: Parsing der API Antwort fehlgeschlagen. Antwort: {chat_response}"}
# --- Validierung des ChatGPT-Vorschlags ---
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] # Nimm korrekte Schreibweise
debug_print(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist gültig ('{final_branch}').")
# Konsistenz wird später gesetzt
result["consistency"] = "pending_comparison" # Temporärer Status
else:
# --- Fallback-Logik ---
debug_print(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist NICHT im Ziel-Schema ({len(ALLOWED_TARGET_BRANCHES)} Einträge) enthalten. Starte Fallback...")
# Versuche Kurzform aus CRM-Branche zu extrahieren
crm_short_branch = "k.A."
if crm_branche and ">" in crm_branche:
crm_short_branch = crm_branche.split(">", 1)[1].strip()
elif crm_branche and crm_branche != "k.A.": # Wenn CRM schon Kurzform sein könnte
crm_short_branch = crm_branche.strip()
# Prüfe, ob die extrahierte CRM-Kurzform gültig ist
if crm_short_branch != "k.A." and crm_short_branch.lower() in allowed_branches_lookup:
final_branch = allowed_branches_lookup[crm_short_branch.lower()] # Nimm korrekte Schreibweise
result["consistency"] = "fallback_crm_valid" # Setze Fallback-Status
# Kombiniere ChatGPT Begründung (falls vorhanden) mit Fallback-Info
fallback_reason = f"Fallback: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}'). Gültige CRM-Kurzform '{final_branch}' verwendet."
result["justification"] = f"{fallback_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})"
debug_print(f"Fallback auf gültige CRM-Kurzform erfolgreich: '{final_branch}'")
else:
# Wenn auch CRM-Kurzform ungültig oder nicht extrahierbar
final_branch = suggested_branch # Behalte ungültigen Vorschlag
result["consistency"] = "fallback_invalid" # Setze Fehler-Fallback-Status
error_reason = f"Fehler: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}') und keine gültige CRM-Kurzform ('{crm_short_branch}') als Fallback verfügbar."
result["justification"] = f"{error_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})"
debug_print(f"Fallback fehlgeschlagen. Ungültiger Vorschlag: '{final_branch}', Ungültige CRM-Kurzform: '{crm_short_branch}'")
# Alternativ: Gib einen speziellen Fehlerwert zurück
# final_branch = "FEHLER - UNGÜLTIGE ZUWEISUNG"
# Setze den finalen Branch im Ergebnis-Dictionary
result["branch"] = final_branch if final_branch else "FEHLER"
# --- Konsistenzprüfung (Finale Bewertung) ---
# Extrahiere CRM-Kurzform für den Vergleich (erneut oder Variable von oben)
crm_short_to_compare = "k.A."
if crm_branche and ">" in crm_branche:
crm_short_to_compare = crm_branche.split(">", 1)[1].strip()
elif crm_branche and crm_branche != "k.A.":
crm_short_to_compare = crm_branche.strip()
# Vergleiche finalen Branch (falls nicht FEHLER) mit CRM-Kurzform (case-insensitive)
if result["branch"] != "FEHLER" and result["branch"].lower() == crm_short_to_compare.lower():
# Wenn sie übereinstimmen UND *kein* Fallback stattgefunden hat, ist es 'ok'.
if result["consistency"] == "pending_comparison":
result["consistency"] = "ok"
# Wenn Fallback auf gültige CRM stattfand (Status 'fallback_crm_valid'), bleibt dieser Status.
elif result["consistency"] == "pending_comparison":
# Wenn sie nicht übereinstimmen und kein Fallback stattfand, ist es 'X'.
result["consistency"] = "X"
# Wenn der Status bereits 'fallback_crm_valid' oder 'fallback_invalid' ist, bleibt er unverändert.
elif result["consistency"] is None: # Sollte nicht passieren, aber zur Sicherheit
result["consistency"] = "error_unknown_state"
# Entferne den temporären Status, falls er noch da ist
if result["consistency"] == "pending_comparison":
result["consistency"] = "error_comparison_failed"
# Debug-Ausgabe des finalen Ergebnisses vor Rückgabe
debug_print(f"Finale Branch-Evaluation: {result}")
return result
# TODO: Weitere ChatGPT-Funktionen (evaluate_fsm_suitability, etc.) analog überarbeiten:
# - Prompts verbessern (klarere Anweisungen, Kontext nur bei Bedarf)
# - call_openai_chat verwenden
# - Parsing der Antworten robuster machen
def process_wiki_verification(crm_data, wiki_data_str):
# Platzhalter - Implementierung anpassen oder entfernen, falls durch _process_batch abgedeckt
debug_print(f"TODO: process_wiki_verification aufrufen/implementieren für {crm_data}")
return "k.A. (Not Implemented)"
def evaluate_fsm_suitability(company_name, company_data):
# Platzhalter - Implementierung anpassen
debug_print(f"TODO: evaluate_fsm_suitability aufrufen/implementieren für {company_name}")
return {"suitability": "k.A.", "justification": "Not Implemented"}
def evaluate_servicetechnicians_estimate(company_name, company_data):
# Platzhalter - Implementierung anpassen
debug_print(f"TODO: evaluate_servicetechnicians_estimate aufrufen/implementieren für {company_name}")
return "k.A. (Not Implemented)"
def map_internal_technicians(value):
# Platzhalter - Implementierung anpassen
debug_print(f"TODO: map_internal_technicians aufrufen/implementieren für {value}")
return "k.A. (Not Implemented)"
def evaluate_servicetechnicians_explanation(company_name, st_estimate, company_data):
# Platzhalter - Implementierung anpassen
debug_print(f"TODO: evaluate_servicetechnicians_explanation aufrufen/implementieren für {company_name}")
return "k.A. (Not Implemented)"
def process_employee_estimation(company_name, wiki_paragraph, crm_employee):
# Platzhalter - Implementierung anpassen
debug_print(f"TODO: process_employee_estimation aufrufen/implementieren für {company_name}")
return "k.A. (Not Implemented)"
def process_employee_consistency(crm_employee, wiki_employee, emp_estimate):
# Platzhalter - Implementierung anpassen
debug_print(f"TODO: process_employee_consistency aufrufen/implementieren für {crm_employee} vs {wiki_employee} vs {emp_estimate}")
return "k.A. (Not Implemented)"
def evaluate_umsatz_chatgpt(company_name, wiki_umsatz):
# Platzhalter - Implementierung anpassen
debug_print(f"TODO: evaluate_umsatz_chatgpt aufrufen/implementieren für {company_name}")
return "k.A. (Not Implemented)"
# ==================== BATCH PROCESSING FUNCTIONS ====================
def _process_batch(sheet, batches, row_numbers):
"""
Hilfsfunktion für process_verification_only: Verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen.
Aktualisiert NUR die Spalten S bis Y. Zeitstempel (AN, AO, AP) werden von
der aufrufenden Funktion oder anderen Prozessen gesetzt.
Args:
sheet (gspread.Worksheet): Das Worksheet-Objekt zum Schreiben.
batches (list): Liste der Prompt-Teile für den Batch.
row_numbers (list): Liste der zugehörigen Zeilennummern.
"""
if not batches:
return
# --- Prompt Erstellung (wie gehabt) ---
aggregated_prompt = (
"Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln für Unternehmen. "
"Für jeden der folgenden Einträge prüfe, ob der vorhandene Wikipedia-Artikel (URL, Absatz, Kategorien) plausibel zum Firmennamen und zur Beschreibung passt. "
"Gib das Ergebnis für jeden Eintrag ausschließlich im folgenden Format auf einer neuen Zeile aus:\n"
"Eintrag <Zeilennummer>: <Antwort>\n\n"
"Mögliche Antworten:\n"
"- 'OK' (wenn der Artikel gut passt)\n"
"- 'X | Alternativer Artikel: <URL> | Begründung: <Kurze Begründung>' (wenn der Artikel nicht passt, aber ein besserer gefunden wurde)\n"
"- 'X | Kein passender Artikel gefunden | Begründung: <Kurze Begründung>' (wenn der Artikel nicht passt und kein besserer gefunden wurde)\n"
"- 'Kein Wikipedia-Eintrag vorhanden.' (wenn initial keine URL angegeben wurde und keine Suche erfolgreich war)\n\n"
"Einträge:\n"
"----------\n"
)
aggregated_prompt += "".join(batches) # Join ohne zusätzliches \n
aggregated_prompt += "----------\nBitte nur die 'Eintrag X: Antwort'-Zeilen ausgeben."
debug_print(f"Verarbeite Verifizierungs-Batch für Zeilen {row_numbers[0]} bis {row_numbers[-1]} (nur S-Y)...") # Hinweis angepasst
# Token Count für den Prompt
prompt_tokens = token_count(aggregated_prompt)
debug_print(f"Token-Zahl für Verifizierungs-Batch: {prompt_tokens}")
# --- ChatGPT Aufruf (wie gehabt) ---
chat_response = call_openai_chat(aggregated_prompt, temperature=0.0)
if not chat_response:
debug_print(f"Fehler: Keine Antwort von OpenAI für Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]}.")
return
# --- Antwort parsen (wie gehabt) ---
answers = {}
lines = chat_response.strip().split('\n')
for line in lines:
match = re.match(r"Eintrag (\d+): (.*)", line.strip())
if match:
row_num = int(match.group(1))
answer_text = match.group(2).strip()
if row_num in row_numbers:
answers[row_num] = answer_text
# else: # Weniger Lärm im Log
# debug_print(f"Warnung: Antwort für unerwartete Zeilennummer {row_num} im Batch erhalten: {answer_text}")
# --- Batch-Update vorbereiten (NUR S bis Y) ---
updates = []
# Timestamps und Version werden HIER NICHT mehr hinzugefügt
for row_num in row_numbers:
answer = answers.get(row_num, "k.A. (Keine Antwort im Batch)") # Fallback
# Variablen für die Spalten S-Y initialisieren
wiki_confirm = "" # Spalte S
alt_article = "" # Spalte T
wiki_explanation = "" # Spalte U
v_val, w_val, x_val, y_val = "", "", "", "" # Spalten V-Y
# Logik zur Bestimmung der Werte für S, T, U basierend auf 'answer' (wie gehabt)
if answer.upper() == "OK":
wiki_confirm = "OK"
elif answer.upper() == "KEIN WIKIPEDIA-EINTRAG VORHANDEN.":
wiki_confirm = "X"
alt_article = "Kein Wikipedia-Eintrag vorhanden."
wiki_explanation = "Ursprünglich keine URL oder Suche erfolglos."
elif answer.startswith("X |"):
parts = answer.split("|", 2)
wiki_confirm = "X"
if len(parts) > 1:
detail = parts[1].strip()
if detail.startswith("Alternativer Artikel:"):
alt_article = detail.split(":", 1)[1].strip()
elif detail == "Kein passender Artikel gefunden":
alt_article = detail
else:
alt_article = detail
if len(parts) > 2:
reason_part = parts[2].strip()
if reason_part.startswith("Begründung:"):
wiki_explanation = reason_part.split(":", 1)[1].strip()
else:
wiki_explanation = reason_part
else: # Unerwartetes Format
wiki_confirm = "?"
wiki_explanation = f"Unerwartetes Format: {answer}"
# Füge Updates NUR für S-Y zur Liste hinzu
updates.append({'range': f'S{row_num}', 'values': [[wiki_confirm]]})
updates.append({'range': f'T{row_num}', 'values': [[alt_article]]})
updates.append({'range': f'U{row_num}', 'values': [[wiki_explanation]]})
updates.append({'range': f'V{row_num}:Y{row_num}', 'values': [[v_val, w_val, x_val, y_val]]})
# --- Führe das Batch-Update für S-Y durch ---
if updates:
try:
# Verwende das übergebene sheet-Objekt direkt
sheet.batch_update(updates, value_input_option='USER_ENTERED')
debug_print(f"Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]} (S-Y) erfolgreich in Google Sheet aktualisiert.")
except Exception as e:
# Gib eine spezifischere Fehlermeldung aus
debug_print(f"FEHLER beim Batch-Update (S-Y) für Zeilen {row_numbers[0]}-{row_numbers[-1]}: {type(e).__name__} - {e}")
# Optional: Fehler weitergeben, wenn retry gewünscht wird (nicht empfohlen für Sheet-Updates hier)
# raise e
else:
debug_print(f"Keine Updates (S-Y) für Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]} generiert.")
# KEINE Pause hier mehr, wird in der aufrufenden Funktion gemacht
# time.sleep(Config.RETRY_DELAY)
def process_verification_only(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet):
"""
Batch-Prozess nur für Wikipedia-Verifizierung (Spalten S-Y).
Lädt Daten neu, prüft für jede Zeile im Bereich, ob Timestamp AX (Wiki Verif.)
bereits gesetzt ist, und überspringt diese ggf. Setzt AX für bearbeitete Zeilen.
AN wird hier *nicht* mehr gesetzt, das muss ggf. _process_single_row tun.
"""
debug_print(f"Starte Wikipedia-Verifizierungsmodus (Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...")
if not sheet_handler.load_data():
debug_print("FEHLER beim Laden der Daten in process_verification_only.")
return
all_data = sheet_handler.get_all_data_with_headers()
if not all_data or len(all_data) <= 5:
debug_print("FEHLER/WARNUNG: Keine Daten zum Verarbeiten in process_verification_only gefunden.")
return
# Hole Index für AX Timestamp (Wiki Verif.)
timestamp_col_key = "Wiki Verif. Timestamp" # NEUER SCHLÜSSEL
timestamp_col_index = COLUMN_MAP.get(timestamp_col_key)
ts_col_letter = sheet_handler._get_col_letter(timestamp_col_index + 1) if timestamp_col_index is not None else "AX_FEHLER"
if timestamp_col_index is None:
debug_print(f"FEHLER: Spaltenschlüssel '{timestamp_col_key}' nicht in COLUMN_MAP gefunden. Breche Wiki-Verifizierung ab.")
return
batch_size = Config.BATCH_SIZE
current_batch = []
current_row_numbers = []
processed_count = 0
skipped_count = 0
for i in range(start_row_index_in_sheet, end_row_index_in_sheet + 1):
row_index_in_list = i - 1
if row_index_in_list >= len(all_data): continue
row = all_data[row_index_in_list]
# --- Timestamp-Prüfung für jede Zeile (AX) ---
ts_value_ax = "INDEX_FEHLER"
ts_ax_is_set = False
if len(row) > timestamp_col_index:
ts_value_ax = row[timestamp_col_index]
ts_ax_is_set = bool(str(ts_value_ax).strip())
log_debug = (i < start_row_index_in_sheet + 5 or i > end_row_index_in_sheet - 5 or i % 500 == 0 or i in range(2122, 2132))
if log_debug:
debug_print(f"Zeile {i} (Wiki Verif. Check): Prüfe Timestamp {ts_col_letter} (Index {timestamp_col_index}). Rohwert='{ts_value_ax}', Strip='{str(ts_value_ax).strip()}', Überspringen? -> {ts_ax_is_set}")
if ts_ax_is_set:
skipped_count += 1
continue
# --- Ende Timestamp-Prüfung ---
# Nur wenn AX leer ist, wird die Zeile für den Batch vorbereitet
company_name = row[COLUMN_MAP.get("CRM Name", 1)] if len(row) > COLUMN_MAP.get("CRM Name", 1) else ''
crm_desc = row[COLUMN_MAP.get("CRM Beschreibung", 5)] if len(row) > COLUMN_MAP.get("CRM Beschreibung", 5) else ''
wiki_url_idx = COLUMN_MAP.get("Wiki URL")
wiki_url = row[wiki_url_idx] if wiki_url_idx is not None and len(row) > wiki_url_idx and row[wiki_url_idx].strip() not in ['', 'k.A.'] else 'k.A.'
wiki_para_idx = COLUMN_MAP.get("Wiki Absatz")
wiki_paragraph = row[wiki_para_idx] if wiki_para_idx is not None and len(row) > wiki_para_idx else 'k.A.'
wiki_cat_idx = COLUMN_MAP.get("Wiki Kategorien")
wiki_categories = row[wiki_cat_idx] if wiki_cat_idx is not None and len(row) > wiki_cat_idx else 'k.A.'
entry_text = (
f"Eintrag {i}:\n"
f" Firmenname: {company_name}\n"
f" CRM-Beschreibung: {crm_desc[:200]}...\n"
f" Wikipedia-URL: {wiki_url}\n"
f" Wiki-Absatz: {wiki_paragraph[:200]}...\n"
f" Wiki-Kategorien: {wiki_categories[:200]}...\n"
f"----\n"
)
current_batch.append(entry_text)
current_row_numbers.append(i)
processed_count += 1
if len(current_batch) >= batch_size or i == end_row_index_in_sheet:
if current_batch:
# Rufe _process_batch auf (schreibt S-Y)
_process_batch(sheet_handler.sheet, current_batch, current_row_numbers)
# Setze den AX Timestamp für die bearbeiteten Zeilen
wiki_ts_updates = []
current_wiki_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for row_num in current_row_numbers:
wiki_ts_updates.append({'range': f'{ts_col_letter}{row_num}', 'values': [[current_wiki_timestamp]]})
if wiki_ts_updates:
success_ts = sheet_handler.batch_update_cells(wiki_ts_updates)
if success_ts:
debug_print(f"Wiki Verif. Timestamp {ts_col_letter} für Batch {current_row_numbers[0]}-{current_row_numbers[-1]} gesetzt.")
else:
debug_print(f"FEHLER beim Setzen des Wiki Verif. Timestamps {ts_col_letter} für Batch.")
time.sleep(Config.RETRY_DELAY)
current_batch = []
current_row_numbers = []
debug_print(f"Wikipedia-Verifizierungs-Batch abgeschlossen. {processed_count} Zeilen zur Verarbeitung weitergegeben, {skipped_count} Zeilen übersprungen.")
# Anpassung in _process_batch: Setzt jetzt *nicht* mehr AO/AP, sondern nur S-Y
def _process_batch(sheet, batches, row_numbers):
"""
Hilfsfunktion für process_verification_only: Verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen.
Aktualisiert NUR die Spalten S bis Y. Zeitstempel werden von der aufrufenden Funktion gesetzt.
"""
if not batches: return
# (Prompt Erstellung wie gehabt)
aggregated_prompt = (
"Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln für Unternehmen. "
"Für jeden der folgenden Einträge prüfe, ob der vorhandene Wikipedia-Artikel (URL, Absatz, Kategorien) plausibel zum Firmennamen und zur Beschreibung passt. "
"Gib das Ergebnis für jeden Eintrag ausschließlich im folgenden Format auf einer neuen Zeile aus:\n"
"Eintrag <Zeilennummer>: <Antwort>\n\n"
"Mögliche Antworten:\n"
"- 'OK' (wenn der Artikel gut passt)\n"
"- 'X | Alternativer Artikel: <URL> | Begründung: <Kurze Begründung>' (wenn der Artikel nicht passt, aber ein besserer gefunden wurde)\n"
"- 'X | Kein passender Artikel gefunden | Begründung: <Kurze Begründung>' (wenn der Artikel nicht passt und kein besserer gefunden wurde)\n"
"- 'Kein Wikipedia-Eintrag vorhanden.' (wenn initial keine URL angegeben wurde und keine Suche erfolgreich war - dieser Fall sollte selten sein, da die Suche vorher stattfindet)\n\n"
"Einträge:\n"
"----------\n"
)
aggregated_prompt += "".join(batches)
aggregated_prompt += "----------\nBitte nur die 'Eintrag X: Antwort'-Zeilen ausgeben."
debug_print(f"Verarbeite Verifizierungs-Batch für Zeilen {row_numbers[0]} bis {row_numbers[-1]}.")
prompt_tokens = token_count(aggregated_prompt)
debug_print(f"Token-Zahl für Verifizierungs-Batch: {prompt_tokens}")
chat_response = call_openai_chat(aggregated_prompt, temperature=0.0)
if not chat_response:
debug_print(f"Fehler: Keine Antwort von OpenAI für Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]}.")
return
# Parse die aggregierte Antwort (wie gehabt)
answers = {}
lines = chat_response.strip().split('\n')
for line in lines:
match = re.match(r"Eintrag (\d+): (.*)", line.strip())
if match:
row_num = int(match.group(1))
answer_text = match.group(2).strip()
if row_num in row_numbers: answers[row_num] = answer_text
# Bereite Batch-Update nur für Spalten S-Y vor
updates = []
# current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Nicht mehr hier
# current_version = Config.VERSION # Nicht mehr hier
for row_num in row_numbers:
answer = answers.get(row_num, "k.A. (Keine Antwort im Batch)")
# debug_print(f"Zeile {row_num} Verifizierungsantwort: '{answer}'") # Optional weniger Lärm
wiki_confirm, alt_article, wiki_explanation = "", "", ""
v_val, w_val, x_val, y_val = "", "", "", ""
if answer.upper() == "OK": wiki_confirm = "OK"
elif answer.upper() == "KEIN WIKIPEDIA-EINTRAG VORHANDEN.":
wiki_confirm, alt_article, wiki_explanation = "X", "Kein Wikipedia-Eintrag vorhanden.", "Ursprünglich keine URL oder Suche erfolglos."
elif answer.startswith("X |"):
parts = answer.split("|", 2)
wiki_confirm = "X"
if len(parts) > 1:
detail = parts[1].strip()
if detail.startswith("Alternativer Artikel:"): alt_article = detail.split(":", 1)[1].strip()
elif detail == "Kein passender Artikel gefunden": alt_article = detail
else: alt_article = detail
if len(parts) > 2:
reason_part = parts[2].strip()
if reason_part.startswith("Begründung:"): wiki_explanation = reason_part.split(":", 1)[1].strip()
else: wiki_explanation = reason_part
else:
wiki_confirm, wiki_explanation = "?", f"Unerwartetes Format: {answer}"
# Füge Updates für S-Y hinzu
updates.append({'range': f'S{row_num}', 'values': [[wiki_confirm]]})
updates.append({'range': f'T{row_num}', 'values': [[alt_article]]})
updates.append({'range': f'U{row_num}', 'values': [[wiki_explanation]]})
updates.append({'range': f'V{row_num}:Y{row_num}', 'values': [[v_val, w_val, x_val, y_val]]})
# Führe das Batch-Update für S-Y durch
if updates:
# Direkten Sheet-Zugriff nutzen, da sheet übergeben wird
try:
sheet.batch_update(updates)
debug_print(f"Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]} (S-Y) erfolgreich in Google Sheet aktualisiert.")
except Exception as e:
debug_print(f"FEHLER beim Batch-Update (S-Y) für Batch {row_numbers[0]}-{row_numbers[-1]}: {e}")
else:
debug_print(f"Keine Updates (S-Y) für Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]} generiert.")
# Kurze Pause nach jedem Batch-API-Call (jetzt in der aufrufenden Funktion)
# time.sleep(Config.RETRY_DELAY) # Entfernt
# Komplette Funktion process_website_batch (prüft jetzt Timestamp AT mit erzwungenem Debugging)
# Komplette Funktion process_website_batch (MIT Batched Google Sheet Updates)
# Komplette Funktion process_website_batch (NEUE STRUKTUR - ECHTER BATCH WORKFLOW)
# Komplette Funktion process_website_batch (NUR SCRAPING)
# Komplette Funktion process_website_batch (Korrigierte Config-Referenzen)
def process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet):
"""
Batch-Prozess NUR für Website-Scraping (Rohtext AR).
Lädt Daten neu, prüft Spalte AR auf leere/k.A.-Werte und überspringt Zeilen mit Inhalt.
Setzt AR + AP für bearbeitete Zeilen. Sendet Updates gebündelt.
"""
debug_print(f"Starte Website-Scraping NUR ROHDATEN (Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...")
# --- Lade Daten ---
if not sheet_handler.load_data(): return
all_data = sheet_handler.get_all_data_with_headers()
if not all_data or len(all_data) <= 5: return
header_rows = 5
# --- Indizes und Buchstaben ---
rohtext_col_key = "Website Rohtext"
rohtext_col_index = COLUMN_MAP.get(rohtext_col_key)
website_col_idx = COLUMN_MAP.get("CRM Website")
version_col_idx = COLUMN_MAP.get("Version")
if None in [rohtext_col_index, website_col_idx, version_col_idx]:
debug_print(f"FEHLER: Benötigte Indizes für process_website_batch fehlen.")
return
rohtext_col_letter = sheet_handler._get_col_letter(rohtext_col_index + 1)
version_col_letter = sheet_handler._get_col_letter(version_col_idx + 1)
# --- Worker-Funktion (unverändert) ---
def scrape_raw_text_task(task_info):
row_num = task_info['row_num']; url = task_info['url']; raw_text = "k.A."; error = None
try: raw_text = get_website_raw(url)
except Exception as e: error = f"Scraping Fehler Zeile {row_num}: {e}"; debug_print(error)
return {"row_num": row_num, "raw_text": raw_text, "error": error}
# --- Hauptlogik: Iteriere und sammle Batches ---
tasks_for_processing_batch = []
all_sheet_updates = []
total_processed_count = 0
total_skipped_count = 0
total_skipped_url_count = 0
total_error_count = 0
# --- KORRIGIERT: Hole Konfigurationswerte aus Config ---
processing_batch_size = Config.PROCESSING_BATCH_SIZE
max_scraping_workers = Config.MAX_SCRAPING_WORKERS
update_batch_row_limit = Config.UPDATE_BATCH_ROW_LIMIT
# --- Ende Korrektur ---
empty_values_for_skip = ["", "k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]
for i in range(start_row_index_in_sheet, end_row_index_in_sheet + 1):
row_index_in_list = i - 1
if row_index_in_list >= len(all_data): continue
row = all_data[row_index_in_list]
# Prüfung, ob AR schon Inhalt hat
should_skip = False
cell_value_ar_str_lower = "FEHLER_INDEX"
if len(row) > rohtext_col_index:
cell_value_ar_str_lower = str(row[rohtext_col_index]).strip().lower()
if cell_value_ar_str_lower not in empty_values_for_skip:
should_skip = True
log_debug = (i < start_row_index_in_sheet + 5 or i > end_row_index_in_sheet - 5 or i % 500 == 0 or i in [10, 13])
if log_debug:
debug_print(f"Zeile {i} (Website AR Check): Prüfe Inhalt Spalte {rohtext_col_letter}. Wert='{cell_value_ar_str_lower}'. Überspringen (da schon Inhalt)? -> {should_skip}")
if should_skip:
total_skipped_count += 1
continue
# URL Prüfung
website_url = row[website_col_idx] if len(row) > website_col_idx else ""
if not website_url or website_url.strip().lower() == "k.a.":
total_skipped_url_count += 1
continue
tasks_for_processing_batch.append({"row_num": i, "url": website_url})
# Verarbeitungs-Batch ausführen
if len(tasks_for_processing_batch) >= processing_batch_size or i == end_row_index_in_sheet:
if tasks_for_processing_batch:
batch_start_row = tasks_for_processing_batch[0]['row_num']
batch_end_row = tasks_for_processing_batch[-1]['row_num']
batch_task_count = len(tasks_for_processing_batch)
debug_print(f"\n--- Starte Scraping-Batch ({batch_task_count} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---")
scraping_results = {}
debug_print(f" Scrape {batch_task_count} Websites parallel (max {max_scraping_workers} worker)...")
with concurrent.futures.ThreadPoolExecutor(max_workers=max_scraping_workers) as executor:
future_to_task = {executor.submit(scrape_raw_text_task, task): task for task in tasks_for_processing_batch}
for future in concurrent.futures.as_completed(future_to_task):
task = future_to_task[future]
try:
result = future.result()
scraping_results[result['row_num']] = result['raw_text']
if result['error']: total_error_count += 1
except Exception as exc:
row_num = task['row_num']; err_msg = f"Generischer Fehler Scraping Task Zeile {row_num}: {exc}"
debug_print(err_msg); scraping_results[row_num] = "k.A. (Fehler)"; total_error_count +=1
current_batch_processed_count = len(scraping_results)
total_processed_count += current_batch_processed_count
debug_print(f" Scraping für Batch beendet. {current_batch_processed_count} Ergebnisse erhalten.")
# Sheet Updates vorbereiten (AR und AP)
if scraping_results:
current_version = Config.VERSION
batch_sheet_updates = []
for row_num, raw_text_res in scraping_results.items():
row_updates = [
{'range': f'{rohtext_col_letter}{row_num}', 'values': [[raw_text_res]]},
{'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}
]
batch_sheet_updates.extend(row_updates)
all_sheet_updates.extend(batch_sheet_updates)
tasks_for_processing_batch = [] # Batch leeren
# Sheet Updates senden (wenn update_batch_row_limit erreicht)
if len(all_sheet_updates) >= update_batch_row_limit * 2: # *2 Updates pro Zeile
debug_print(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...")
success = sheet_handler.batch_update_cells(all_sheet_updates)
if success: debug_print(f" Sheet-Update bis Zeile {i} erfolgreich.")
else: debug_print(f" FEHLER beim Sheet-Update bis Zeile {i}.")
all_sheet_updates = []
# Finale Sheet Updates senden
if all_sheet_updates:
debug_print(f"Sende finale Sheet-Updates ({len(all_sheet_updates)} Zellen)...")
sheet_handler.batch_update_cells(all_sheet_updates)
debug_print(f"Website-Scraping NUR ROHDATEN abgeschlossen. {total_processed_count} Websites verarbeitet (inkl. Fehler), {total_error_count} Fehler, {total_skipped_count} Zeilen wg. Inhalt übersprungen, {total_skipped_url_count} Zeilen ohne URL übersprungen.")
# NEUE Funktion process_website_summarization_batch
def process_website_summarization_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet):
"""
Batch-Prozess NUR für Website-Zusammenfassung (AS).
Lädt Daten neu, prüft, ob Rohtext (AR) vorhanden und Zusammenfassung (AS) fehlt.
Fasst Rohtexte im Batch via OpenAI zusammen und setzt AS + AP.
"""
debug_print(f"Starte Website-Zusammenfassung (OpenAI Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...")
# --- Konfiguration ---
OPENAI_BATCH_SIZE_LIMIT = 8
update_batch_row_limit = 50
# --- Lade Daten ---
if not sheet_handler.load_data(): return
all_data = sheet_handler.get_all_data_with_headers()
if not all_data or len(all_data) <= 5: return
header_rows = 5
# --- Indizes und Buchstaben ---
rohtext_col_idx = COLUMN_MAP.get("Website Rohtext")
summary_col_idx = COLUMN_MAP.get("Website Zusammenfassung")
version_col_idx = COLUMN_MAP.get("Version")
if None in [rohtext_col_idx, summary_col_idx, version_col_idx]:
debug_print(f"FEHLER: Benötigte Indizes für process_website_summarization_batch fehlen.")
return
summary_col_letter = sheet_handler._get_col_letter(summary_col_idx + 1)
version_col_letter = sheet_handler._get_col_letter(version_col_idx + 1)
# --- Verarbeitung ---
tasks_for_openai_batch = []
all_sheet_updates = []
rows_in_current_update_batch = 0
processed_count = 0
skipped_no_rohtext = 0
skipped_summary_exists = 0
for i in range(start_row_index_in_sheet, end_row_index_in_sheet + 1):
row_index_in_list = i - 1
if row_index_in_list >= len(all_data): continue
row = all_data[row_index_in_list]
# Prüfung 1: Ist Rohtext vorhanden und gültig?
raw_text = ""
if len(row) > rohtext_col_idx:
raw_text = str(row[rohtext_col_idx]).strip()
if not raw_text or raw_text == "k.A." or raw_text == "k.A. (Nur Cookie-Banner erkannt)" or raw_text == "k.A. (Fehler)":
skipped_no_rohtext += 1
continue
# Prüfung 2: Fehlt die Zusammenfassung (AS)?
summary_exists = False
if len(row) > summary_col_idx:
if str(row[summary_col_idx]).strip() and str(row[summary_col_idx]).strip() != "k.A.":
summary_exists = True
if summary_exists:
skipped_summary_exists += 1
continue
# Wenn Rohtext da ist und Zusammenfassung fehlt -> Aufgabe hinzufügen
tasks_for_openai_batch.append({'row_num': i, 'raw_text': raw_text})
processed_count += 1 # Zähle Zeilen, die potenziell zusammengefasst werden
# --- OpenAI Batch verarbeiten, wenn voll oder letzte Zeile ---
if len(tasks_for_openai_batch) >= OPENAI_BATCH_SIZE_LIMIT or (processed_count > 0 and i == end_row_index_in_sheet):
if tasks_for_openai_batch:
debug_print(f" Verarbeite OpenAI Batch für {len(tasks_for_openai_batch)} Aufgaben (Start: {tasks_for_openai_batch[0]['row_num']})...")
summaries_result = summarize_batch_openai(tasks_for_openai_batch)
# Sheet Updates für diesen OpenAI Batch vorbereiten
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Nur für Version hier relevant?
current_version = Config.VERSION
for task in tasks_for_openai_batch:
row_num = task['row_num']
summary = summaries_result.get(row_num, "k.A. (Fehler Batch Zuordnung)")
row_updates = [
{'range': f'{summary_col_letter}{row_num}', 'values': [[summary]]},
{'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]} # Setze Version hier
]
all_sheet_updates.extend(row_updates)
rows_in_current_update_batch += 1 # Zähle Zeilen für Sheet Update Batch
tasks_for_openai_batch = [] # OpenAI Batch leeren
# --- Gesammelte Sheet Updates senden, wenn Limit erreicht oder letzte Zeile ---
if all_sheet_updates and \
(rows_in_current_update_batch >= update_batch_row_limit or (processed_count > 0 and i == end_row_index_in_sheet)):
debug_print(f" Sende Sheet-Update für {rows_in_current_update_batch} Zusammenfassungen...")
success = sheet_handler.batch_update_cells(all_sheet_updates)
if success: debug_print(f" Sheet-Update bis Zeile {i} erfolgreich.")
else: debug_print(f" FEHLER beim Sheet-Update bis Zeile {i}.")
all_sheet_updates = []
rows_in_current_update_batch = 0
# Letzten Sheet Update Batch senden
if all_sheet_updates:
debug_print(f"Sende LETZTES Sheet-Update für {rows_in_current_update_batch} Zusammenfassungen...")
sheet_handler.batch_update_cells(all_sheet_updates)
debug_print(f"Website-Zusammenfassungs-Batch abgeschlossen. {processed_count} Zusammenfassungen angefordert, {skipped_no_rohtext} wg. fehlendem Rohtext übersprungen, {skipped_summary_exists} wg. vorhandener Zusammenfassung übersprungen.")
# Komplette Funktion process_branch_batch (prüft jetzt Timestamp AO mit erzwungenem Debugging)
# Komplette Funktion process_branch_batch (MIT Korrektur und Prüfung auf AO)
def process_branch_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet):
"""
Batch-Prozess für Brancheneinschätzung. Lädt Daten neu, prüft Timestamp AO,
liest vorhandene Zusammenfassung (AS) und setzt AO + AP für bearbeitete Zeilen.
"""
debug_print(f"Starte Brancheneinschätzung (Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...")
if not sheet_handler.load_data(): return
all_data = sheet_handler.get_all_data_with_headers()
if not all_data or len(all_data) <= 5: return
sheet = sheet_handler.sheet
# Indizes holen
timestamp_col_key = "Timestamp letzte Prüfung"
timestamp_col_index = COLUMN_MAP.get(timestamp_col_key)
branche_crm_idx = COLUMN_MAP.get("CRM Branche")
beschreibung_idx = COLUMN_MAP.get("CRM Beschreibung")
branche_wiki_idx = COLUMN_MAP.get("Wiki Branche")
kategorien_wiki_idx = COLUMN_MAP.get("Wiki Kategorien")
summary_web_idx = COLUMN_MAP.get("Website Zusammenfassung") # Index für AS
version_col_idx = COLUMN_MAP.get("Version")
branch_w_idx = COLUMN_MAP.get("Chat Vorschlag Branche")
branch_x_idx = COLUMN_MAP.get("Chat Konsistenz Branche")
branch_y_idx = COLUMN_MAP.get("Chat Begründung Abweichung Branche")
required_indices = [timestamp_col_index, branche_crm_idx, beschreibung_idx, branche_wiki_idx,
kategorien_wiki_idx, summary_web_idx, version_col_index, branch_w_idx,
branch_x_idx, branch_y_idx]
if None in required_indices:
debug_print(f"FEHLER: Mindestens ein benötigter Spaltenindex für process_branch_batch fehlt in COLUMN_MAP.")
return
ts_col_letter = sheet_handler._get_col_letter(timestamp_col_index + 1)
version_col_letter = sheet_handler._get_col_letter(version_col_index + 1)
branch_w_letter = sheet_handler._get_col_letter(branch_w_idx + 1)
branch_x_letter = sheet_handler._get_col_letter(branch_x_idx + 1)
branch_y_letter = sheet_handler._get_col_letter(branch_y_idx + 1)
processed_count = 0
skipped_count = 0
if not ALLOWED_TARGET_BRANCHES:
load_target_schema()
if not ALLOWED_TARGET_BRANCHES: return
for i in range(start_row_index_in_sheet, end_row_index_in_sheet + 1):
row_index_in_list = i - 1
if row_index_in_list >= len(all_data): continue
row = all_data[row_index_in_list]
# Timestamp-Prüfung (AO)
should_skip = False
if len(row) > timestamp_col_index and str(row[timestamp_col_index]).strip():
should_skip = True
if should_skip:
skipped_count += 1
continue
# Daten holen (inkl. AS, das von einem anderen Prozess geschrieben wurde)
crm_branche = row[branche_crm_idx] if len(row) > branche_crm_idx else ""
beschreibung = row[beschreibung_idx] if len(row) > beschreibung_idx else ""
wiki_branche = row[branche_wiki_idx] if len(row) > branche_wiki_idx else ""
wiki_kategorien = row[kategorien_wiki_idx] if len(row) > kategorien_wiki_idx else ""
website_summary = row[summary_web_idx] if len(row) > summary_web_idx else "k.A." # Lese AS
result = evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kategorien, website_summary)
processed_count += 1
updates = []
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
current_version = Config.VERSION
updates.append({'range': f'{branch_w_letter}{i}', 'values': [[result.get("branch", "Fehler")]]})
updates.append({'range': f'{branch_x_letter}{i}', 'values': [[result.get("consistency", "Fehler")]]})
updates.append({'range': f'{branch_y_letter}{i}', 'values': [[result.get("justification", "Fehler")]]})
updates.append({'range': f'{ts_col_letter}{i}', 'values': [[current_timestamp]]}) # AO Timestamp
updates.append({'range': f'{version_col_letter}{i}', 'values': [[current_version]]}) # AP Version
if updates:
success = sheet_handler.batch_update_cells(updates)
if success: debug_print(f"Zeile {i}: Branch-Einschätzung erfolgreich aktualisiert.")
else: debug_print(f"FEHLER beim Schreiben der Branch-Updates für Zeile {i}.")
time.sleep(Config.RETRY_DELAY)
debug_print(f"Brancheneinschätzung (Batch) abgeschlossen. {processed_count} Zeilen eingeschätzt, {skipped_count} Zeilen wg. Timestamp übersprungen.")
def process_branch_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet):
"""Batch-Prozess für Brancheneinschätzung."""
debug_print(f"Starte Brancheneinschätzung (Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...")
all_data = sheet_handler.get_all_data_with_headers()
sheet = sheet_handler.sheet
# Stelle sicher, dass das Branchenschema geladen ist
if not ALLOWED_TARGET_BRANCHES:
load_target_schema() # Versuch es zu laden
if not ALLOWED_TARGET_BRANCHES:
debug_print("FEHLER: Ziel-Branchenschema konnte nicht geladen werden. Breche Branch-Batch ab.")
return
for i in range(start_row_index_in_sheet, end_row_index_in_sheet + 1):
row_index_in_list = i - 1
row = all_data[row_index_in_list]
# TODO: Zeitstempelprüfung zum Überspringen?
# if len(row) > COLUMN_MAP["Timestamp letzte Prüfung"] and row[COLUMN_MAP["Timestamp letzte Prüfung"]].strip():
# debug_print(f"Zeile {i}: Überspringe Branchen-Einschätzung (Zeitstempel vorhanden).")
# continue
# Hole benötigte Daten aus der Zeile (verwende COLUMN_MAP)
crm_branche = row[COLUMN_MAP["CRM Branche"]] if len(row) > COLUMN_MAP["CRM Branche"] else ""
beschreibung = row[COLUMN_MAP["CRM Beschreibung"]] if len(row) > COLUMN_MAP["CRM Beschreibung"] else ""
wiki_branche = row[COLUMN_MAP["Wiki Branche"]] if len(row) > COLUMN_MAP["Wiki Branche"] else ""
wiki_kategorien = row[COLUMN_MAP["Wiki Kategorien"]] if len(row) > COLUMN_MAP["Wiki Kategorien"] else ""
# Nimm Website Zusammenfassung aus Spalte AS (Index 44)
website_summary = row[COLUMN_MAP["Website Zusammenfassung"]] if len(row) > COLUMN_MAP["Website Zusammenfassung"] else ""
debug_print(f"Zeile {i}: Starte Brancheneinschätzung...")
result = evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kategorien, website_summary)
updates = []
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
current_version = Config.VERSION
updates.append({'range': f'W{i}', 'values': [[result.get("branch", "Fehler")]]}) # Spalte W
updates.append({'range': f'X{i}', 'values': [[result.get("consistency", "Fehler")]]}) # Spalte X
updates.append({'range': f'Y{i}', 'values': [[result.get("justification", "Fehler")]]}) # Spalte Y
updates.append({'range': f'AO{i}', 'values': [[current_timestamp]]}) # Spalte AO
updates.append({'range': f'AP{i}', 'values': [[current_version]]}) # Spalte AP
# Führe Batch-Update für diese eine Zeile durch
if updates:
sheet_handler.batch_update_cells(updates)
debug_print(f"Zeile {i}: Branch-Einschätzung aktualisiert: {result} | Zeitstempel: {current_timestamp}, Version: {current_version}")
# Pause zwischen den API-Aufrufen
time.sleep(Config.RETRY_DELAY)
debug_print("Brancheneinschätzung (Batch) abgeschlossen.")
# Annahmen:
# - Funktionen debug_print, process_verification_only, process_website_batch, process_branch_batch sind definiert.
# - sheet_handler ist eine initialisierte Instanz von GoogleSheetHandler (mit der korrekten get_start_row_index Methode).
# - Globale Konstante header_rows (oder besser, hol sie vom sheet_handler?)
# Komplette run_dispatcher Funktion (Start immer basierend auf AO)
def run_dispatcher(mode, sheet_handler, row_limit=None):
"""Wählt passenden Batch-Prozess, ermittelt Startzeile dynamisch."""
debug_print(f"Starte Dispatcher im Modus '{mode}' mit row_limit={row_limit}.")
header_rows = 5
# Startspalte für jeden Modus
start_col_key = "Timestamp letzte Prüfung" # Standard AO
min_start_row = 7
if mode == "website": start_col_key = "Website Rohtext" # AR !
elif mode == "wiki": start_col_key = "Wiki Verif. Timestamp" # AX
elif mode == "branch": start_col_key = "Timestamp letzte Prüfung" # AO
elif mode == "summarize": start_col_key = "Website Zusammenfassung" # AS
elif mode == "combined": start_col_key = "Timestamp letzte Prüfung" # AO
debug_print(f"Dispatcher: Ermittle Startzeile basierend auf Spalte '{start_col_key}'...")
start_data_index = sheet_handler.get_start_row_index(check_column_key=start_col_key, min_sheet_row=min_start_row)
if start_data_index == -1: return debug_print(f"FEHLER: Startspalte '{start_col_key}' prüfen!")
start_row_index_in_sheet = start_data_index + header_rows + 1
total_sheet_rows = len(sheet_handler.sheet_values)
# Prüfungen (wie gehabt)
if start_data_index >= len(sheet_handler.get_data()): return debug_print("Start nach Ende.")
if start_row_index_in_sheet > total_sheet_rows: return debug_print("Ungültige Startzeile.")
# Endzeile
if row_limit is not None and row_limit > 0: end_row_index_in_sheet = min(start_row_index_in_sheet + row_limit - 1, total_sheet_rows)
elif row_limit == 0: return debug_print("Limit 0.")
else: end_row_index_in_sheet = total_sheet_rows
debug_print(f"Dispatcher: Verarbeitung geplant für Sheet-Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}.")
if start_row_index_in_sheet > end_row_index_in_sheet: return debug_print("Start nach Ende (berechnet).")
# Modusauswahl
try:
if mode == "wiki": process_verification_only(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet)
elif mode == "website": process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet) # Prüft AR, Setzt AR+AP
elif mode == "branch": process_branch_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet)
elif mode == "summarize": process_website_summarization_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet)
elif mode == "combined":
debug_print("--- Start Combined Mode: Wiki ---"); process_verification_only(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet); time.sleep(1)
debug_print("--- Start Combined Mode: Website Scraping ---"); process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet); time.sleep(1)
debug_print("--- Start Combined Mode: Website Summarization ---"); process_website_summarization_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet); time.sleep(1)
debug_print("--- Start Combined Mode: Branch ---"); process_branch_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet)
debug_print("--- Combined Mode abgeschlossen ---")
else: debug_print(f"Ungültiger Modus '{mode}'.")
except Exception as e: debug_print(f"FEHLER im Dispatcher: {e}"); import traceback; debug_print(traceback.format_exc())
# --- Ende run_dispatcher Funktion ---
# ==================== SERP API / LINKEDIN FUNCTIONS ====================
@retry_on_failure
def serp_website_lookup(company_name):
"""Ermittelt Website via SERP API (Google Suche)."""
serp_key = Config.API_KEYS.get('serpapi')
if not serp_key:
debug_print("Fehler: SerpAPI Key nicht verfügbar für Website Lookup.")
return "k.A."
if not company_name: return "k.A."
# Blacklist unerwünschter Domains
blacklist = ["bloomberg.com", "northdata.de", "finanzen.net", "handelsblatt.com", "wikipedia.org", "linkedin.com"]
query = f'{company_name} offizielle Website' # Präzisere Query
params = {
"engine": "google",
"q": query,
"api_key": serp_key,
"hl": "de",
"gl": "de" # Geolocation auf Deutschland setzen
}
api_url = "https://serpapi.com/search"
try:
response = requests.get(api_url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
# 1. Knowledge Graph prüfen (oft die offizielle Seite)
if "knowledge_graph" in data and "website" in data["knowledge_graph"]:
kg_url = data["knowledge_graph"]["website"]
if kg_url and not any(bad_domain in kg_url for bad_domain in blacklist):
normalized_url = simple_normalize_url(kg_url)
if normalized_url != "k.A.":
debug_print(f"SERP Lookup: Website '{normalized_url}' aus Knowledge Graph für '{company_name}' gefunden.")
return normalized_url
# 2. Organische Ergebnisse prüfen
if "organic_results" in data:
for result in data["organic_results"]:
url = result.get("link", "")
# Prüfe Blacklist und ob es eine "echte" Website ist (nicht nur Suche etc.)
if url and not any(bad_domain in url for bad_domain in blacklist) and url.startswith("http"):
normalized_url = simple_normalize_url(url)
if normalized_url != "k.A.":
# Zusätzliche Plausibilitätsprüfung: Enthält die Domain Teile des Firmennamens?
domain_part = normalized_url.replace('www.', '').split('.')[0]
if domain_part in normalize_company_name(company_name):
debug_print(f"SERP Lookup: Website '{normalized_url}' aus Organic Results für '{company_name}' gefunden.")
return normalized_url
else:
debug_print(f"SERP Lookup: URL '{normalized_url}' übersprungen (Domain passt nicht zu '{company_name}').")
debug_print(f"SERP Lookup: Keine passende Website für '{company_name}' gefunden.")
return "k.A."
except requests.exceptions.RequestException as e:
debug_print(f"Fehler beim SERP API Website Lookup für '{company_name}': {e}")
return "k.A."
except Exception as e:
debug_print(f"Allgemeiner Fehler beim SERP API Website Lookup für '{company_name}': {e}")
return "k.A."
@retry_on_failure
def search_linkedin_contacts(company_name, website, position_query, crm_kurzform, num_results=10):
"""Sucht LinkedIn Kontakte via SERP API."""
serp_key = Config.API_KEYS.get('serpapi')
if not serp_key:
debug_print("Fehler: SerpAPI Key nicht verfügbar für LinkedIn Suche.")
return []
if not all([company_name, position_query, crm_kurzform]):
return []
# Query anpassen für bessere Ergebnisse
query = f'site:linkedin.com/in "{position_query}" "{crm_kurzform}"' # Suche nach Kurzform im Titel
# query = f'site:linkedin.com/in "{position_query}" "{company_name}"' # Original Query
params = {
"engine": "google",
"q": query,
"api_key": serp_key,
"hl": "de",
"gl": "de",
"num": num_results # Google's num Parameter (max 100, aber oft weniger geliefert)
}
api_url = "https://serpapi.com/search"
try:
response = requests.get(api_url, params=params, timeout=15) # Längerer Timeout
response.raise_for_status()
data = response.json()
contacts = []
if "organic_results" in data:
for result in data["organic_results"]:
title = result.get("title", "")
linkedin_url = result.get("link", "")
# Filter: Muss LinkedIn URL sein und Kurzform muss im Titel vorkommen
if not linkedin_url or "linkedin.com/in/" not in linkedin_url:
continue
if crm_kurzform.lower() not in title.lower():
debug_print(f"LinkedIn Treffer übersprungen: Kurzform '{crm_kurzform}' nicht in Titel '{title}'")
continue
# Extrahiere Name und Position aus Titel
name_part = ""
pos_part = position_query # Fallback auf Suchbegriff
# Versuche gängige Trennzeichen
separators = ["", "-", "|", " at ", " bei "]
title_cleaned = title.replace("...", "").strip() # Bereinige Titel
found_sep = False
for sep in separators:
if sep in title_cleaned:
parts = title_cleaned.split(sep, 1)
name_part = parts[0].strip()
# Versuche, LinkedIn/Profil etc. aus Namen zu entfernen
name_part = name_part.replace(" | LinkedIn", "").replace(" - LinkedIn", "").replace(" - Profil", "").strip()
# Positionsteil kann komplex sein, nehme alles nach dem Trenner
potential_pos = parts[1].strip()
# Entferne Firmenteil, wenn er dem Kurznamen ähnelt
if crm_kurzform.lower() in potential_pos.lower():
potential_pos = potential_pos.replace(crm_kurzform, "", 1).strip() # Nur erste Instanz ersetzen
# Entferne generische Endungen
potential_pos = potential_pos.split(" | LinkedIn")[0].split(" - LinkedIn")[0].strip()
pos_part = potential_pos if potential_pos else position_query
found_sep = True
break
if not found_sep: # Kein Trennzeichen gefunden
name_part = title_cleaned.split(" | LinkedIn")[0].split(" - LinkedIn")[0].strip()
# Prüfe, ob der Suchbegriff im verbleibenden Namensteil ist
if position_query.lower() in name_part.lower():
name_part = name_part.replace(position_query, "", 1).strip() # Versuche Position zu entfernen
# Teile Namen in Vor- und Nachname
firstname = ""
lastname = ""
name_parts = name_part.split()
if len(name_parts) > 1:
firstname = name_parts[0]
lastname = " ".join(name_parts[1:])
elif len(name_parts) == 1:
firstname = name_parts[0] # Nur Vorname gefunden?
if not firstname: # Wenn Name nicht extrahiert werden konnte, überspringe
debug_print(f"Kontakt übersprungen: Name konnte nicht extrahiert werden aus Titel '{title}'")
continue
contact_data = {
"Firmenname": company_name, # Originalname für Kontext
"CRM Kurzform": crm_kurzform,
"Website": website,
"Vorname": firstname,
"Nachname": lastname,
"Position": pos_part,
"LinkedInURL": linkedin_url
}
contacts.append(contact_data)
debug_print(f"Gefundener LinkedIn Kontakt: {firstname} {lastname} - {pos_part}")
debug_print(f"LinkedIn Suche für '{position_query}' bei '{crm_kurzform}' ergab {len(contacts)} Kontakte.")
return contacts
except requests.exceptions.RequestException as e:
debug_print(f"Fehler bei der SERP API LinkedIn Suche: {e}")
return []
except Exception as e:
debug_print(f"Allgemeiner Fehler bei der SERP API LinkedIn Suche: {e}")
return []
# Funktion count_linkedin_contacts wurde entfernt, da search_linkedin_contacts jetzt die Liste liefert
# und len() darauf angewendet werden kann.
def process_contact_research(sheet_handler):
"""Sucht LinkedIn Kontakte und trägt sie in 'Contacts' Sheet ein."""
debug_print("Starte Contact Research (LinkedIn)...")
main_sheet = sheet_handler.sheet
all_data = sheet_handler.get_all_data_with_headers()
header_rows = 5
# Finde Startzeile basierend auf Timestamp in Spalte AM (Index 38)
timestamp_col_index = COLUMN_MAP["Contact Search Timestamp"]
start_row_index_in_sheet = -1
for i in range(header_rows + 1, len(all_data) + 1):
if i < 7: continue # Normalerweise ab Zeile 7
row_index_in_list = i - 1
row = all_data[row_index_in_list]
if len(row) <= timestamp_col_index or not row[timestamp_col_index].strip():
start_row_index_in_sheet = i
break
if start_row_index_in_sheet == -1:
debug_print("Keine Zeile ohne Contact Search Timestamp (Spalte AM, ab Zeile 7) gefunden. Überspringe.")
return
debug_print(f"Contact Research startet ab Zeile {start_row_index_in_sheet}.")
# Kontakte-Blatt öffnen oder erstellen
try:
contacts_sheet = sheet_handler.sheet.spreadsheet.worksheet("Contacts")
debug_print("Blatt 'Contacts' gefunden.")
except gspread.exceptions.WorksheetNotFound:
debug_print("Blatt 'Contacts' nicht gefunden, erstelle neu...")
contacts_sheet = sheet_handler.sheet.spreadsheet.add_worksheet(title="Contacts", rows="1000", cols="12")
header = ["Firmenname", "CRM Kurzform", "Website", "Geschlecht", "Vorname", "Nachname", "Position",
"Suchbegriffskategorie", "E-Mail-Adresse", "LinkedIn-Link", "Timestamp"]
contacts_sheet.update(values=[header], range_name="A1:K1")
# Optional: Alignment Demo hier nicht mehr aufrufen
# alignment_demo(contacts_sheet) # NICHT MEHR NÖTIG/FALSCH
debug_print("Neues Blatt 'Contacts' erstellt und Header eingetragen.")
# Positionen, nach denen gesucht wird
positions_to_search = ["Serviceleiter", "Leiter Kundendienst", "IT-Leiter", "Leiter IT", "Geschäftsführer", "Vorstand", "Disponent", "Einsatzleiter"]
# Gehe Zeilen im Hauptblatt durch
for i in range(start_row_index_in_sheet, len(all_data) + 1):
row_index_in_list = i - 1
row = all_data[row_index_in_list]
company_name = row[COLUMN_MAP["CRM Name"]] if len(row) > COLUMN_MAP["CRM Name"] else ""
crm_kurzform = row[COLUMN_MAP["CRM Kurzform"]] if len(row) > COLUMN_MAP["CRM Kurzform"] else ""
website = row[COLUMN_MAP["CRM Website"]] if len(row) > COLUMN_MAP["CRM Website"] else ""
if not all([company_name, crm_kurzform, website]):
debug_print(f"Zeile {i}: Übersprungen (fehlende CRM Daten: Name, Kurzform oder Website).")
continue
debug_print(f"Zeile {i}: Suche Kontakte für '{crm_kurzform}'...")
all_found_contacts = []
contact_counts = {pos: 0 for pos in ["Serviceleiter", "IT-Leiter", "Geschäftsführer", "Disponent"]} # Für die Zählung im Hauptblatt
for position in positions_to_search:
# Suche max. 5 Kontakte pro Position, um API Calls/Kosten zu begrenzen
found_contacts = search_linkedin_contacts(company_name, website, position, crm_kurzform, num_results=5)
# Zählung für das Hauptblatt (vereinfachte Kategorien)
if "serviceleiter" in position.lower() or "kundendienst" in position.lower() or "einsatzleiter" in position.lower():
contact_counts["Serviceleiter"] += len(found_contacts)
elif "it-leiter" in position.lower() or "leiter it" in position.lower():
contact_counts["IT-Leiter"] += len(found_contacts)
elif "geschäftsführer" in position.lower() or "vorstand" in position.lower():
contact_counts["Geschäftsführer"] += len(found_contacts)
elif "disponent" in position.lower():
contact_counts["Disponent"] += len(found_contacts)
# Füge gefundene Kontakte zur Liste hinzu (mit Suchkategorie)
for contact in found_contacts:
contact["Suchbegriffskategorie"] = position
all_found_contacts.append(contact)
time.sleep(1.5) # Kleine Pause zwischen SerpAPI-Aufrufen
# Verarbeite gefundene Kontakte und schreibe ins Contacts-Sheet
rows_to_append = []
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
unique_contacts = {c['LinkedInURL']: c for c in all_found_contacts}.values() # Deduplizieren basierend auf URL
for contact in unique_contacts:
firstname = contact.get("Vorname", "")
lastname = contact.get("Nachname", "")
gender_value = get_gender(firstname)
email = get_email_address(firstname, lastname, website)
contact_row = [
contact.get("Firmenname", ""),
contact.get("CRM Kurzform", ""),
contact.get("Website", ""),
gender_value,
firstname,
lastname,
contact.get("Position", ""),
contact.get("Suchbegriffskategorie", ""),
email,
contact.get("LinkedInURL", ""),
timestamp
]
rows_to_append.append(contact_row)
if rows_to_append:
try:
# Verwende append_rows für Effizienz
contacts_sheet.append_rows(rows_to_append, value_input_option='USER_ENTERED')
debug_print(f"Zeile {i}: {len(rows_to_append)} neue Kontakte zum 'Contacts'-Blatt hinzugefügt.")
except Exception as e:
debug_print(f"Zeile {i}: Fehler beim Hinzufügen von Kontakten zum Sheet: {e}")
# Evtl. einzeln versuchen bei Fehler?
# Aktualisiere Trefferzahlen und Timestamp im Hauptblatt (Batch Update)
main_sheet_updates = []
main_sheet_updates.append({'range': f'AI{i}', 'values': [[str(contact_counts["Serviceleiter"])]]})
main_sheet_updates.append({'range': f'AJ{i}', 'values': [[str(contact_counts["IT-Leiter"])]]})
main_sheet_updates.append({'range': f'AK{i}', 'values': [[str(contact_counts["Geschäftsführer"])]]})
main_sheet_updates.append({'range': f'AL{i}', 'values': [[str(contact_counts["Disponent"])]]})
main_sheet_updates.append({'range': f'AM{i}', 'values': [[timestamp]]}) # Contact Search Timestamp
sheet_handler.batch_update_cells(main_sheet_updates)
debug_print(f"Zeile {i}: Kontaktzahlen im Hauptblatt aktualisiert: {contact_counts} Timestamp in AM gesetzt.")
# Pause nach Verarbeitung einer Firma
time.sleep(Config.RETRY_DELAY)
debug_print("Contact Research abgeschlossen.")
# ==================== ALIGNMENT DEMO (Hauptblatt) ====================
# ==================== ALIGNMENT DEMO (Hauptblatt) ====================
# Diese Funktion ist bereits im Code vorhanden (Zeile ~1230 in der vorherigen Version)
# Sie bleibt unverändert:
def alignment_demo(sheet):
"""Schreibt die Header-Struktur (Zeilen 1-5, jetzt bis Spalte AX) ins angegebene Sheet."""
new_headers = [
[ # Spaltenname (Zeile 1)
"ReEval Flag", "CRM Name", "CRM Kurzform", "CRM Website", "CRM Ort", "CRM Beschreibung", "CRM Branche", "CRM Beschreibung Branche extern", "CRM Anzahl Techniker", "CRM Umsatz", "CRM Anzahl Mitarbeiter", "CRM Vorschlag Wiki URL", "Wiki URL", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Chat Wiki Konsistenzprüfung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", "Begründung bei Abweichung", "Chat Vorschlag Branche", "Chat Konsistenz Branche", "Chat Begründung Abweichung Branche", "Chat Prüfung FSM Relevanz", "Chat Begründung für FSM Relevanz", "Chat Schätzung Anzahl Mitarbeiter", "Chat Konsistenzprüfung Mitarbeiterzahl", "Chat Begründung Abweichung Mitarbeiterzahl", "Chat Einschätzung Anzahl Servicetechniker", "Chat Begründung Abweichung Anzahl Servicetechniker", "Chat Schätzung Umsatz", "Chat Begründung Abweichung Umsatz", "Linked Serviceleiter gefunden", "Linked It-Leiter gefunden", "Linked Management gefunden", "Linked Disponent gefunden", "Contact Search Timestamp", "Wikipedia Timestamp", "Timestamp letzte Prüfung", "Version", "Tokens", "Website Rohtext", "Website Zusammenfassung",
"Website Scrape Timestamp", # AT
"Geschätzter Techniker Bucket", # AU
"Finaler Umsatz (Wiki>CRM)", # AV
"Finaler Mitarbeiter (Wiki>CRM)", # AW
"Wiki Verif. Timestamp" # AX (NEU)
],
[ # Quelle der Daten (Zeile 2)
"CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "System", "System", "System", "System", "System", "Web Scraper", "Chat GPT API",
"System", # AT
"ML Modell / Skript", # AU
"Skript (Wiki/CRM)", # AV
"Skript (Wiki/CRM)", # AW
"System" # AX (NEU) - Timestamp vom Wiki-Verifizierungs-Prozess
],
[ # Feldkategorie (Zeile 3)
"Prozess", "Firmenname", "Firmenname", "Website", "Ort", "Beschreibung (Text)", "Branche", "Branche", "Anzahl Servicetechniker", "Umsatz", "Anzahl Mitarbeiter", "Wikipedia Artikel URL", "Wikipedia Artikel", "Beschreibung (Text)", "Branche", "Umsatz", "Anzahl Mitarbeiter", "Kategorien (Text)", "Verifizierung", "Begründung bei Abweichung", "Wikipedia Artikel", "Wikipedia Artikel", "Branche", "Branche", "Branche", "FSM Relevanz", "FSM Relevanz", "Anzahl Mitarbeiter", "Anzahl Mitarbeiter", "Anzahl Mitarbeiter", "Anzahl Servicetechniker", "Anzahl Servicetechniker", "Umsatz", "Umsatz", "Kontakte zur Firma", "Kontakte zur Firma", "Kontakte zur Firma", "Kontakte zur Firma", "Timestamp", "Timestamp", "Timestamp", "Version des Skripts die verwendet wurde", "ChatGPT Tokens", "Website-Content", "Website Zusammenfassung",
"Timestamp", # AT
"Anzahl Servicetechniker Bucket", # AU
"Umsatz", # AV
"Anzahl Mitarbeiter", # AW
"Timestamp" # AX (NEU)
],
[ # Kurze Beschreibung (Zeile 4)
"Systemspalte...", "Enthält den Firmennamen...", "Manuell gepflegte Kurzform...", "Website des Unternehmens.", "Ort des Unternehmens.", "Kurze Beschreibung...", "Aktuelle Branchenzuweisung...", "Externe Branchenbeschreibung...", "Recherchierte Anzahl...", "Umsatz in Mio. € (CRM).", "Anzahl Mitarbeiter (CRM).", "Vorgeschlagene Wikipedia URL...", "Wikipedia URL...", "Erster Absatz...", "Wikipedia-Branche...", "Wikipedia-Umsatz...", "Wikipedia-Mitarbeiterzahl...", "Liste der Wikipedia-Kategorien.", "\"OK\" oder \"X\" Ergebnis...", "Begründung bei Inkonsistenz...", "Chat-Vorschlag Wiki Artikel...", "Nicht genutzt...", "Branchenvorschlag via ChatGPT...", "Vergleich: Übereinstimmung CRM vs. ...", "Begründung bei abweichender...", "FSM-Relevanz: Bewertung...", "Begründung zur FSM-Bewertung.", "Schätzung Anzahl Mitarbeiter...", "Vergleich CRM vs. Wiki vs. ...", "Begründung bei Mitarbeiterabweichung...", "Schätzung Servicetechniker...", "Begründung bei Abweichung...", "Schätzung Umsatz via ChatGPT.", "Begründung bei Umsatzabweichung.", "Anzahl Kontakte (Serviceleiter)...", "Anzahl Kontakte (IT-Leiter)...", "Anzahl Kontakte (Management)...", "Anzahl Kontakte (Disponent)...", "Timestamp der Kontaktsuche.", "Timestamp der Wikipedia-Suche/Extraktion.", "Timestamp der ChatGPT-Bewertung / Letzte Prüfung der Zeile.", "Ausgabe der Skriptversion...", "Token-Zählung...", "Roh extrahierter Text...", "Zusammenfassung des Webseiteninhalts...",
"Timestamp des letzten Website-Scrapings (AR, AS).", # AT
"Geschätzter Bucket (1-7) für Servicetechniker...", # AU
"Konsolidierter Umsatz (Mio €) nach Priorität Wiki > CRM.", # AV
"Konsolidierte Mitarbeiterzahl nach Priorität Wiki > CRM.", # AW
"Timestamp der letzten Wiki-Verifikation (Spalten S-Y)." # AX (NEU)
],
[ # Aufgabe / Funktion (Zeile 5) - Ergänzt um AX
"Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Wird durch Wikipedia Scraper bereitgestellt", "Wird zunächst nicht verwendet...", "Wird u.a. zur finalen Ermittlung...", "Wird u.a. mit CRM-Umsatz...", "Wird u.a. mit CRM-Anzahl...", "Wenn Website-Daten fehlen...", "\"Es soll durch ChatGPT geprüft werden...", "\"Liegt eine Inkonsistenz...", "\"Sollte durch die Wikipedia-Suche...", "XXX derzeit nicht verwendet...", "\"ChatGPT soll anhand der vorliegenden...", "Die in Spalte CRM festgelegte...", "Weicht die von ChatGPT ermittelte...", "ChatGPT soll anhand der vorliegenden Daten prüfen...", "Die in 'Chat Begründung für FSM Relevanz'...", "Nur wenn kein Wikipedia-Eintrag...", "Entspricht die durch ChatGPT ermittelte...", "Weicht die von ChatGPT geschätzte...", "ChatGPT soll auf Basis öffentlich...", "Weicht die von ChatGPT geschätzte...", "Nur wenn kein Wikipedia-Eintrag...", "ChatGPT soll signifikante Umsatzabweichungen...", "Über SerpAPI wird zusammen...", "Über SerpAPI wird zusammen...", "Über SerpAPI wird zusammen...", "Über SerpAPI wird zusammen...", "Wenn die Kontaktsuche gestartet wird...", "Wenn die Wikipedia-Suche gestartet wird...", "Wenn die ChatGPT-Bewertung gestartet wird...", "Wird durch das System befüllt", "Wird durch tiktoken berechnet", "Wird durch Web Scraper...", "Wird durch ChatGPT API...",
"Timestamp wird gesetzt, wenn Website Rohtext/Zusammenfassung geschrieben werden.", # AT
"Ergebnis der Schätzung durch das trainierte ML-Modell.", # AU
"Vom Skript berechneter Wert, priorisiert Wiki > CRM...", # AV
"Vom Skript berechneter Wert, priorisiert Wiki > CRM...", # AW
"Timestamp wird gesetzt, wenn Wiki-Verifikation (S-Y) durchgeführt wurde." # AX (NEU)
]
]
num_cols = len(new_headers[0])
def colnum_string(n):
string = ""
while n > 0: n, remainder = divmod(n - 1, 26); string = chr(65 + remainder) + string
return string
end_col_letter = colnum_string(num_cols)
header_range = f"A1:{end_col_letter}{len(new_headers)}"
try:
sheet.update(values=new_headers, range_name=header_range)
print(f"Alignment-Demo abgeschlossen: Header in Bereich {header_range} geschrieben.")
debug_print(f"Alignment-Demo: Header in Bereich {header_range} geschrieben.")
except Exception as e:
print(f"FEHLER beim Schreiben der Alignment-Demo Header: {e}")
debug_print(f"FEHLER beim Schreiben der Alignment-Demo Header: {e}")
# ==================== DATA PROCESSOR ====================
class DataProcessor:
"""
Verarbeitet Daten aus dem Google Sheet, führt verschiedene Anreicherungs-
und Analyseprozesse durch, inklusive Timestamp-basierter Überspringung.
Enthält jetzt auch die Datenvorbereitung für das ML-Modell.
"""
def __init__(self, sheet_handler):
"""
Initialisiert den DataProcessor.
Args:
sheet_handler (GoogleSheetHandler): Eine initialisierte Instanz des GoogleSheetHandlers.
"""
self.sheet_handler = sheet_handler
self.wiki_scraper = WikipediaScraper() # Eigene Instanz des Scrapers
# @retry_on_failure # Vorsicht mit Retry auf dieser Ebene für die ganze Zeile
def _process_single_row(self, row_num_in_sheet, row_data, process_wiki=True, process_chatgpt=True, process_website=True):
"""
Verarbeitet die Daten für eine einzelne Zeile, prüft Timestamps für jeden Teilbereich.
Args:
row_num_in_sheet (int): Die 1-basierte Zeilennummer im Google Sheet.
row_data (list): Die List der Daten für diese Zeile.
process_wiki (bool): Ob der Wikipedia-Teil ausgeführt werden soll.
process_chatgpt (bool): Ob der ChatGPT-Evaluationsteil ausgeführt werden soll.
process_website (bool): Ob der Website-Teil ausgeführt werden soll.
"""
debug_print(f"--- Starte Verarbeitung für Zeile {row_num_in_sheet} ---")
updates = [] # Sammle alle Updates für diese Zeile
now_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
any_processing_done = False # Flag, ob überhaupt etwas getan wurde
# --- Daten extrahieren (nutze COLUMN_MAP) ---
# Hilfsfunktion, um sicher auf Index zuzugreifen
def get_cell_value(col_key):
idx = COLUMN_MAP.get(col_key)
if idx is not None and len(row_data) > idx:
return row_data[idx]
return "" # Oder None oder einen anderen Standardwert
company_name = get_cell_value("CRM Name")
website_url = get_cell_value("CRM Website")
original_website = website_url # Merken für späteren Vergleich
crm_branche = get_cell_value("CRM Branche")
crm_beschreibung = get_cell_value("CRM Beschreibung")
crm_wiki_url = get_cell_value("CRM Vorschlag Wiki URL")
# Lese aktuelle Werte für Website Rohtext/Zusammenfassung
website_raw = get_cell_value("Website Rohtext") or "k.A."
website_summary = get_cell_value("Website Zusammenfassung") or "k.A."
# --- 1. Website Handling (Lookup, Scrape, Summarize) ---
# Prüfe Timestamp AT (Index 45)
website_ts_needed = process_website and not get_cell_value("Website Scrape Timestamp").strip()
if website_ts_needed:
debug_print(f"Zeile {row_num_in_sheet}: Starte Website Verarbeitung (Timestamp AT fehlt)...")
any_processing_done = True
# Website Lookup, wenn leer
if not website_url or website_url.strip().lower() == "k.a.":
debug_print(f"Zeile {row_num_in_sheet}: CRM Website fehlt, starte SERP Lookup für '{company_name}'...")
new_website = serp_website_lookup(company_name)
if new_website != "k.A.":
website_url = new_website # Aktualisiere URL für weitere Schritte
debug_print(f"Zeile {row_num_in_sheet}: SERP Lookup erfolgreich: {website_url}")
if website_url != original_website:
updates.append({'range': f'D{row_num_in_sheet}', 'values': [[website_url]]})
else:
debug_print(f"Zeile {row_num_in_sheet}: SERP Lookup erfolglos.")
# Website Scraping, wenn URL vorhanden
if website_url and website_url.strip().lower() != "k.a.":
debug_print(f"Zeile {row_num_in_sheet}: Starte Website Scraping für {website_url}...")
new_website_raw = get_website_raw(website_url)
new_website_summary = summarize_website_content(new_website_raw)
# Füge Updates nur hinzu, wenn sich etwas geändert hat oder vorher k.A. war
if new_website_raw != website_raw:
updates.append({'range': f'AR{row_num_in_sheet}', 'values': [[new_website_raw]]})
website_raw = new_website_raw # Aktualisiere lokalen Wert für Chat-Teil
if new_website_summary != website_summary:
updates.append({'range': f'AS{row_num_in_sheet}', 'values': [[new_website_summary]]})
website_summary = new_website_summary # Aktualisiere lokalen Wert für Chat-Teil
else:
debug_print(f"Zeile {row_num_in_sheet}: Überspringe Website Scraping (keine gültige URL).")
# Setze Rohtext/Summary auf k.A., falls sie vorher was anderes waren?
if website_raw != "k.A.": updates.append({'range': f'AR{row_num_in_sheet}', 'values': [['k.A.']]})
if website_summary != "k.A.": updates.append({'range': f'AS{row_num_in_sheet}', 'values': [['k.A.']]})
website_raw, website_summary = "k.A.", "k.A." # Aktualisiere lokale Werte
# Setze Website Timestamp (AT)
updates.append({'range': f'AT{row_num_in_sheet}', 'values': [[now_timestamp]]})
# Version wird am Ende gesetzt
elif process_website:
debug_print(f"Zeile {row_num_in_sheet}: Überspringe Website Verarbeitung (Timestamp AT vorhanden).")
# Stelle sicher, dass lokale Variablen website_raw/summary aktuell sind
website_raw = get_cell_value("Website Rohtext") or "k.A."
website_summary = get_cell_value("Website Zusammenfassung") or "k.A."
# --- 2. Wikipedia Handling ---
wiki_data = {} # Wird gefüllt, entweder durch Scraping oder aus Zeile gelesen
wiki_ts_needed = process_wiki and not get_cell_value("Wikipedia Timestamp").strip()
if wiki_ts_needed:
debug_print(f"Zeile {row_num_in_sheet}: Starte Wikipedia Verarbeitung (Timestamp AN fehlt)...")
any_processing_done = True
# Logik für Suche und Extraktion
valid_crm_wiki_url = crm_wiki_url if crm_wiki_url and crm_wiki_url.strip() not in ["", "k.A."] else None
article_page = None
if valid_crm_wiki_url:
debug_print(f"Zeile {row_num_in_sheet}: Prüfe CRM Wiki Vorschlag: {valid_crm_wiki_url}")
page = self.wiki_scraper._fetch_page_content(valid_crm_wiki_url.split('/')[-1])
# Überprüfe ob website_url hier aktuell ist (könnte durch Lookup geändert sein)
current_website_for_validation = website_url if website_url and website_url != 'k.A.' else original_website
if page and self.wiki_scraper._validate_article(page, company_name, current_website_for_validation):
article_page = page
else:
debug_print(f"Zeile {row_num_in_sheet}: CRM Wiki Vorschlag nicht validiert. Starte Suche...")
article_page = self.wiki_scraper.search_company_article(company_name, current_website_for_validation)
else:
debug_print(f"Zeile {row_num_in_sheet}: Kein CRM Wiki Vorschlag. Starte Suche...")
current_website_for_validation = website_url if website_url and website_url != 'k.A.' else original_website
article_page = self.wiki_scraper.search_company_article(company_name, current_website_for_validation)
if article_page:
debug_print(f"Zeile {row_num_in_sheet}: Extrahiere Daten aus Artikel: {article_page.url}")
wiki_data = self.wiki_scraper.extract_company_data(article_page.url)
else:
debug_print(f"Zeile {row_num_in_sheet}: Kein passender Wikipedia Artikel gefunden.")
# Setze Standard-k.A.-Werte für wiki_data
wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}
# Füge Wiki-Daten zu Updates hinzu
updates.append({'range': f'M{row_num_in_sheet}', 'values': [[wiki_data.get('url', 'k.A.')]]})
updates.append({'range': f'N{row_num_in_sheet}', 'values': [[wiki_data.get('first_paragraph', 'k.A.')]]})
updates.append({'range': f'O{row_num_in_sheet}', 'values': [[wiki_data.get('branche', 'k.A.')]]})
updates.append({'range': f'P{row_num_in_sheet}', 'values': [[wiki_data.get('umsatz', 'k.A.')]]})
updates.append({'range': f'Q{row_num_in_sheet}', 'values': [[wiki_data.get('mitarbeiter', 'k.A.')]]})
updates.append({'range': f'R{row_num_in_sheet}', 'values': [[wiki_data.get('categories', 'k.A.')]]})
# Setze Wiki Timestamp (AN)
updates.append({'range': f'AN{row_num_in_sheet}', 'values': [[now_timestamp]]})
# Version wird am Ende gesetzt
elif process_wiki: # Wenn nicht benötigt, aber Modus aktiv ist
debug_print(f"Zeile {row_num_in_sheet}: Überspringe Wikipedia Verarbeitung (Timestamp AN vorhanden).")
# Lade vorhandene Wiki-Daten aus der Zeile, um sie für ChatGPT verfügbar zu machen
wiki_data['url'] = get_cell_value("Wiki URL") or 'k.A.'
wiki_data['first_paragraph'] = get_cell_value("Wiki Absatz") or 'k.A.'
wiki_data['branche'] = get_cell_value("Wiki Branche") or 'k.A.'
wiki_data['umsatz'] = get_cell_value("Wiki Umsatz") or 'k.A.'
wiki_data['mitarbeiter'] = get_cell_value("Wiki Mitarbeiter") or 'k.A.'
wiki_data['categories'] = get_cell_value("Wiki Kategorien") or 'k.A.'
else: # Wenn Modus inaktiv, setze leere Daten
wiki_data = {'url': 'k.A.', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}
# --- 3. ChatGPT Evaluationen ---
chat_ts_needed = process_chatgpt and not get_cell_value("Timestamp letzte Prüfung").strip()
if chat_ts_needed:
debug_print(f"Zeile {row_num_in_sheet}: Starte ChatGPT Evaluationen (Timestamp AO fehlt)...")
any_processing_done = True
# 3.1 Branchenevaluierung (Nutzt aktuelle wiki_data und website_summary)
branch_result = evaluate_branche_chatgpt(
crm_branche,
crm_beschreibung,
wiki_data.get('branche', 'k.A.'),
wiki_data.get('categories', 'k.A.'),
website_summary # Nutzt den Wert, der ggf. oben aktualisiert wurde
)
updates.append({'range': f'W{row_num_in_sheet}', 'values': [[branch_result.get('branch', 'Fehler')]]})
updates.append({'range': f'X{row_num_in_sheet}', 'values': [[branch_result.get('consistency', 'Fehler')]]})
updates.append({'range': f'Y{row_num_in_sheet}', 'values': [[branch_result.get('justification', 'Fehler')]]})
# --- HIER weitere ChatGPT-basierte Evaluationen einfügen ---
# Beispiel: FSM-Eignung
# fsm_result = evaluate_fsm_suitability(company_name, wiki_data)
# if fsm_result:
# updates.append({'range': f'Z{row_num_in_sheet}', 'values': [[fsm_result.get('suitability', 'k.A.')]]})
# updates.append({'range': f'AA{row_num_in_sheet}', 'values': [[fsm_result.get('justification', 'k.A.')]]})
# Beispiel: Mitarbeiter-Schätzung etc.
# ...
# Setze Timestamp letzte Prüfung (AO)
updates.append({'range': f'AO{row_num_in_sheet}', 'values': [[now_timestamp]]})
# Version wird am Ende gesetzt
elif process_chatgpt:
debug_print(f"Zeile {row_num_in_sheet}: Überspringe ChatGPT Evaluationen (Timestamp AO vorhanden).")
# --- 4. Abschließende Updates ---
# Setze Version, wenn *irgendetwas* in dieser Zeile verarbeitet wurde
if any_processing_done:
updates.append({'range': f'AP{row_num_in_sheet}', 'values': [[Config.VERSION]]})
# --- 5. Batch Update für diese Zeile durchführen ---
if updates:
# Führe Batch Update über den Handler aus
success = self.sheet_handler.batch_update_cells(updates)
if success:
debug_print(f"Zeile {row_num_in_sheet}: Batch-Update erfolgreich ({len(updates)} Zellen/Bereiche).")
else:
debug_print(f"Zeile {row_num_in_sheet}: FEHLER beim Batch-Update.")
else:
debug_print(f"Zeile {row_num_in_sheet}: Keine Updates zum Schreiben (alles übersprungen oder keine Änderungen).")
debug_print(f"--- Verarbeitung für Zeile {row_num_in_sheet} abgeschlossen ---")
# Kurze Pause (optional, aber gut für APIs)
time.sleep(max(0.2, Config.RETRY_DELAY / 10))
def process_rows_sequentially(self, start_row_index, num_rows_to_process, process_wiki=True, process_chatgpt=True, process_website=True):
""" Verarbeitet Zeilen sequentiell ab einem Startindex. """
data_rows = self.sheet_handler.get_data() # Daten ohne Header
header_rows = 5
if start_row_index >= len(data_rows):
debug_print("Startindex liegt hinter der letzten Datenzeile. Keine Verarbeitung.")
return
# Berechne den Endindex sicher
end_row_index = min(start_row_index + num_rows_to_process, len(data_rows))
actual_rows_to_process = end_row_index - start_row_index
if actual_rows_to_process <= 0:
debug_print("Keine Zeilen zur sequenziellen Verarbeitung übrig.")
return
debug_print(f"Verarbeite {actual_rows_to_process} Zeilen sequenziell (Daten-Index {start_row_index} bis {end_row_index - 1})...")
for i in range(start_row_index, end_row_index):
if i >= len(data_rows): # Zusätzliche Sicherheitsprüfung
debug_print(f"WARNUNG: Index {i} überschreitet Datenlänge ({len(data_rows)}). Breche Schleife ab.")
break
row_data = data_rows[i]
row_num_in_sheet = i + header_rows + 1 # 1-basierter Sheet-Index
# Rufe die detaillierte Verarbeitungsmethode auf
self._process_single_row(row_num_in_sheet, row_data, process_wiki, process_chatgpt, process_website)
def process_reevaluation_rows(self):
""" Verarbeitet nur Zeilen, die in Spalte A mit 'x' markiert sind. """
debug_print("Starte Re-Evaluierungsmodus (Spalte A = 'x')...")
data_rows = self.sheet_handler.get_data()
header_rows = 5
rows_processed = 0
reeval_col_idx = COLUMN_MAP.get("ReEval Flag")
if reeval_col_idx is None:
debug_print("FEHLER: Spalte 'ReEval Flag' nicht in COLUMN_MAP gefunden. Breche Re-Evaluierung ab.")
return
for i, row in enumerate(data_rows):
row_num_in_sheet = i + header_rows + 1
# Prüfe Flag in Spalte A (Index 0)
if len(row) > reeval_col_idx and row[reeval_col_idx].strip().lower() == "x":
debug_print(f"Re-Evaluiere Zeile {row_num_in_sheet}...")
# Führe volle Verarbeitung für diese Zeile durch
self._process_single_row(row_num_in_sheet, row, process_wiki=True, process_chatgpt=True, process_website=True)
rows_processed += 1
# Optional: Flag nach Verarbeitung löschen?
# update_flag = [{'range': f'A{row_num_in_sheet}', 'values': [['']]}]
# self.sheet_handler.batch_update_cells(update_flag)
debug_print(f"Re-Evaluierung abgeschlossen. {rows_processed} Zeilen verarbeitet.")
def process_website_details_for_marked_rows(self):
""" Neuer Modus 23: Extrahiert Website-Details für markierte Zeilen. """
debug_print("Starte Modus 23: Website Detail Extraction für Zeilen mit 'x' in Spalte A.")
data_rows = self.sheet_handler.get_data()
header_rows = 5
rows_processed = 0
reeval_col_idx = COLUMN_MAP.get("ReEval Flag")
website_col_idx = COLUMN_MAP.get("CRM Website")
details_col = f"AR" # Spalte AR für Details? War vorher Rohtext. Ggf. neue Spalte?
if reeval_col_idx is None or website_col_idx is None:
debug_print("FEHLER: Benötigte Spalten für Modus 23 nicht in COLUMN_MAP gefunden.")
return
for i, row in enumerate(data_rows):
row_num_in_sheet = i + header_rows + 1
if len(row) > reeval_col_idx and row[reeval_col_idx].strip().lower() == "x":
website_url = row[website_col_idx] if len(row) > website_col_idx else ""
if not website_url or website_url.strip().lower() == "k.a.":
debug_print(f"Zeile {row_num_in_sheet}: Keine gültige Website in Spalte D vorhanden, überspringe.")
continue
debug_print(f"Zeile {row_num_in_sheet}: Extrahiere Website Details von {website_url}...")
details = scrape_website_details(website_url) # Annahme: Diese Funktion existiert
# Speichere das Detail-Ergebnis in Spalte AR (Index 43)
update_data = [{'range': f'{details_col}{row_num_in_sheet}', 'values': [[details]]}]
# Optional: Timestamp setzen? In AT?
# update_data.append({'range': f'AT{row_num_in_sheet}', 'values': [[datetime.now().strftime("%Y-%m-%d %H:%M:%S")]]})
self.sheet_handler.batch_update_cells(update_data)
debug_print(f"Zeile {row_num_in_sheet}: Website Detail Extraction abgeschlossen, Ergebnis in Spalte {details_col} geschrieben.")
rows_processed += 1
time.sleep(Config.RETRY_DELAY)
debug_print(f"Modus 23 abgeschlossen. {rows_processed} Zeilen verarbeitet.")
def process_serp_website_lookup_for_empty(self):
""" Neuer Modus 22: Füllt fehlende Websites via SERP API. """
debug_print("Starte Modus 22: SERP API Website Lookup für leere Zellen in Spalte D.")
data_rows = self.sheet_handler.get_data()
header_rows = 5
rows_processed = 0
website_col_idx = COLUMN_MAP.get("CRM Website")
name_col_idx = COLUMN_MAP.get("CRM Name")
if website_col_idx is None or name_col_idx is None:
debug_print("FEHLER: Benötigte Spalten für Modus 22 nicht in COLUMN_MAP gefunden.")
return
for i, row in enumerate(data_rows):
row_num_in_sheet = i + header_rows + 1
current_website = row[website_col_idx] if len(row) > website_col_idx else ""
if not current_website or current_website.strip().lower() == "k.a.":
company_name = row[name_col_idx] if len(row) > name_col_idx else ""
if not company_name:
debug_print(f"Zeile {row_num_in_sheet}: Übersprungen (kein Firmenname für Lookup).")
continue
debug_print(f"Zeile {row_num_in_sheet}: Suche Website für '{company_name}'...")
new_website = serp_website_lookup(company_name) # Annahme: Diese Funktion existiert
if new_website != "k.A.":
update_data = [{'range': f'D{row_num_in_sheet}', 'values': [[new_website]]}]
# Optional: Timestamp setzen? Wo? AT?
self.sheet_handler.batch_update_cells(update_data)
debug_print(f"Zeile {row_num_in_sheet}: Neue Website '{new_website}' gefunden und in Spalte D eingetragen.")
rows_processed += 1
else:
debug_print(f"Zeile {row_num_in_sheet}: Keine Website gefunden.")
time.sleep(Config.RETRY_DELAY)
debug_print(f"Modus 22 abgeschlossen. {rows_processed} Websites ergänzt.")
# --- NEU: Datenvorbereitung als Methode der Klasse ---
def prepare_data_for_modeling(self):
"""
Lädt Daten aus dem Google Sheet über den sheet_handler,
bereitet sie für das Decision Tree Modell vor. (Implementierung siehe vorherige Antwort)
"""
debug_print("Starte Datenvorbereitung für Modellierung...")
try:
# --- 1. Daten laden & Spalten auswählen ---
if not self.sheet_handler or not self.sheet_handler.sheet_values:
debug_print("Fehler: Sheet Handler nicht initialisiert oder keine Daten geladen.")
return None
all_data = self.sheet_handler.sheet_values
if len(all_data) <= 5:
debug_print("Fehler: Nicht genügend Datenzeilen im Sheet gefunden.")
return None
headers = all_data[0]
data_rows = all_data[5:]
df = pd.DataFrame(data_rows, columns=headers)
debug_print(f"DataFrame erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.")
# Finde die tatsächlichen Spaltennamen anhand der COLUMN_MAP
col_indices = {}
tech_col_key = "CRM Anzahl Techniker" # <- ANPASSEN, FALLS NÖTIG
try:
col_indices = {
"name": all_data[0][COLUMN_MAP["CRM Name"]],
"branche": all_data[0][COLUMN_MAP["CRM Branche"]],
"umsatz_crm": all_data[0][COLUMN_MAP["CRM Umsatz"]],
"umsatz_wiki": all_data[0][COLUMN_MAP["Wiki Umsatz"]],
"ma_crm": all_data[0][COLUMN_MAP["CRM Anzahl Mitarbeiter"]],
"ma_wiki": all_data[0][COLUMN_MAP["Wiki Mitarbeiter"]],
"techniker": all_data[0][COLUMN_MAP[tech_col_key]]
}
cols_to_select = list(col_indices.values())
except KeyError as e:
debug_print(f"FEHLER: Konnte Mapping für Schlüssel '{e}' in COLUMN_MAP nicht finden oder Spalte nicht im Header.")
return None
except IndexError as e:
debug_print(f"FEHLER: Spaltenindex aus COLUMN_MAP ist außerhalb der Grenzen der Header-Zeile: {e}")
return None
df_subset = df[cols_to_select].copy()
rename_map = {v: k for k, v in col_indices.items()}
df_subset.rename(columns=rename_map, inplace=True)
debug_print(f"Benötigte Spalten ausgewählt und umbenannt: {list(df_subset.columns)}")
# --- 2. Features konsolidieren ---
def get_valid_numeric(value_str):
# (Implementierung wie in vorheriger Antwort)
if value_str is None or pd.isna(value_str) or value_str == '': return np.nan
try:
val = float(str(value_str).replace(',', '.'))
return val if val > 0 else np.nan
except (ValueError, TypeError):
cleaned_str = re.sub(r'[^\d.]', '', str(value_str))
if not cleaned_str: return np.nan
try:
val = float(cleaned_str)
return val if val > 0 else np.nan
except ValueError: return np.nan
cols_to_process = {
'Umsatz': ('umsatz_wiki', 'umsatz_crm', 'Finaler_Umsatz'),
'Mitarbeiter': ('ma_wiki', 'ma_crm', 'Finaler_Mitarbeiter')
}
for base_name, (wiki_col, crm_col, final_col) in cols_to_process.items():
debug_print(f"Verarbeite '{base_name}'...")
if wiki_col not in df_subset.columns: df_subset[wiki_col] = np.nan
if crm_col not in df_subset.columns: df_subset[crm_col] = np.nan
wiki_numeric = df_subset[wiki_col].apply(get_valid_numeric)
crm_numeric = df_subset[crm_col].apply(get_valid_numeric)
df_subset[final_col] = np.where(
wiki_numeric.notna(), wiki_numeric,
np.where(crm_numeric.notna(), crm_numeric, np.nan)
)
debug_print(f" -> {df_subset[final_col].notna().sum()} gültige '{final_col}' Werte erstellt.")
# --- 3. Zielvariable vorbereiten ---
techniker_col = "techniker"
debug_print(f"Verarbeite Zielvariable '{techniker_col}'...")
df_subset['Anzahl_Servicetechniker_Numeric'] = pd.to_numeric(df_subset[techniker_col], errors='coerce')
initial_rows = len(df_subset)
df_filtered = df_subset[
df_subset['Anzahl_Servicetechniker_Numeric'].notna() &
(df_subset['Anzahl_Servicetechniker_Numeric'] > 0)
].copy()
filtered_rows = len(df_filtered)
debug_print(f"{initial_rows - filtered_rows} Zeilen entfernt (fehlende/ungültige Technikerzahl).")
debug_print(f"Verbleibende Zeilen für Modellierung: {filtered_rows}")
if filtered_rows == 0: return None
# --- 4. Techniker-Buckets erstellen ---
bins = [-1, 0, 19, 49, 99, 249, 499, float('inf')]
labels = ['Bucket_1_(0)', 'Bucket_2_(<20)', 'Bucket_3_(<50)', 'Bucket_4_(<100)', 'Bucket_5_(<250)', 'Bucket_6_(<500)', 'Bucket_7_(>499)']
df_filtered['Techniker_Bucket'] = pd.cut(
df_filtered['Anzahl_Servicetechniker_Numeric'],
bins=bins, labels=labels, right=True
)
debug_print("Techniker-Buckets erstellt.")
debug_print(f"Verteilung der Buckets:\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True).round(3)}")
# --- 5. Kategoriale Features vorbereiten (Branche) ---
branche_col = "branche"
debug_print(f"Verarbeite kategoriales Feature '{branche_col}'...")
df_filtered[branche_col] = df_filtered[branche_col].astype(str).fillna('Unbekannt').str.strip()
df_encoded = pd.get_dummies(df_filtered, columns=[branche_col], prefix='Branche', dummy_na=False)
debug_print(f"One-Hot Encoding für Branche durchgeführt.")
# --- 6. Finale Auswahl ---
feature_columns = [col for col in df_encoded.columns if col.startswith('Branche_')]
feature_columns.extend(['Finaler_Umsatz', 'Finaler_Mitarbeiter'])
target_column = 'Techniker_Bucket'
original_data_cols = ['name', 'Anzahl_Servicetechniker_Numeric']
df_model_ready = df_encoded[original_data_cols + feature_columns + [target_column]].copy()
for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter']:
df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce')
df_model_ready = df_model_ready.reset_index(drop=True)
debug_print("Datenvorbereitung abgeschlossen.")
nan_counts = df_model_ready[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].isna().sum()
debug_print(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}")
return df_model_ready
except Exception as e:
debug_print(f"FEHLER während der Datenvorbereitung: {e}")
import traceback
debug_print(traceback.format_exc())
return None
# ==================== MAIN FUNCTION ====================
def main():
global LOG_FILE # LOG_FILE wird global benötigt
# --- Initialisierung ---
# Argument Parser
parser = argparse.ArgumentParser(description="Firmen-Datenanreicherungs-Skript")
# HIER NEU: 'summarize' als Option hinzugefügt
valid_modes = ["combined", "wiki", "website", "branch", "summarize", "reeval", "website_lookup", "website_details", "contacts", "full_run", "alignment", "train_technician_model"]
parser.add_argument("--mode", type=str, help=f"Betriebsmodus ({', '.join(valid_modes)})")
parser.add_argument("--limit", type=int, help="Maximale Anzahl zu verarbeitender Zeilen (für Batch/sequentielle Modi)", default=None)
# Optional: Argumente speziell für das Training hinzufügen? z.B. --output_model_file
# parser.add_argument("--model_out", type=str, default="technician_model.pkl", help="Dateipfad zum Speichern des trainierten Modells")
# parser.add_argument("--patterns_out", type=str, default="technician_patterns.json", help="Dateipfad zum Speichern der extrahierten Muster")
args = parser.parse_args()
# Lade API Keys
Config.load_api_keys()
# Betriebsmodus ermitteln
mode = None
# Priorisiere Kommandozeilenargumente
if args.mode and args.mode.lower() in valid_modes:
mode = args.mode.lower()
print(f"Betriebsmodus (aus Kommandozeile): {mode}")
else:
# Nur wenn KEIN Modus über die Kommandozeile kam, FRAGE interaktiv
print("Bitte wählen Sie den Betriebsmodus:")
print(" combined: Wiki(AX), Website-Scrape(AT), Summarize(AS), Branch(AO) (Batch, Start bei leerem AO)") # Aktualisiert
print(" wiki: Nur Wikipedia-Verifizierung (AX) (Batch, Start bei leerem AX)") # Aktualisiert
print(" website: Nur Website-Scraping Rohtext (AT) (Batch, Start bei leerem AT)") # Aktualisiert
print(" summarize: Nur Website-Zusammenfassung aus Rohtext (AS) (Batch, Start bei leerem AO)") # NEU
print(" branch: Nur Branchen-Einschätzung (AO) (Batch, Start bei leerem AO)") # Aktualisiert
print(" reeval: Verarbeitet Zeilen mit 'x' (volle Verarbeitung, alle TS prüfen)")
print(" website_lookup: Sucht fehlende Websites (D)")
print(" website_details:Extrahiert Details für Zeilen mit 'x' (AR)")
print(" contacts: Sucht LinkedIn Kontakte (AM)")
print(" full_run: Verarbeitet sequentiell ab erster Zeile ohne AO (alle TS prüfen)") # Aktualisiert
print(" alignment: Schreibt Header A1:AX5 (!)") # Aktualisiert
print(" train_technician_model: Trainiert Decision Tree zur Technikerschätzung")
try:
mode_input = input(f"Geben Sie den Modus ein ({', '.join(valid_modes)}): ").strip().lower()
if mode_input in valid_modes:
mode = mode_input
else:
print("Ungültige Eingabe. Standardmodus 'combined' wird verwendet.")
mode = "combined"
except OSError as e:
if e.errno == 9:
print("Fehler: Interaktive Modus-Abfrage nicht möglich (läuft im Hintergrund?). Standardmodus 'combined' wird verwendet.")
mode = "combined"
else:
print(f"Unerwarteter OS-Fehler bei Modus-Abfrage: {e}")
print("Standardmodus 'combined' wird verwendet.")
mode = "combined"
except EOFError:
print("Fehler: Interaktive Modus-Abfrage nicht möglich (EOF). Standardmodus 'combined' wird verwendet.")
mode = "combined"
# Zeilenlimit ermitteln
row_limit = None
if args.limit is not None:
if args.limit >= 0:
row_limit = args.limit
print(f"Zeilenlimit (aus Kommandozeile): {row_limit}")
else:
print("Warnung: Negatives Zeilenlimit ignoriert. Kein Limit gesetzt.")
row_limit = None
# HIER NEU: summarize hinzugefügt
elif mode in ["combined", "wiki", "website", "branch", "summarize", "full_run"]:
try:
limit_input = input("Wie viele Zeilen sollen maximal bearbeitet werden? (Enter für alle) ")
if limit_input.strip():
limit_val = int(limit_input)
if limit_val >= 0:
row_limit = limit_val
print(f"Zeilenlimit: {row_limit}")
else:
print("Warnung: Negatives Zeilenlimit ignoriert. Kein Limit gesetzt.")
row_limit = None
else:
row_limit = None
print("Kein Zeilenlimit gesetzt.")
except ValueError:
print("Ungültige Eingabe für Zeilenlimit. Kein Limit gesetzt.")
row_limit = None
except OSError as e:
if e.errno == 9:
print("Warnung: Interaktive Abfrage des Limits nicht möglich (läuft im Hintergrund?). Kein Limit gesetzt.")
row_limit = None
else:
print(f"Unerwarteter OS-Fehler bei Limit-Abfrage: {e}")
print("Kein Limit gesetzt.")
row_limit = None
except EOFError:
print("Warnung: Interaktive Abfrage des Limits nicht möglich (EOF). Kein Limit gesetzt.")
row_limit = None
# Logfile initialisieren
LOG_FILE = create_log_filename(mode)
debug_print(f"===== Skript gestartet =====")
debug_print(f"Version: {Config.VERSION}")
debug_print(f"Betriebsmodus: {mode}")
limit_log_text = str(row_limit) if row_limit is not None else 'N/A für diesen Modus'
# HIER NEU: summarize hinzugefügt
if mode in ["combined", "wiki", "website", "branch", "summarize", "full_run"]:
limit_log_text = str(row_limit) if row_limit is not None else 'Unbegrenzt'
if row_limit == 0:
limit_log_text = '0 (Keine Verarbeitung geplant)'
debug_print(f"Zeilenlimit: {limit_log_text}")
debug_print(f"Logdatei: {LOG_FILE}")
# --- Vorbereitung ---
load_target_schema()
try:
sheet_handler = GoogleSheetHandler()
# Stelle sicher, dass nach der Initialisierung Daten geladen sind
if not sheet_handler.sheet_values:
raise ValueError("Google Sheet Handler konnte keine Daten laden.")
except Exception as e:
debug_print(f"FATAL: Konnte Google Sheet Handler nicht initialisieren oder Daten laden: {e}")
print(f"FEHLER: Verbindung/Datenladen Google Sheets fehlgeschlagen. Log: {LOG_FILE}")
return # Abbruch
data_processor = DataProcessor(sheet_handler)
# --- Modusausführung ---
start_time = time.time()
debug_print(f"Starte Verarbeitung um {datetime.now().strftime('%H:%M:%S')}...")
try:
# HIER NEU: summarize hinzugefügt zu dieser Gruppe
if mode in ["wiki", "website", "branch", "summarize", "combined"]:
if row_limit == 0:
debug_print("Zeilenlimit ist 0. Überspringe Dispatcher-Aufruf.")
else:
# Rufe Dispatcher auf, der den richtigen Startpunkt findet und die passende Funktion aufruft
run_dispatcher(mode, sheet_handler, row_limit)
elif mode == "reeval":
data_processor.process_reevaluation_rows() # Nutzt _process_single_row intern
elif mode == "website_lookup":
data_processor.process_serp_website_lookup_for_empty()
elif mode == "website_details":
data_processor.process_website_details_for_marked_rows()
elif mode == "contacts":
process_contact_research(sheet_handler) # Annahme: Diese Funktion existiert global
elif mode == "full_run":
if row_limit == 0:
debug_print("Zeilenlimit ist 0. Überspringe sequenzielle Verarbeitung.")
else:
# full_run startet immer ab der ersten Zeile ohne AO
start_index = sheet_handler.get_start_row_index(check_column_key="Timestamp letzte Prüfung")
if start_index != -1 and start_index < len(sheet_handler.get_data()):
num_available = len(sheet_handler.get_data()) - start_index
num_to_process = min(row_limit, num_available) if row_limit is not None and row_limit >= 0 else num_available
if num_to_process > 0:
# _process_single_row prüft alle Timestamps intern
data_processor.process_rows_sequentially(start_index, num_to_process, process_wiki=True, process_chatgpt=True, process_website=True)
else: debug_print("Keine Zeilen für 'full_run' zu verarbeiten.")
else: debug_print(f"Startindex ({start_index}) für 'full_run' ungültig oder keine Daten.")
elif mode == "alignment":
print("\nACHTUNG: Dieser Modus überschreibt die Zellen A1:AX5 im Haupt-Sheet!") # AX statt AS
print("Diese Zellen enthalten die Spaltendefinitionen (Alignment Demo).")
try:
confirm = input("Möchten Sie wirklich fortfahren? (j/N): ").strip().lower()
if confirm == 'j':
debug_print("Bestätigung erhalten. Starte Alignment Demo...")
alignment_demo(sheet_handler.sheet) # Ruft die globale Funktion auf
debug_print("Alignment Demo Aufruf beendet.")
else:
print("Vorgang abgebrochen.")
debug_print("Alignment Demo vom Benutzer abgebrochen.")
except Exception as e: # Fange generische Exceptions für input()
print(f"Fehler bei Bestätigung ({e}). Vorgang abgebrochen.")
debug_print(f"Alignment Demo abgebrochen (Fehler: {e}).")
elif mode == "train_technician_model":
debug_print("Starte Modus: train_technician_model")
# Rufe die Methode über die data_processor Instanz auf
prepared_df = data_processor.prepare_data_for_modeling() # Korrigierter Aufruf
if prepared_df is not None and not prepared_df.empty:
# --- Train/Test Split ---
debug_print("Aufteilen der Daten in Trainings- und Testsets...")
try:
# Stelle sicher, dass 'name' und Original-Technikerzahl entfernt werden, bevor gesplittet wird
X = prepared_df.drop(columns=['Techniker_Bucket', 'name', 'Anzahl_Servicetechniker_Numeric'])
y = prepared_df['Techniker_Bucket']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)
debug_print(f"Trainingsdaten: {X_train.shape}, Testdaten: {X_test.shape}")
except Exception as e:
debug_print(f"Fehler beim Train/Test Split: {e}")
X_train, X_test, y_train, y_test = None, None, None, None
if X_train is not None:
# --- Imputation ---
debug_print("Imputation fehlender Werte (Median)...")
numeric_features = ['Finaler_Umsatz', 'Finaler_Mitarbeiter']
try:
imputer = SimpleImputer(strategy='median')
imputer.fit(X_train[numeric_features])
X_train[numeric_features] = imputer.transform(X_train[numeric_features])
X_test[numeric_features] = imputer.transform(X_test[numeric_features])
imputer_filename = "median_imputer.pkl"; pickle.dump(imputer, open(imputer_filename, 'wb'))
debug_print(f"Median-Imputer trainiert und gespeichert als '{imputer_filename}'.")
imputation_successful = True
except Exception as e: debug_print(f"Fehler Imputation: {e}"); imputation_successful = False
if imputation_successful:
# --- Modelltraining & Tuning ---
debug_print("Starte Decision Tree Training mit GridSearchCV...")
param_grid = { 'criterion': ['gini', 'entropy'], 'max_depth': [5, 8, 10, 12, 15], 'min_samples_split': [20, 40, 60], 'min_samples_leaf': [10, 20, 30], 'ccp_alpha': [0.0, 0.001, 0.005]}
dtree = DecisionTreeClassifier(random_state=42)
grid_search = GridSearchCV(estimator=dtree, param_grid=param_grid, cv=5, scoring='accuracy', n_jobs=-1, verbose=1)
try:
grid_search.fit(X_train, y_train)
best_params = grid_search.best_params_; best_score = grid_search.best_score_; best_estimator = grid_search.best_estimator_
debug_print(f"Beste Parameter: {best_params}, Bester Score: {best_score:.4f}")
model_filename = "technician_decision_tree_model.pkl"; pickle.dump(best_estimator, open(model_filename, 'wb'))
debug_print(f"Bestes Modell gespeichert als '{model_filename}'.")
# --- Evaluation ---
debug_print("Evaluiere bestes Modell auf Test-Set...")
y_pred = best_estimator.predict(X_test)
test_accuracy = accuracy_score(y_test, y_pred)
report = classification_report(y_test, y_pred, zero_division=0)
conf_matrix = confusion_matrix(y_test, y_pred)
debug_print(f"\n--- Evaluation Test-Set ---")
debug_print(f"Genauigkeit: {test_accuracy:.4f}"); print(f"\nModell Genauigkeit (Test): {test_accuracy:.4f}")
debug_print(f"Bericht:\n{report}"); print(f"Log für Details: {LOG_FILE}")
debug_print(f"Matrix:\n{conf_matrix}")
# --- Muster extrahieren ---
debug_print("\nExtrahiere Regeln (Text)...")
try:
feature_names = list(X_train.columns)
rules_text = export_text(best_estimator, feature_names=feature_names, show_weights=True)
debug_print(f"--- Baumregeln ---:\n{rules_text[:2000]}...") # Gekürzt für Log
patterns_filename_txt = "technician_patterns.txt"
with open(patterns_filename_txt, 'w', encoding='utf-8') as f_rules: f_rules.write(rules_text)
debug_print(f"Regeln gespeichert in '{patterns_filename_txt}'.")
except Exception as e_export: debug_print(f"Fehler Export Regeln: {e_export}")
except Exception as e_train: debug_print(f"FEHLER Training/Tuning: {e_train}"); import traceback; debug_print(traceback.format_exc())
else: debug_print("Datenvorbereitung fehlgeschlagen/leer. Modus 'train_technician_model' abgebrochen.")
else:
debug_print(f"Unbekannter Modus '{mode}'.")
except Exception as e:
debug_print(f"FATAL: Unerwarteter Fehler in main try-Block: {e}")
import traceback
debug_print(traceback.format_exc())
# --- Abschluss ---
end_time = time.time(); duration = end_time - start_time
debug_print(f"Verarbeitung abgeschlossen um {datetime.now().strftime('%H:%M:%S')}.")
debug_print(f"Gesamtdauer: {duration:.2f} Sekunden.")
debug_print(f"===== Skript beendet =====")
if LOG_FILE:
try:
with open(LOG_FILE, "a", encoding="utf-8") as f: f.write(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] ===== Skript wirklich beendet =====\n")
except: pass
print(f"Verarbeitung abgeschlossen. Logfile: {LOG_FILE}")
# Führt die main-Funktion aus, wenn das Skript direkt gestartet wird
if __name__ == '__main__':
main()