3763 lines
205 KiB
Python
3763 lines
205 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
v1.6.2: Verfeinere Timestamp-Logik & integriere ML-Datenvorbereitung
|
||
|
||
Git-Änderungsbeschreibung:
|
||
- Passe Dispatcher (`run_dispatcher`) und `GoogleSheetHandler.get_start_row_index` an, um den Startpunkt basierend auf dem Website Scrape Timestamp (Spalte AT) zu bestimmen.
|
||
- Implementiere individuelle Timestamp-Prüfungen in den Batch-Funktionen (`process_verification_only` (AN), `process_website_batch` (AT), `process_branch_batch` (AO)), um das erneute Verarbeiten abgeschlossener Zeilen zu verhindern.
|
||
- Überarbeite `_process_single_row` (`full_run`, `reeval`), um für jeden Teilbereich (Wiki, Website, Chat) den zugehörigen Timestamp zu prüfen und nur bei Bedarf auszuführen.
|
||
- Passe `_process_batch` an, sodass es nur noch Ergebnisspalten (S-Y) schreibt; Timestamps werden jetzt von der aufrufenden Funktion gesetzt.
|
||
- Füge neue Spalten (AT: Website TS, AU: Gesch. Techniker Bucket, AV: Finaler Umsatz, AW: Finaler MA) zur `alignment_demo` und `COLUMN_MAP` hinzu.
|
||
- Integriere die Funktion `prepare_data_for_modeling` als Methode in die `DataProcessor`-Klasse (wird noch nicht aktiv in einem Modus aufgerufen).
|
||
"""
|
||
|
||
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
|
||
|
||
# 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.2"
|
||
LANG = "de"
|
||
SHEET_URL = "https://docs.google.com/spreadsheets/d/1u_gHr9JUfmV1-iviRzbSe3575QEp7KLhK5jFV_gJcgo"
|
||
MAX_RETRIES = 3
|
||
RETRY_DELAY = 5
|
||
LOG_CSV = "gpt_antworten_log.csv" # Wird dieser Log noch verwendet? Ggf. entfernen.
|
||
SIMILARITY_THRESHOLD = 0.65
|
||
DEBUG = True
|
||
WIKIPEDIA_SEARCH_RESULTS = 5
|
||
HTML_PARSER = "html.parser"
|
||
BATCH_SIZE = 10
|
||
TOKEN_MODEL = "gpt-3.5-turbo" # Oder "gpt-4" etc.
|
||
|
||
# Zentrales API-Key-Management
|
||
API_KEYS = {}
|
||
|
||
@classmethod
|
||
def load_api_keys(cls):
|
||
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)
|
||
# Set OpenAI Key globally if loaded
|
||
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):
|
||
try:
|
||
with open(filepath, "r") as f:
|
||
return f.read().strip()
|
||
except Exception as e:
|
||
debug_print(f"Fehler beim Lesen des API-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 - !! Wichtig für die Zielvariable !!
|
||
"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 (ungenutzt?)
|
||
"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 (War das nicht die Schätzung? Verwechselt mit AU?)
|
||
"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
|
||
"Timestamp letzte Prüfung": 40, # AO
|
||
"Version": 41, # AP
|
||
"Tokens": 42, # AQ
|
||
"Website Rohtext": 43, # AR
|
||
"Website Zusammenfassung": 44, # AS
|
||
# --- NEUE SPALTEN ---
|
||
"Website Scrape Timestamp": 45, # AT
|
||
"Geschätzter Techniker Bucket": 46, # AU
|
||
"Finaler Umsatz (Wiki>CRM)": 47,# AV
|
||
"Finaler Mitarbeiter (Wiki>CRM)": 48 # AW
|
||
}
|
||
# 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):
|
||
self.sheet = None
|
||
self.sheet_values = []
|
||
self.headers = [] # Um Header-Zeilen zu speichern (Annahme: Zeile 1 sind die Namen)
|
||
self._connect()
|
||
if self.sheet:
|
||
self._load_data()
|
||
|
||
# retry_on_failure Decorator sollte hier angewendet werden
|
||
@retry_on_failure
|
||
def _connect(self):
|
||
"""Stellt Verbindung zum Google Sheet her."""
|
||
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 gspread.exceptions.APIError as e:
|
||
debug_print(f"FEHLER bei Google API Verbindung: Status {e.response.status_code} - {e.response.text[:200]}")
|
||
raise # Damit retry greift
|
||
except Exception as e:
|
||
debug_print(f"FEHLER bei der Google Sheets Verbindung: {type(e).__name__} - {e}")
|
||
raise # Damit retry greift
|
||
|
||
# retry_on_failure Decorator sollte hier angewendet werden
|
||
@retry_on_failure
|
||
def _load_data(self):
|
||
"""Lädt alle Daten aus dem Sheet."""
|
||
if not self.sheet:
|
||
debug_print("Fehler: Keine Sheet-Verbindung zum Laden der Daten.")
|
||
self.sheet_values = []
|
||
self.headers = []
|
||
return
|
||
debug_print("Lade Daten aus Google Sheet...")
|
||
self.sheet_values = self.sheet.get_all_values()
|
||
if len(self.sheet_values) >= 1:
|
||
# Speichere die *echten* Header-Namen aus der ersten Zeile
|
||
self.headers = self.sheet_values[0]
|
||
# Die Alignment-Demo Header (Zeilen 1-5) werden hier nicht separat gespeichert,
|
||
# sheet_values enthält alles.
|
||
else:
|
||
self.headers = []
|
||
debug_print("Warnung: Google Sheet scheint leer zu sein.")
|
||
debug_print(f"Daten geladen: {len(self.sheet_values)} Zeilen insgesamt.")
|
||
|
||
def get_data(self):
|
||
"""Gibt die geladenen Daten zurück (ohne die ersten 5 Header-Zeilen)."""
|
||
header_rows = 5 # Definiert die Anzahl der zu überspringenden Header-Zeilen
|
||
if len(self.sheet_values) <= header_rows:
|
||
debug_print("Warnung in get_data: Weniger Zeilen als Header-Zeilen vorhanden.")
|
||
return []
|
||
# Gibt eine Slice der Liste zurück, die die Datenzeilen enthält
|
||
return self.sheet_values[header_rows:]
|
||
|
||
def get_all_data_with_headers(self):
|
||
"""Gibt alle Daten inklusive Header zurück."""
|
||
return self.sheet_values
|
||
|
||
def _get_col_letter(self, col_idx_1_based):
|
||
""" Konvertiert 1-basierten Spaltenindex in Buchstaben. """
|
||
string = ""
|
||
n = col_idx_1_based
|
||
while n > 0:
|
||
n, remainder = divmod(n - 1, 26)
|
||
string = chr(65 + remainder) + string
|
||
return string
|
||
|
||
def get_start_row_index(self, check_column_key, min_sheet_row=7):
|
||
"""
|
||
Findet den Index der ersten Zeile (0-basiert für Daten nach Header),
|
||
ab einer Mindestzeilennummer im Sheet, in der der Timestamp in der
|
||
Spalte, die durch den *Schlüssel* in COLUMN_MAP definiert ist, fehlt.
|
||
|
||
Args:
|
||
check_column_key (str): Der Schlüssel in COLUMN_MAP für die zu prüfende Spalte.
|
||
min_sheet_row (int): Die 1-basierte Zeilennummer im Sheet, ab der gesucht werden soll.
|
||
|
||
Returns:
|
||
int: Der 0-basierte Index in der Datenliste (ohne Header),
|
||
oder der Index nach der letzten Zeile, wenn alle gefüllt sind.
|
||
"""
|
||
header_rows = 5
|
||
data_rows = self.get_data() # Holt Daten OHNE die 5 Header
|
||
|
||
if not data_rows:
|
||
debug_print("Keine Datenzeilen vorhanden für get_start_row_index.")
|
||
return 0
|
||
|
||
# Hole den Spaltenindex aus COLUMN_MAP
|
||
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!")
|
||
# Fallback oder Fehler werfen? Vorerst auf eine bekannte Spalte (AO) zurückfallen? Schlecht.
|
||
# Besser: None zurückgeben oder Fehler werfen, damit aufrufende Funktion es merkt.
|
||
# Hier: Gib -1 zurück als Fehlerindikator
|
||
return -1
|
||
|
||
actual_col_letter = self._get_col_letter(check_column_index + 1) # +1 für 1-basierte Konvertierung
|
||
|
||
# Berechne den 0-basierten Startindex für die *Datenliste* data_rows
|
||
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} (Sheet-Zeile {search_start_index_in_data + header_rows + 1}) nach leerem Wert in Spalte '{check_column_key}' ({actual_col_letter}, Index {check_column_index}).")
|
||
|
||
if search_start_index_in_data >= len(data_rows):
|
||
debug_print(f"Start-Suchindex ({search_start_index_in_data}) liegt nach oder auf letzter Datenzeile ({len(data_rows)-1}). Alle vorherigen Zeilen scheinen gefüllt.")
|
||
return len(data_rows) # Index nach der letzten Zeile
|
||
|
||
# Durchlaufe die Datenzeilen ab dem berechneten Startindex
|
||
for i in range(search_start_index_in_data, len(data_rows)):
|
||
row = data_rows[i]
|
||
current_sheet_row = i + header_rows + 1
|
||
|
||
# Prüfe den Wert in der Zielspalte
|
||
cell_value = None
|
||
is_empty = True
|
||
if len(row) > check_column_index:
|
||
cell_value = row[check_column_index]
|
||
if cell_value and str(cell_value).strip(): # Prüft auf nicht leer und nicht nur Whitespace
|
||
is_empty = False
|
||
|
||
# DEBUG Log für jede 1000ste Zeile oder wenn ein relevanter Übergang erwartet wird
|
||
if i == search_start_index_in_data or i % 1000 == 0 or current_sheet_row in [2121, 2122, 8926, 8927, 8928]:
|
||
debug_print(f" -> Prüfe Daten-Index {i} (Sheet Zeile {current_sheet_row}): Wert in Spalte {actual_col_letter}='{cell_value}' -> Leer? {is_empty}")
|
||
|
||
if is_empty:
|
||
debug_print(f"Erste Zeile ab Zeile {min_sheet_row} ohne Wert in Spalte {actual_col_letter} gefunden: Zeile {current_sheet_row} (Daten-Index {i})")
|
||
return i # Gibt den 0-basierten Index *innerhalb der Datenliste* zurück
|
||
|
||
# Wenn die Schleife durchläuft, sind alle Zeilen ab dem Start gefüllt
|
||
last_index = len(data_rows)
|
||
debug_print(f"Alle Zeilen ab Daten-Index {search_start_index_in_data} (Sheet Zeile {search_start_index_in_data + header_rows + 1}) haben einen Wert in Spalte {actual_col_letter}. Nächster Daten-Index wäre {last_index}.")
|
||
return last_index
|
||
|
||
# --- NEU HINZUGEFÜGTE METHODE ---
|
||
# retry_on_failure Decorator sollte hier angewendet werden
|
||
@retry_on_failure
|
||
def batch_update_cells(self, update_data):
|
||
"""
|
||
Führt ein Batch-Update im Google Sheet durch. Beinhaltet Fehlerbehandlung.
|
||
|
||
Args:
|
||
update_data (list): Eine Liste von Dictionaries, jedes mit 'range' und 'values'.
|
||
z.B. [{'range': 'A1', 'values': [['Wert']]}, ...]
|
||
|
||
Returns:
|
||
bool: True bei Erfolg, False bei Fehler nach Retries.
|
||
"""
|
||
if not self.sheet:
|
||
debug_print("FEHLER: Keine Sheet-Verbindung für Batch-Update.")
|
||
return False
|
||
if not update_data:
|
||
return True
|
||
|
||
try:
|
||
self.sheet.batch_update(update_data, value_input_option='USER_ENTERED')
|
||
return True
|
||
except gspread.exceptions.APIError as e:
|
||
debug_print(f"Google API Fehler beim Batch-Update: Status {e.response.status_code} - {e.response.text[:500]}")
|
||
raise e # Fehler weitergeben, damit der Decorator ihn fängt
|
||
except Exception as e:
|
||
debug_print(f"Allgemeiner Fehler beim Batch-Update: {type(e).__name__} - {e}")
|
||
raise e
|
||
|
||
|
||
|
||
# ==================== 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."
|
||
|
||
@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.
|
||
Prüft für jede Zeile im Bereich, ob Timestamp AN bereits gesetzt ist.
|
||
"""
|
||
debug_print(f"Starte Wikipedia-Verifizierungsmodus (Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...")
|
||
|
||
# Lade aktuelle Daten direkt hier
|
||
all_data = sheet_handler.get_all_data_with_headers()
|
||
if not all_data or len(all_data) <= 5: # Check ob Daten vorhanden sind
|
||
debug_print("FEHLER/WARNUNG: Keine Daten zum Verarbeiten in process_verification_only gefunden.")
|
||
return
|
||
|
||
# Hole Index für AN Timestamp
|
||
timestamp_col_key = "Wikipedia Timestamp"
|
||
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 "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):
|
||
debug_print(f"Warnung (Wiki): Zeilenindex {row_index_in_list} außerhalb des Datenbereichs ({len(all_data)} Zeilen).")
|
||
continue
|
||
|
||
row = all_data[row_index_in_list]
|
||
|
||
# --- DEBUGGING Timestamp-Prüfung AN ---
|
||
ts_value_an = "INDEX_FEHLER"
|
||
ts_an_is_set = False
|
||
if len(row) > timestamp_col_index:
|
||
ts_value_an = row[timestamp_col_index]
|
||
ts_an_is_set = bool(str(ts_value_an).strip()) # Prüfe, ob Wert nach strip nicht leer ist
|
||
# Logge für die ersten 5 geprüften und letzte 5 geprüfte Zeilen im Range, oder wenn es sich ändert
|
||
log_debug = (i < start_row_index_in_sheet + 5 or i > end_row_index_in_sheet - 5 or i % 500 == 0)
|
||
if log_debug:
|
||
debug_print(f"Zeile {i} (Wiki Check): Prüfe Timestamp {ts_col_letter} (Index {timestamp_col_index}). Rohwert='{ts_value_an}', Strip='{str(ts_value_an).strip()}', Bedingung ist: {ts_an_is_set}")
|
||
# --- Ende DEBUGGING ---
|
||
|
||
if ts_an_is_set:
|
||
# Weniger Lärm im Log, nur zählen
|
||
skipped_count += 1
|
||
if skipped_count == 1 or skipped_count % 100 == 0: # Logge den ersten und jeden 100. übersprungenen
|
||
debug_print(f"Zeile {i}: Überspringe Wiki-Verifizierung (Timestamp {ts_col_letter} vorhanden: '{ts_value_an.strip()}'). ({skipped_count} gesamt übersprungen)")
|
||
continue
|
||
# --- Ende Timestamp-Prüfung ---
|
||
|
||
# (Restliche Logik zum Erstellen von entry_text wie zuvor)
|
||
company_name = row[COLUMN_MAP["CRM Name"]] if len(row) > COLUMN_MAP["CRM Name"] else ''
|
||
crm_desc = row[COLUMN_MAP["CRM Beschreibung"]] if len(row) > COLUMN_MAP["CRM Beschreibung"] else ''
|
||
wiki_url = row[COLUMN_MAP["Wiki URL"]] if len(row) > COLUMN_MAP["Wiki URL"] and row[COLUMN_MAP["Wiki URL"]].strip() not in ['', 'k.A.'] else 'k.A.'
|
||
wiki_paragraph = row[COLUMN_MAP["Wiki Absatz"]] if len(row) > COLUMN_MAP["Wiki Absatz"] else 'k.A.'
|
||
wiki_categories = row[COLUMN_MAP["Wiki Kategorien"]] if len(row) > COLUMN_MAP["Wiki Kategorien"] 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
|
||
|
||
# Wenn Batch voll oder letzte Zeile erreicht
|
||
if len(current_batch) >= batch_size or i == end_row_index_in_sheet:
|
||
if current_batch:
|
||
# Setze zuerst den AN 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:
|
||
# Verwende Spaltenbuchstaben dynamisch
|
||
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-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-Timestamps {ts_col_letter} für Batch.")
|
||
|
||
# Rufe dann _process_batch auf, das S-Y schreibt
|
||
_process_batch(sheet_handler.sheet, current_batch, current_row_numbers)
|
||
time.sleep(Config.RETRY_DELAY) # Pause nach API Calls
|
||
|
||
current_batch = []
|
||
current_row_numbers = []
|
||
|
||
debug_print(f"Wikipedia-Verifizierungs-Batch abgeschlossen. {processed_count} Zeilen zur Verarbeitung an ChatGPT gesendet, {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)
|
||
def process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet):
|
||
"""Batch-Prozess für Website-Scraping (Rohtext & Zusammenfassung)."""
|
||
debug_print(f"Starte Website-Scraping (Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...")
|
||
|
||
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_website_batch gefunden.")
|
||
return
|
||
|
||
sheet = sheet_handler.sheet
|
||
timestamp_col_key = "Website Scrape Timestamp"
|
||
timestamp_col_index = COLUMN_MAP.get(timestamp_col_key)
|
||
website_col_index = COLUMN_MAP.get("CRM Website")
|
||
rohtext_col_index = COLUMN_MAP.get("Website Rohtext")
|
||
summary_col_index = COLUMN_MAP.get("Website Zusammenfassung")
|
||
version_col_index = COLUMN_MAP.get("Version")
|
||
|
||
if None in [timestamp_col_index, website_col_index, rohtext_col_index, summary_col_index, version_col_index]:
|
||
debug_print(f"FEHLER: Mindestens ein benötigter Spaltenindex für process_website_batch fehlt in COLUMN_MAP (benötigt: '{timestamp_col_key}', 'CRM Website', 'Website Rohtext', 'Website Zusammenfassung', 'Version').")
|
||
return
|
||
|
||
ts_col_letter = sheet_handler._get_col_letter(timestamp_col_index + 1)
|
||
rohtext_col_letter = sheet_handler._get_col_letter(rohtext_col_index + 1)
|
||
summary_col_letter = sheet_handler._get_col_letter(summary_col_index + 1)
|
||
version_col_letter = sheet_handler._get_col_letter(version_col_index + 1)
|
||
|
||
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):
|
||
debug_print(f"Warnung (Website): Zeilenindex {row_index_in_list} außerhalb des Datenbereichs ({len(all_data)} Zeilen).")
|
||
continue
|
||
|
||
row = all_data[row_index_in_list]
|
||
|
||
# --- ERZWUNGENE DEBUGGING Timestamp-Prüfung AT ---
|
||
ts_value_at = "INDEX_FEHLER"
|
||
ts_at_is_set = False
|
||
if len(row) > timestamp_col_index:
|
||
ts_value_at = row[timestamp_col_index]
|
||
# Konvertiere zu String VOR strip, um Fehler bei None etc. zu vermeiden
|
||
ts_at_is_set = bool(str(ts_value_at).strip())
|
||
# Gib für JEDE Zeile im Bereich die Prüfung aus
|
||
debug_print(f"Zeile {i} (Website Check): Prüfe Timestamp {ts_col_letter} (Index {timestamp_col_index}). Rohwert='{ts_value_at}', Strip='{str(ts_value_at).strip()}', Überspringen? -> {ts_at_is_set}")
|
||
# --- Ende ERZWUNGENE DEBUGGING ---
|
||
|
||
if ts_at_is_set:
|
||
skipped_count += 1
|
||
# Logge nur noch selten, da wir die Prüfung oben schon loggen
|
||
# if skipped_count == 1 or skipped_count % 100 == 0:
|
||
# debug_print(f"Zeile {i}: Überspringe Website-Scraping (Timestamp {ts_col_letter} vorhanden). ({skipped_count} gesamt übersprungen)")
|
||
continue
|
||
# --- Ende Timestamp-Prüfung ---
|
||
|
||
# (Rest der Logik zum Scrapen und Updaten wie zuvor)
|
||
website_url = row[website_col_index] if len(row) > website_col_index else ""
|
||
if not website_url or website_url.strip().lower() == "k.a.":
|
||
skipped_count += 1
|
||
continue
|
||
|
||
raw_text = get_website_raw(website_url)
|
||
summary = summarize_website_content(raw_text)
|
||
processed_count += 1
|
||
|
||
updates = []
|
||
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
current_version = Config.VERSION
|
||
|
||
updates.append({'range': f'{rohtext_col_letter}{i}', 'values': [[raw_text]]})
|
||
updates.append({'range': f'{summary_col_letter}{i}', 'values': [[summary]]})
|
||
updates.append({'range': f'{ts_col_letter}{i}', 'values': [[current_timestamp]]}) # AT 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}: Website-Daten erfolgreich aktualisiert.") # Geänderte Log-Nachricht
|
||
if not success:
|
||
debug_print(f"FEHLER beim Schreiben der Website-Updates für Zeile {i}.")
|
||
|
||
time.sleep(Config.RETRY_DELAY)
|
||
|
||
debug_print(f"Website-Scraping (Batch) abgeschlossen. {processed_count} Websites gescraped, {skipped_count} Zeilen übersprungen.")
|
||
|
||
# Komplette Funktion process_branch_batch (prüft jetzt Timestamp AO mit erzwungenem Debugging)
|
||
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()
|
||
if not all_data or len(all_data) <= 5:
|
||
debug_print("FEHLER/WARNUNG: Keine Daten zum Verarbeiten in process_branch_batch gefunden.")
|
||
return
|
||
|
||
sheet = sheet_handler.sheet
|
||
|
||
# Hole Indizes aus COLUMN_MAP
|
||
timestamp_col_key = "Timestamp letzte Prüfung"
|
||
timestamp_col_index = COLUMN_MAP.get(timestamp_col_key)
|
||
branche_crm_idx = COLUMN_MAP.get("CRM Branche")
|
||
beschreibung_idx = COLUMN_MAP.get("CRM Beschreibung")
|
||
branche_wiki_idx = COLUMN_MAP.get("Wiki Branche")
|
||
kategorien_wiki_idx = COLUMN_MAP.get("Wiki Kategorien")
|
||
summary_web_idx = COLUMN_MAP.get("Website Zusammenfassung")
|
||
version_col_index = 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 (benötigt: '{timestamp_col_key}', 'CRM Branche', etc.).")
|
||
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:
|
||
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
|
||
if row_index_in_list >= len(all_data):
|
||
debug_print(f"Warnung (Branch): Zeilenindex {row_index_in_list} außerhalb des Datenbereichs ({len(all_data)} Zeilen).")
|
||
continue
|
||
|
||
row = all_data[row_index_in_list]
|
||
|
||
# --- ERZWUNGENE DEBUGGING Timestamp-Prüfung AO ---
|
||
ts_value_ao = "INDEX_FEHLER"
|
||
ts_ao_is_set = False
|
||
if len(row) > timestamp_col_index:
|
||
ts_value_ao = row[timestamp_col_index]
|
||
ts_ao_is_set = bool(str(ts_value_ao).strip())
|
||
# Gib für JEDE Zeile im Bereich die Prüfung aus
|
||
debug_print(f"Zeile {i} (Branch Check): Prüfe Timestamp {ts_col_letter} (Index {timestamp_col_index}). Rohwert='{ts_value_ao}', Strip='{str(ts_value_ao).strip()}', Überspringen? -> {ts_ao_is_set}")
|
||
# --- Ende ERZWUNGENE DEBUGGING ---
|
||
|
||
if ts_ao_is_set:
|
||
skipped_count += 1
|
||
# if skipped_count == 1 or skipped_count % 100 == 0:
|
||
# debug_print(f"Zeile {i}: Überspringe Branchen-Einschätzung (Timestamp {ts_col_letter} vorhanden). ({skipped_count} gesamt übersprungen)")
|
||
continue
|
||
# --- Ende Timestamp-Prüfung ---
|
||
|
||
# (Restliche Logik zum Datenholen und Bewerten wie zuvor)
|
||
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 ""
|
||
|
||
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.") # Geänderte Log-Nachricht
|
||
if not success:
|
||
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 übersprungen.")
|
||
|
||
def process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet):
|
||
"""Batch-Prozess für Website-Scraping (Rohtext & Zusammenfassung)."""
|
||
debug_print(f"Starte Website-Scraping (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 # Direkter Zugriff auf das Sheet-Objekt
|
||
|
||
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: Hier prüfen, ob Verarbeitung übersprungen werden soll (z.B. Zeitstempel schon vorhanden?)
|
||
# if len(row) > COLUMN_MAP["Timestamp letzte Prüfung"] and row[COLUMN_MAP["Timestamp letzte Prüfung"]].strip():
|
||
# debug_print(f"Zeile {i}: Überspringe Website-Scraping (Zeitstempel vorhanden).")
|
||
# continue
|
||
|
||
website_url = row[COLUMN_MAP["CRM Website"]] if len(row) > COLUMN_MAP["CRM Website"] else ""
|
||
if not website_url or website_url.strip().lower() == "k.a.":
|
||
debug_print(f"Zeile {i}: Kein gültiger Website-Eintrag, überspringe Website-Scraping.")
|
||
# Optional: Zeitstempel trotzdem setzen?
|
||
# sheet.update_cell(i, COLUMN_MAP["Timestamp letzte Prüfung"] + 1, datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||
# sheet.update_cell(i, COLUMN_MAP["Version"] + 1, Config.VERSION)
|
||
continue
|
||
|
||
debug_print(f"Zeile {i}: Verarbeite Website {website_url}...")
|
||
raw_text = get_website_raw(website_url)
|
||
summary = summarize_website_content(raw_text)
|
||
|
||
updates = []
|
||
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
current_version = Config.VERSION
|
||
|
||
updates.append({'range': f'AR{i}', 'values': [[raw_text]]}) # Spalte AR
|
||
updates.append({'range': f'AS{i}', 'values': [[summary]]}) # Spalte AS
|
||
updates.append({'range': f'AT{i}', 'values': [[current_timestamp]]}) # Spalte AT
|
||
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}: Website-Daten aktualisiert | Zeitstempel: {current_timestamp}, Version: {current_version}")
|
||
|
||
# Pause zwischen den Zeilen/Websites
|
||
time.sleep(Config.RETRY_DELAY)
|
||
|
||
debug_print("Website-Scraping (Batch) abgeschlossen.")
|
||
|
||
|
||
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?)
|
||
|
||
def run_dispatcher(mode, sheet_handler, row_limit=None):
|
||
"""
|
||
Wählt den passenden Batch-Prozess basierend auf dem Modus.
|
||
Ermittelt die Startzeile dynamisch basierend auf dem Timestamp in der relevanten Spalte.
|
||
"""
|
||
debug_print(f"Starte Dispatcher im Modus '{mode}' mit row_limit={row_limit}.")
|
||
header_rows = 5 # Definiere die Anzahl der Header-Zeilen
|
||
|
||
# --- Startzeilen-Ermittlung basierend auf Modus ---
|
||
# Definiere, welche Spalte für welchen Modus den Startpunkt bestimmt
|
||
# Standardmäßig AO für Gesamtprüfung, AT für Website, AN für Wiki, AO für Branch
|
||
start_col_key = "Timestamp letzte Prüfung" # Standard (Spalte AO)
|
||
min_start_row = 7 # Mindestens ab Zeile 7 suchen
|
||
|
||
if mode == "website":
|
||
start_col_key = "Website Scrape Timestamp" # Spalte AT
|
||
elif mode == "wiki":
|
||
start_col_key = "Wikipedia Timestamp" # Spalte AN
|
||
elif mode == "branch":
|
||
start_col_key = "Timestamp letzte Prüfung" # Spalte AO
|
||
elif mode == "combined":
|
||
# HIER KORRIGIERT: Combined startet basierend auf AO (letzter Schritt)
|
||
start_col_key = "Timestamp letzte Prüfung" # Spalte AO
|
||
# Füge ggf. andere Modi hinzu
|
||
|
||
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)
|
||
|
||
# Fehlerprüfung: Wenn get_start_row_index -1 zurückgibt (Schlüssel nicht gefunden)
|
||
if start_data_index == -1:
|
||
debug_print(f"FEHLER: Konnte Startzeile nicht ermitteln (Spaltenschlüssel '{start_col_key}' in COLUMN_MAP prüfen?). Dispatcher beendet.")
|
||
return
|
||
|
||
# Umrechnung des 0-basierten Daten-Index in die 1-basierte Sheet-Zeilennummer
|
||
start_row_index_in_sheet = start_data_index + header_rows + 1
|
||
|
||
# Hole Gesamtzahl der Zeilen für die Endberechnung
|
||
all_data = sheet_handler.get_all_data_with_headers()
|
||
total_sheet_rows = len(all_data)
|
||
|
||
# Prüfe, ob der Startpunkt überhaupt im Sheet liegt
|
||
if start_row_index_in_sheet > total_sheet_rows and total_sheet_rows > header_rows:
|
||
# Wenn der Startpunkt HINTER der letzten Zeile liegt, gibt es nichts zu tun
|
||
debug_print(f"Startzeile ({start_row_index_in_sheet}) liegt hinter der letzten Sheet-Zeile ({total_sheet_rows}). Keine neuen Zeilen zu verarbeiten. Dispatcher beendet.")
|
||
return
|
||
elif start_row_index_in_sheet > total_sheet_rows:
|
||
# Wenn das Sheet nur Header hat oder leer ist
|
||
debug_print(f"Sheet hat keine Datenzeilen oder Startzeile ({start_row_index_in_sheet}) ist ungültig. Dispatcher beendet.")
|
||
return
|
||
|
||
|
||
# --- Endzeilen-Ermittlung ---
|
||
if row_limit is not None and row_limit > 0:
|
||
# Berechne Endzeile basierend auf Startzeile und Limit,
|
||
# aber nicht über die letzte Zeile hinaus.
|
||
end_row_index_in_sheet = min(start_row_index_in_sheet + row_limit - 1, total_sheet_rows)
|
||
elif row_limit == 0:
|
||
debug_print("Zeilenlimit ist 0. Keine Verarbeitung.")
|
||
return
|
||
else: # Kein Limit oder negatives Limit -> bis zum Ende des Sheets
|
||
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}.")
|
||
|
||
# Zusätzliche Prüfung: Liegt Start nach Ende? (Sollte nicht passieren, aber sicher ist sicher)
|
||
if start_row_index_in_sheet > end_row_index_in_sheet:
|
||
debug_print("Berechnete Startzeile liegt nach der Endzeile. Keine Verarbeitung.")
|
||
return
|
||
|
||
# --- Modusauswahl und Aufruf der Verarbeitungsfunktionen ---
|
||
# Die aufgerufenen Funktionen müssen jetzt selbst prüfen, ob die jeweilige Zeile
|
||
# wegen eines bereits vorhandenen Timestamps übersprungen werden soll.
|
||
try:
|
||
if mode == "wiki":
|
||
# process_verification_only prüft Timestamp AN intern
|
||
process_verification_only(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet)
|
||
elif mode == "website":
|
||
# process_website_batch prüft Timestamp AT intern
|
||
process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet)
|
||
elif mode == "branch":
|
||
# process_branch_batch prüft Timestamp AO intern
|
||
process_branch_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet)
|
||
elif mode == "combined":
|
||
# Führt die Teile nacheinander aus, jeder Teil prüft seinen eigenen Timestamp
|
||
debug_print("--- Start Combined Mode: Wiki ---")
|
||
process_verification_only(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet) # Prüft AN
|
||
debug_print("--- Start Combined Mode: Website ---")
|
||
process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet) # Prüft AT
|
||
debug_print("--- Start Combined Mode: Branch ---")
|
||
process_branch_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet) # Prüft AO
|
||
debug_print("--- Combined Mode abgeschlossen ---")
|
||
else:
|
||
debug_print(f"Ungültiger Modus '{mode}' wurde im Dispatcher übergeben.")
|
||
|
||
except Exception as e:
|
||
debug_print(f"FEHLER im Dispatcher während der Ausführung von Modus '{mode}': {e}")
|
||
import traceback
|
||
debug_print(traceback.format_exc()) # Gib den Traceback aus für detaillierte Fehlersuche
|
||
|
||
# --- 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 AW) 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 (NEU)
|
||
"Geschätzter Techniker Bucket", # AU (NEU)
|
||
"Finaler Umsatz (Wiki>CRM)", # AV (NEU)
|
||
"Finaler Mitarbeiter (Wiki>CRM)" # AW (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 (NEU) - Timestamp vom Scraping-Prozess
|
||
"ML Modell / Skript", # AU (NEU) - Ergebnis der Schätzung
|
||
"Skript (Wiki/CRM)", # AV (NEU) - Berechnet nach Priorität
|
||
"Skript (Wiki/CRM)" # AW (NEU) - Berechnet nach Priorität
|
||
],
|
||
[ # 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 (NEU)
|
||
"Anzahl Servicetechniker Bucket", # AU (NEU)
|
||
"Umsatz", # AV (NEU)
|
||
"Anzahl Mitarbeiter" # AW (NEU)
|
||
],
|
||
[ # Kurze Beschreibung (Zeile 4)
|
||
"Systemspalte, irrelevant für den Prompt. Wird zur manuellen Neuprüfung genutzt.", "Enthält den Firmennamen; Normalisierung erfolgt bei der Suche.", "Manuell gepflegte Kurzform, meist die ersten 2 Worte.", "Website des Unternehmens.", "Ort des Unternehmens.", "Kurze Beschreibung des Unternehmens.", "Aktuelle Branchenzuweisung gemäß Ziel-Branchenschema.", "Externe Branchenbeschreibung (z.B. von Dealfront).", "Recherchierte Anzahl Servicetechniker.", "Umsatz in Mio. € (CRM).", "Anzahl Mitarbeiter (CRM).", "Vorgeschlagene Wikipedia URL (Ausgangspunkt).", "Wikipedia URL (Ergebnis der Suche).", "Erster Absatz des Wikipedia-Artikels.", "Wikipedia-Branche – für den Branchenabgleich.", "Wikipedia-Umsatz – zur Validierung.", "Wikipedia-Mitarbeiterzahl – zur Validierung.", "Liste der Wikipedia-Kategorien.", "\"OK\" oder \"X\" – Ergebnis der Wikipedia-Validierung.", "Begründung bei Inkonsistenz (Wiki).", "Chat-Vorschlag Wiki Artikel: Falls kein passender Artikel gefunden, alternativ vorschlagen.", "Nicht genutzt, evtl. für zukünftige Funktionen.", "Branchenvorschlag via ChatGPT (alternativer Vorschlag).", "Vergleich: Übereinstimmung CRM vs. ChatGPT-Branche (OK/X).", "Begründung bei abweichender Branchenzuordnung.", "FSM-Relevanz: Bewertung, ob das Unternehmen für FSM geeignet ist (OK/X).", "Begründung zur FSM-Bewertung.", "Schätzung Anzahl Mitarbeiter via ChatGPT (nur falls Wiki-Daten fehlen).", "Vergleich CRM vs. Wiki vs. ChatGPT Mitarbeiterzahl (OK/X).", "Begründung bei Mitarbeiterabweichung (Prozentdifferenz).", "Schätzung Servicetechniker via ChatGPT (in Kategorien, z.B. <50, >100, >200, >500).", "Begründung bei Abweichung der Technikerzahl.", "Schätzung Umsatz via ChatGPT.", "Begründung bei Umsatzabweichung.", "Anzahl Kontakte (Serviceleiter) gefunden.", "Anzahl Kontakte (IT-Leiter) gefunden.", "Anzahl Kontakte (Management) gefunden.", "Anzahl Kontakte (Disponent) gefunden.", "Timestamp der Kontaktsuche.", "Timestamp der Wikipedia-Suche.", "Timestamp der ChatGPT-Bewertung / Letzte Prüfung der Zeile.", "Ausgabe der Skriptversion, die das Ergebnis erzeugt hat.", "Token-Zählung (separat pro Modul).", "Roh extrahierter Text der Firmenwebsite (maximal 1000 Zeichen).", "Zusammenfassung des Webseiteninhalts, fokussiert auf Tätigkeitsfeld, Produkte & Leistungen.",
|
||
"Timestamp des letzten Website-Scrapings (AR, AS).", # AT (NEU)
|
||
"Geschätzter Bucket (1-7) für Servicetechniker basierend auf ML-Modell.",# AU (NEU)
|
||
"Konsolidierter Umsatz (Mio €) nach Priorität Wiki > CRM.", # AV (NEU)
|
||
"Konsolidierte Mitarbeiterzahl nach Priorität Wiki > CRM." # AW (NEU)
|
||
],
|
||
[ # Aufgabe / Funktion (Zeile 5) - Hier müssen wir neue Einträge hinzufügen
|
||
"Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Wird durch Wikipedia Scraper bereitgestellt", "Wird zunächst nicht verwendet, kann aber zum Vergleich mit der CRM-Beschreibung genutzt werden.", "Wird u.a. zur finalen Ermittlung der Branche im Ziel-Branchenschema genutzt und mit der CRM-Branche bzw. CRM-Beschreibung Branche Extern verglichen. Stimmen alle drei Einstufungen grob überein, bestärkt dies die ursprüngliche Einstufung. Laufen diese Branchen weit auseinander, soll – sofern der Wikipedia-Artikel verifiziert ist – die Branche von Wikipedia als zuverlässigste Quelle bewertet werden, danach folgen CRM-Beschreibung Branche Extern und CRM-Branche an dritter Stelle.", "Wird u.a. mit CRM-Umsatz zur Validierung des Unternehmens verglichen bzw. zur Bewertung der Größe / Einschätzung der Technikerzahl bzw. FSM-Relevanz genutzt.", "Wird u.a. mit CRM-Anzahl Mitarbeiter zur Validierung des Unternehmens verglichen bzw. zur Bewertung der Größe / Einschätzung der Technikerzahl bzw. FSM-Relevanz genutzt.", "Wenn Website-Daten fehlen, wird in diesem Feld keine zusätzliche Information einbezogen; ansonsten als zusätzlicher Kontext.", "\"Es soll durch ChatGPT geprüft werden, ob anhand der vorliegenden Daten bestätigt werden kann, dass der Wikipedia-Eintrag das Unternehmen sicher beschreibt. Dabei können alle Daten (Website, Umsatz, Mitarbeiterzahl etc.) berücksichtigt werden. Eine gewisse Toleranz (±30%) ist erlaubt. Insbesondere bei Konzernstrukturen muss großzügig bewertet werden. Abweichungen sollen in der Spalte 'Chat Begründung Wiki Inkonsistenz' begründet werden.\"", "\"Liegt eine Inkonsistenz zwischen dem gefundenen Wikipedia-Artikel und dem Unternehmen vor, so soll dies kurz begründet werden. Wurde der Artikel als unpassend identifiziert, soll ChatGPT einen alternativen Wikipedia-Artikel vorschlagen und diesen in 'Chat Vorschlag Wiki Artikel' ausgeben.\"", "\"Sollte durch die Wikipedia-Suche kein Artikel gefunden werden oder als unpassend bewertet werden, soll ChatGPT eigenständig nach einem passenden Artikel recherchieren. Der gefundene Artikel muss vom als unpassend bewerteten Artikel abweichen. Wird kein passender Artikel gefunden, soll 'kein Artikel verfügbar' ausgegeben werden.\"", "XXX derzeit nicht verwendet, wird vermutlich gelöscht xxx", "\"ChatGPT soll anhand der vorliegenden Informationen prüfen, welcher Branche des Ziel-Branchenschemas das Unternehmen am ehesten zugeordnet werden kann. Das Ziel-Branchenschema darf nicht verändert werden, sondern die Vorschläge müssen exakt diesem Schema entsprechen.\"", "Die in Spalte CRM festgelegte Branche soll mit der von ChatGPT ermittelten Branche in 'Chat Vorschlag Branche' verglichen werden.", "Weicht die von ChatGPT ermittelte Branche von der in CRM vorliegenden ab, so soll ChatGPT die Abweichung kurz begründen.", "ChatGPT soll anhand der vorliegenden Daten prüfen, ob das Unternehmen für den Einsatz einer Field Service Management Lösung geeignet ist.", "Die in 'Chat Begründung für FSM Relevanz' angegebene Begründung soll zur Bewertung der FSM-Eignung herangezogen werden.", "Nur wenn kein Wikipedia-Eintrag vorhanden ist, soll ChatGPT basierend auf öffentlich verfügbaren Informationen die Mitarbeiterzahl schätzen. Falls keine Schätzung möglich ist, wird 'keine Schätzung möglich' ausgegeben.", "Entspricht die durch ChatGPT ermittelte Mitarbeiterzahl ungefähr den in CRM und Wikipedia ermittelten Werten (±30%), wird 'OK' ausgegeben, andernfalls 'X' und eine Begründung in 'Chat Begründung Abweichung Mitarbeiterzahl'.", "Weicht die von ChatGPT geschätzte Mitarbeiterzahl signifikant von den CRM- oder Wikipedia-Werten ab, soll dies kurz begründet werden.", "ChatGPT soll auf Basis öffentlich zugänglicher Informationen eine Schätzung der Anzahl Servicetechniker abgeben (Kategorisierung: 0, <50, >100, >200, >500). Bei Abweichungen der Recherche-Werte soll 'X' ausgegeben werden, ansonsten 'OK'.", "Weicht die von ChatGPT geschätzte Technikerzahl von den CRM-Werten ab, soll dies begründet werden.", "Nur wenn kein Wikipedia-Eintrag vorhanden ist, soll ChatGPT den Umsatz anhand der Unternehmenswebsite oder anderer Daten schätzen. Bei fehlender Schätzung soll 'keine Schätzung möglich' ausgegeben werden.", "ChatGPT soll signifikante Umsatzabweichungen zwischen den Schätzungen von Chat, Wikipedia und CRM begründen. Stimmen die Werte (±30%) überein, wird 'OK' ausgegeben.", "Über SerpAPI wird zusammen mit der in 'CRM Kurzform' enthaltenen Information nach 'Serviceleiter' gesucht.", "Über SerpAPI wird zusammen mit 'CRM Kurzform' nach 'Leiter IT' gesucht.", "Über SerpAPI wird zusammen mit 'CRM Kurzform' nach 'Geschäftsführer' gesucht.", "Über SerpAPI wird zusammen mit 'CRM Kurzform' erneut nach 'Serviceleiter' gesucht.", "Wenn die Kontaktsuche gestartet wird, wird der erste Eintrag ohne Zeitstempel gesucht; Zeilen mit vorhandenem Zeitstempel werden übersprungen.", "Wenn die Wikipedia-Suche gestartet wird, wird der erste Eintrag ohne Zeitstempel gesucht; Zeilen mit vorhandenem Zeitstempel werden übersprungen.", "Wenn die ChatGPT-Bewertung gestartet wird, wird der erste Eintrag ohne Zeitstempel gesucht; Zeilen mit vorhandenem Zeitstempel werden übersprungen.", "Wird durch das System befüllt", "Wird durch tiktoken berechnet", "Wird durch Web Scraper befüllt (z.B. Modus website)", "Wird durch ChatGPT API aus Website Rohtext generiert (z.B. Modus website)",
|
||
"Timestamp wird gesetzt, wenn Website Rohtext/Zusammenfassung geschrieben werden.", # AT (NEU)
|
||
"Ergebnis der Schätzung durch das trainierte ML-Modell (z.B. Decision Tree).", # AU (NEU)
|
||
"Vom Skript berechneter Wert, priorisiert Wiki > CRM. Dient als Input für ML-Modell und zur Transparenz.", # AV (NEU)
|
||
"Vom Skript berechneter Wert, priorisiert Wiki > CRM. Dient als Input für ML-Modell und zur Transparenz." # AW (NEU)
|
||
]
|
||
]
|
||
# Bestimme den Bereich basierend auf der Anzahl der Spalten in der ersten Header-Zeile (jetzt AW)
|
||
num_cols = len(new_headers[0]) # Sollte 49 sein (A=1 ... AW=49)
|
||
def colnum_string(n):
|
||
string = ""
|
||
while n > 0:
|
||
n, remainder = divmod(n - 1, 26)
|
||
string = chr(65 + remainder) + string
|
||
return string
|
||
|
||
end_col_letter = colnum_string(num_cols) # Sollte "AW" ergeben
|
||
header_range = f"A1:{end_col_letter}{len(new_headers)}" # Sollte A1:AW5 sein
|
||
|
||
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: 'train_technician_model' als Option hinzugefügt
|
||
parser.add_argument("--mode", type=str, help="Betriebsmodus (z.B. combined, ..., full_run, alignment, train_technician_model)")
|
||
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
|
||
# HIER NEU: 'train_technician_model' hinzugefügt
|
||
valid_modes = ["combined", "wiki", "website", "branch", "reeval", "website_lookup", "website_details", "contacts", "full_run", "alignment", "train_technician_model"]
|
||
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-Verifizierung, Website-Scraping & Branch-Einschätzung (Batch, ab erster leerer Zeile)")
|
||
print(" wiki: Nur Wikipedia-Verifizierung (Batch, ab erster leerer Zeile)")
|
||
print(" website: Nur Website-Scraping & Zusammenfassung (Batch, ab erster leerer Zeile)")
|
||
print(" branch: Nur Branchen-Einschätzung (Batch, ab erster leerer Zeile)")
|
||
print(" reeval: Verarbeitet alle Zeilen mit 'x' in Spalte A (volle Verarbeitung)")
|
||
print(" website_lookup: Sucht fehlende Websites (Spalte D) via SERP API")
|
||
print(" website_details:Extrahiert Title/Desc/H-Tags für Zeilen mit 'x' in Spalte A")
|
||
print(" contacts: Sucht LinkedIn Kontakte via SERP API und schreibt in 'Contacts' Blatt")
|
||
print(" full_run: Verarbeitet alle Zeilen sequentiell ab der ersten ohne Zeitstempel (AO)")
|
||
print(" alignment: Schreibt die Definitions-Header (Zeilen 1-5) ins Hauptblatt (Überschreibt A1:AS5!)")
|
||
print(" train_technician_model: Bereitet Daten vor, trainiert & evaluiert Decision Tree zur Technikerschätzung") # NEUE Beschreibung
|
||
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 (Logik bleibt unverändert, fragt nur wenn nötig)
|
||
# Hinweis: Das Limit wird im 'train_technician_model' Modus aktuell nicht direkt verwendet,
|
||
# da alle verfügbaren Daten mit Technikerzahl genutzt werden sollten.
|
||
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
|
||
elif mode in ["combined", "wiki", "website", "branch", "full_run"]: # Limit nur für diese Modi interaktiv abfragen
|
||
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'
|
||
if mode in ["combined", "wiki", "website", "branch", "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 ---
|
||
# Lade Branchenschema (wird für fast alle Modi benötigt)
|
||
load_target_schema()
|
||
|
||
# Initialisiere Google Sheet Handler
|
||
try:
|
||
sheet_handler = GoogleSheetHandler()
|
||
except Exception as e:
|
||
debug_print(f"FATAL: Konnte Google Sheet Handler nicht initialisieren: {e}")
|
||
print(f"FEHLER: Verbindung zu Google Sheets fehlgeschlagen. Siehe Logdatei: {LOG_FILE}")
|
||
return # Abbruch
|
||
|
||
# Initialisiere DataProcessor (wird für einige Modi gebraucht)
|
||
data_processor = DataProcessor(sheet_handler)
|
||
|
||
# --- Modusausführung ---
|
||
start_time = time.time()
|
||
debug_print(f"Starte Verarbeitung um {datetime.now().strftime('%H:%M:%S')}...")
|
||
|
||
try:
|
||
if mode in ["wiki", "website", "branch", "combined"]:
|
||
if row_limit == 0:
|
||
debug_print("Zeilenlimit ist 0. Überspringe Dispatcher-Aufruf.")
|
||
else:
|
||
run_dispatcher(mode, sheet_handler, row_limit)
|
||
elif mode == "reeval":
|
||
data_processor.process_reevaluation_rows()
|
||
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)
|
||
elif mode == "full_run":
|
||
if row_limit == 0:
|
||
debug_print("Zeilenlimit ist 0. Überspringe sequenzielle Verarbeitung.")
|
||
else:
|
||
start_index = sheet_handler.get_start_row_index()
|
||
if start_index < len(sheet_handler.get_data()):
|
||
num_available = len(sheet_handler.get_data()) - start_index
|
||
if row_limit is not None and row_limit >= 0:
|
||
num_to_process = min(row_limit, num_available)
|
||
else:
|
||
num_to_process = num_available
|
||
|
||
if num_to_process > 0:
|
||
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 (Limit 0 oder Startindex am Ende).")
|
||
else:
|
||
debug_print(f"Startindex {start_index} liegt hinter der letzten Datenzeile. Keine Verarbeitung für 'full_run'.")
|
||
elif mode == "alignment":
|
||
print("\nACHTUNG: Dieser Modus überschreibt die Zellen A1:AS5 im Haupt-Sheet!")
|
||
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)
|
||
debug_print("Alignment Demo Aufruf beendet.")
|
||
else:
|
||
print("Vorgang abgebrochen.")
|
||
debug_print("Alignment Demo vom Benutzer abgebrochen.")
|
||
except OSError as e:
|
||
if e.errno == 9:
|
||
print("Fehler: Interaktive Bestätigung nicht möglich (läuft im Hintergrund?). Vorgang abgebrochen.")
|
||
debug_print("Alignment Demo abgebrochen (keine interaktive Bestätigung möglich).")
|
||
else:
|
||
print(f"Unerwarteter OS-Fehler bei Bestätigung: {e}. Vorgang abgebrochen.")
|
||
debug_print(f"Alignment Demo abgebrochen (OS-Fehler: {e}).")
|
||
except EOFError:
|
||
print("Fehler: Interaktive Bestätigung nicht möglich (EOF). Vorgang abgebrochen.")
|
||
debug_print("Alignment Demo abgebrochen (EOF).")
|
||
|
||
# HIER NEU: Block für den Modelltrainings-Modus
|
||
elif mode == "train_technician_model":
|
||
debug_print("Starte Modus: train_technician_model")
|
||
|
||
# 1. Daten vorbereiten
|
||
prepared_df = prepare_data_for_modeling(sheet_handler)
|
||
|
||
if prepared_df is not None and not prepared_df.empty:
|
||
# 2. Train/Test Split
|
||
debug_print("Aufteilen der Daten in Trainings- und Testsets...")
|
||
try:
|
||
X = prepared_df.drop(columns=['Techniker_Bucket'])
|
||
y = prepared_df['Techniker_Bucket']
|
||
# Stratify=y ist wichtig, um die Verteilung der Buckets in Train/Test ähnlich zu halten
|
||
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 # Zurücksetzen
|
||
|
||
if X_train is not None:
|
||
# 3. Imputation fehlender Werte (Umsatz/Mitarbeiter)
|
||
debug_print("Imputation fehlender Werte (Median)...")
|
||
numeric_features = ['Finaler_Umsatz', 'Finaler_Mitarbeiter']
|
||
try:
|
||
imputer = SimpleImputer(strategy='median')
|
||
# WICHTIG: Imputer NUR auf Trainingsdaten fitten!
|
||
imputer.fit(X_train[numeric_features])
|
||
# Transformiere Trainings- UND Testdaten
|
||
X_train_imputed_np = imputer.transform(X_train[numeric_features])
|
||
X_test_imputed_np = imputer.transform(X_test[numeric_features])
|
||
|
||
# Konvertiere zurück zu DataFrames und setze Spaltennamen und Index zurück
|
||
X_train[numeric_features] = X_train_imputed_np
|
||
X_test[numeric_features] = X_test_imputed_np
|
||
|
||
# Speichere den Imputer für spätere Verwendung (z.B. bei neuen Daten)
|
||
imputer_filename = "median_imputer.pkl"
|
||
with open(imputer_filename, 'wb') as f_imputer:
|
||
pickle.dump(imputer, f_imputer)
|
||
debug_print(f"Median-Imputer trainiert und gespeichert als '{imputer_filename}'.")
|
||
imputation_successful = True
|
||
except Exception as e:
|
||
debug_print(f"Fehler bei der Imputation: {e}")
|
||
imputation_successful = False
|
||
|
||
if imputation_successful:
|
||
# 4. Modelltraining & Hyperparameter-Tuning (Beispielhaft)
|
||
debug_print("Starte Decision Tree Training mit GridSearchCV...")
|
||
# Definiere den Parameter-Grid für die Suche
|
||
param_grid = {
|
||
'criterion': ['gini', 'entropy'],
|
||
'max_depth': [5, 8, 10, 12, None], # None = unbegrenzt (vorsicht)
|
||
'min_samples_split': [10, 20, 40],
|
||
'min_samples_leaf': [5, 10, 20],
|
||
'ccp_alpha': [0.0, 0.001, 0.005, 0.01] # Für Pruning
|
||
}
|
||
|
||
# Erstelle Decision Tree Classifier
|
||
dtree = DecisionTreeClassifier(random_state=42)
|
||
|
||
# Erstelle GridSearchCV Objekt (cv=5 für 5-fache Kreuzvalidierung)
|
||
# scoring='accuracy' oder 'f1_weighted' etc.
|
||
grid_search = GridSearchCV(estimator=dtree, param_grid=param_grid, cv=5, scoring='accuracy', n_jobs=-1, verbose=1) # n_jobs=-1 nutzt alle CPU Kerne
|
||
|
||
try:
|
||
grid_search.fit(X_train, y_train)
|
||
|
||
# Bestes Modell und Parameter ausgeben
|
||
best_params = grid_search.best_params_
|
||
best_score = grid_search.best_score_
|
||
best_estimator = grid_search.best_estimator_
|
||
debug_print(f"GridSearchCV abgeschlossen.")
|
||
debug_print(f"Beste Parameter gefunden: {best_params}")
|
||
debug_print(f"Bester Kreuzvalidierungs-Score (Accuracy): {best_score:.4f}")
|
||
|
||
# Speichere das beste Modell
|
||
model_filename = "technician_decision_tree_model.pkl"
|
||
with open(model_filename, 'wb') as f_model:
|
||
pickle.dump(best_estimator, f_model)
|
||
debug_print(f"Bestes Modell gespeichert als '{model_filename}'.")
|
||
|
||
# 5. Evaluation auf dem Test-Set
|
||
debug_print("Evaluiere bestes Modell auf dem 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--- Evaluationsergebnisse (Test-Set) ---")
|
||
debug_print(f"Genauigkeit: {test_accuracy:.4f}")
|
||
debug_print(f"Klassifikationsbericht:\n{report}")
|
||
debug_print(f"Konfusionsmatrix:\n{conf_matrix}")
|
||
print(f"\nModell-Evaluation abgeschlossen. Genauigkeit auf Test-Set: {test_accuracy:.4f}") # Auch für User sichtbar
|
||
print(f"Detaillierter Bericht im Logfile: {LOG_FILE}")
|
||
|
||
# 6. Muster extrahieren
|
||
debug_print("\nExtrahiere Regeln aus dem besten Baum (Textformat)...")
|
||
try:
|
||
feature_names = list(X_train.columns) # Namen der Features
|
||
# Stelle sicher, dass die Label-Namen (Buckets) verfügbar sind
|
||
class_names = best_estimator.classes_ # Die Bucket-Labels
|
||
|
||
rules_text = export_text(best_estimator, feature_names=feature_names, show_weights=True) # show_weights zeigt Verteilung in Blättern
|
||
debug_print(f"--- Baumregeln (Text) ---:\n{rules_text}")
|
||
|
||
# Speichere Regeln als Textdatei
|
||
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 als Text gespeichert in '{patterns_filename_txt}'.")
|
||
|
||
# TODO (Optional): Regeln als JSON extrahieren (komplexer)
|
||
# Hier müsste man den Baum traversieren (tree_ Attribut)
|
||
|
||
except Exception as e_export:
|
||
debug_print(f"Fehler beim Extrahieren/Speichern der Baumregeln: {e_export}")
|
||
|
||
except Exception as e_train:
|
||
debug_print(f"FEHLER während des Modelltrainings/-tunings: {e_train}")
|
||
import traceback
|
||
debug_print(traceback.format_exc())
|
||
else:
|
||
debug_print("Datenvorbereitung fehlgeschlagen oder keine Daten vorhanden. Modus 'train_technician_model' abgebrochen.")
|
||
|
||
else:
|
||
debug_print(f"Unbekannter Modus '{mode}' - keine Aktion ausgeführt.")
|
||
|
||
except Exception as e:
|
||
debug_print(f"FATAL: Unerwarteter Fehler auf oberster Ebene während der Modusausführung: {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() |