# --- START OF FILE helpers.py (Part 1/10) --- #!/usr/bin/env python3 """ helpers.py Sammlung von globalen, wiederverwendbaren Hilfsfunktionen für das Projekt "Automatisierte Unternehmensbewertung". Enthält Decorators, Text-Normalisierung, API-Wrapper und andere Dienstprogramme. """ ALLOWED_TARGET_BRANCHES = [] # ============================================================================== # 1. IMPORTS # ============================================================================== # Standardbibliotheken import os import time import re import csv import json import random import logging import traceback import unicodedata from datetime import datetime from urllib.parse import urlparse, unquote from difflib import SequenceMatcher # Externe Bibliotheken import gspread import wikipedia import requests from bs4 import BeautifulSoup import pandas as pd import openai from openai.error import AuthenticationError, OpenAIError, RateLimitError, APIError, Timeout, InvalidRequestError, ServiceUnavailableError from config import (Config, BRANCH_MAPPING_FILE, URL_CHECK_MARKER, USER_AGENTS, LOG_DIR) # Optionale Bibliotheken try: import tiktoken except ImportError: tiktoken = None logging.warning("tiktoken nicht gefunden. Token-Zaehlung wird geschaetzt.") try: import gender_guesser.detector as gender # Initialisieren Sie den Detector einmal global gender_detector = gender.Detector() logging.info("gender_guesser.Detector initialisiert.") except ImportError: gender = None gender_detector = None logging.warning("gender_guesser Bibliothek nicht gefunden. Geschlechtserkennung deaktiviert.") except Exception as e: gender = None gender_detector = None logging.warning(f"Fehler bei Initialisierung von gender_guesser: {e}. Geschlechtserkennung deaktiviert.") # Import der Config-Klasse und Konstanten from config import Config, BRANCH_MAPPING_FILE, URL_CHECK_MARKER, USER_AGENTS from config import Config, COLUMN_MAP, COLUMN_ORDER def get_col_idx(key): """ Ermittelt sicher den 0-basierten Spalten-Index für einen gegebenen Spaltennamen (key) mithilfe der zentralen COLUMN_ORDER-Liste aus der Config. """ try: return COLUMN_ORDER.index(key) except (ValueError, NameError): # Fängt den Fehler ab, falls COLUMN_ORDER nicht importiert wurde oder der Key nicht existiert. # Ein Logging hier wäre gut, aber um Abhängigkeiten zu vermeiden, geben wir nur None zurück. return None # ============================================================================== # 2. RETRY DECORATOR # ============================================================================== decorator_logger = logging.getLogger(__name__ + ".Retry") def retry_on_failure(func): """ Decorator, der eine Funktion bei bestimmten Fehlern mehrmals wiederholt. Implementiert exponentiellen Backoff mit Jitter. """ def wrapper(*args, **kwargs): func_name = func.__name__ self_arg = args[0] if args and hasattr(args[0], func_name) and isinstance(args[0], object) else None effective_func_name = f"{self_arg.__class__.__name__}.{func_name}" if self_arg else func_name max_retries_config = getattr(Config, 'MAX_RETRIES', 3) base_delay = getattr(Config, 'RETRY_DELAY', 5) if max_retries_config <= 0: try: return func(*args, **kwargs) except Exception as e: decorator_logger.error(f"FEHLER bei '{effective_func_name}' (keine Retries konfiguriert). {type(e).__name__} - {str(e)[:150]}...") if not isinstance(e, (requests.exceptions.RequestException, gspread.exceptions.APIError, OpenAIError, wikipedia.exceptions.WikipediaException)): decorator_logger.exception("Details zum Fehler:") raise e for attempt in range(max_retries_config): try: if attempt > 0: decorator_logger.warning(f"Wiederhole Versuch {attempt + 1}/{max_retries_config} fuer '{effective_func_name}'...") return func(*args, **kwargs) except (gspread.exceptions.SpreadsheetNotFound, AuthenticationError, ValueError) as e: decorator_logger.critical(f"❌ ENDGUELTIGER FEHLER bei '{effective_func_name}': Permanentes Problem erkannt. {type(e).__name__} - {str(e)[:150]}...") decorator_logger.exception("Details:") raise e except requests.exceptions.HTTPError as e: if hasattr(e, 'response') and e.response is not None: status_code = e.response.status_code non_retryable_status_codes = [404, 400, 401, 403] if status_code in non_retryable_status_codes: decorator_logger.critical(f"❌ ENDGUELTIGER FEHLER bei '{effective_func_name}': HTTP Fehler {status_code} erhalten ({e.response.reason}). Nicht wiederholbar. {str(e)[:100]}...") decorator_logger.exception("Details:") raise e except (requests.exceptions.RequestException, gspread.exceptions.APIError, OpenAIError, wikipedia.exceptions.WikipediaException) as e: error_msg = str(e) error_type = type(e).__name__ if attempt < max_retries_config - 1: wait_time = base_delay * (2 ** attempt) + random.uniform(0, 1) if isinstance(e, RateLimitError): decorator_logger.warning(f"🚦 RATE LIMIT ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...") elif isinstance(e, Timeout) and isinstance(e, OpenAIError): decorator_logger.warning(f"⏰ OPENAI TIMEOUT ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...") elif isinstance(e, gspread.exceptions.APIError) and hasattr(e, 'response') and e.response is not None and e.response.status_code == 429: decorator_logger.warning(f"🚦 GSPREAD RATE LIMIT ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...") elif isinstance(e, requests.exceptions.Timeout): decorator_logger.warning(f"⏰ REQUESTS TIMEOUT ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...") elif isinstance(e, requests.exceptions.RequestException): decorator_logger.warning(f"🌐 NETZWERKFEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...") elif isinstance(e, OpenAIError): decorator_logger.warning(f"🤖 OPENAI FEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...") elif isinstance(e, wikipedia.exceptions.WikipediaException): decorator_logger.warning(f"📚 WIKIPEDIA FEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...") else: decorator_logger.warning(f"♻️ WIEDERHOLBARER FEHLER ({error_type}) bei '{effective_func_name}' (Versuch {attempt+1}/{max_retries_config}). {error_msg[:150]}... Warte {wait_time:.2f}s...") time.sleep(wait_time) else: decorator_logger.error(f"❌ ENDGUELTIGER FEHLER bei '{effective_func_name}' nach {max_retries_config} Versuchen.") raise e except Exception as e: decorator_logger.critical(f"💥 UNERWARTETER FEHLER ({type(e).__name__}) bei '{effective_func_name}'. KEIN RETRY VERSUCHT.") decorator_logger.exception("Details zum unerwarteten Fehler:") raise e raise RuntimeError(f"Retry decorator logic error: Loop completed unexpectedly for {effective_func_name}. This should not happen.") return wrapper # ============================================================================== # 3. LOGGING & TOKEN COUNT HELPERS # ============================================================================== def token_count(text, model=None): """Zaehlt Tokens via tiktoken oder schaetzt ueber Leerzeichen.""" logger = logging.getLogger(__name__) if not text or not isinstance(text, str): return 0 current_model = model if model else getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo') if tiktoken: try: if not hasattr(token_count, 'enc_cache'): token_count.enc_cache = {} if current_model not in token_count.enc_cache: token_count.enc_cache[current_model] = tiktoken.encoding_for_model(current_model) enc = token_count.enc_cache[current_model] return len(enc.encode(text)) except Exception as e: logger.debug(f"Fehler beim Token-Counting mit tiktoken fuer Modell '{current_model}': {e} - Fallback zur Schaetzung.") return len(str(text).split()) else: return len(str(text).split()) def create_log_filename(mode): """Erstellt einen zeitgestempelten Logdateinamen im LOG_DIR.""" logger = logging.getLogger(__name__) log_dir_path = LOG_DIR if not os.path.exists(log_dir_path): try: os.makedirs(log_dir_path, exist_ok=True) logger.info(f"Log-Verzeichnis '{log_dir_path}' erstellt.") except Exception as e: logger.error(f"FEHLER: Konnte Log-Verzeichnis '{log_dir_path}' nicht erstellen: {e}") log_dir_path = "." logger.warning(f"Versuche, Logdatei im aktuellen Verzeichnis '{log_dir_path}' zu erstellen.") try: now = datetime.now().strftime("%Y-%m-%d_%H-%M") # YYYY-MM-DD für bessere Sortierung ver_short = getattr(Config, 'VERSION', 'unknown').replace(".", "") filename = f"{now}_{ver_short}_Modus-{mode}.txt" # .log als Dateiendung return os.path.join(log_dir_path, filename) except Exception as e_fallback: logger.error(f"FEHLER: Konnte Logdateinamen auch im Fallback-Verzeichnis '{log_dir_path}' nicht erstellen: {e_fallback}") return None # ============================================================================== # 4. TEXT, STRING & URL UTILITIES # ============================================================================== def simple_normalize_url(url): """Normalisiert URL zu domain.tld oder k.A. (ohne www, ohne Pfad).""" logger = logging.getLogger(__name__) if not url or not isinstance(url, str): return "k.A." url = url.strip() if not url or url.lower() == 'k.a.': return "k.A." if not url.lower().startswith(("http://", "https://")): url = "https://" + url try: parsed = urlparse(url) domain_part = parsed.netloc if not domain_part: logger.debug(f"URL '{url[:100]}...' konnte nicht sinnvoll geparst werden (leerer netloc).") return "k.A." domain_part = domain_part.split(":", 1)[0] if '@' in domain_part: domain_part = domain_part.split('@', 1)[1] try: domain_part = domain_part.encode('ascii').decode('idna') except UnicodeDecodeError: pass domain_part = domain_part.lower() if domain_part.startswith("www."): domain_part = domain_part[4:] if domain_part and '.' in domain_part: parts = domain_part.split('.') if len(parts) > 1 and parts[-1].isalpha() and len(parts[-1]) >= 2: return domain_part else: logger.debug(f"URL '{url[:100]}...' normalisiert zu '{domain_part}', aber TLD-Pruefung schlug fehl.") return "k.A." else: logger.debug(f"URL '{url[:100]}...' normalisiert zu '{domain_part}', enthaelt keinen Punkt oder ist leer.") return "k.A." except Exception as e: logger.error(f"Fehler bei URL-Normalisierung fuer '{url[:100]}...': {e}") return "k.A. (Fehler Normalisierung)" def normalize_string(s): """Normalisiert Umlaute und Sonderzeichen nach einer definierten Liste.""" logger = logging.getLogger(__name__) 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' } try: s = unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode('ascii') except Exception as e: logger.debug(f"Fehler bei unicodedata Normalisierung fuer '{str(s)[:50]}...': {e}") pass for src, target in replacements.items(): s = s.replace(src, target) return s def clean_text(text): """ Bereinigt Text (Unicode, Referenzen, Whitespace, etc.) von Wikipedia, Websites etc. Entfernt gaengige unerwuenschte Muster wie [1], [Bearbeiten]. """ logger = logging.getLogger(__name__) if text is None: return "k.A." try: text = str(text) if not text.strip(): return "k.A." text = unicodedata.normalize("NFC", text) text = re.sub(r'\[\d+\]', '', text) text = re.sub(r'\[\s*Bearbeiten\s*\|\s*Quelltext bearbeiten\s*\]', '', text, flags=re.IGNORECASE) text = re.sub(r'\[koordinaten\]', '', text, flags=re.IGNORECASE) text = re.sub(r'\s+', ' ', text).strip() return text if text else "k.A." except Exception as e: logger.error(f"Fehler bei clean_text fuer Input '{str(text)[:50]}...': {e}") return "k.A. (Fehler Bereinigung)" def normalize_company_name(name): """ Entfernt gaengige Rechtsformzusaetze etc. fuer Vergleiche. Nutzt clean_text und normalize_string. """ if not name: return "" name = clean_text(name) name = normalize_string(name) forms = [ r'gmbh', r'ges\.?\s*m\.?\s*b\.?\s*h\.?', r'gesellschaft mit beschraenkter haftung', r'ug', r'u\.g\.', r'unternehmergesellschaft', r'haftungsbeschraenkt', r'ag', r'a\.g\.', r'aktiengesellschaft', r'ohg', r'o\.h\.g\.', r'offene handelsgesellschaft', r'kg', r'k\.g\.', r'kommanditgesellschaft', r'gmbh\s*&\s*co\.?\s*kg', r'ges\.?\s*m\.?\s*b\.?\s*h\.?\s*&\s*co\.?\s*k\.g\.?', r'ag\s*&\s*co\.?\s*kg', r'a\.g\.?\s*&\s*co\.?\s*k\.g\.?', r'e\.k\.?', r'e\.kfm\.?', r'e\.kfr\.?', r'eingetragene[rn]? kauffrau', r'eingetragene[rn]? kaufmann', r'ltd\.?', r'limited', r'ltd\s*&\s*co\.?\s*kg', r's\.?a\.?r\.?l\.?', r'sarl', r'sagl', r's\.?a\.?', r'societe anonyme', r'sociedad anonima', r's\.?p\.?a\.?', r'societa 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'gemeinnuetzige gmbh', r'gemeinnuetzige[rn]? 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 buergerlichem recht', r'kollektivgesellschaft', r'einzelfirma', r'gruppe', r'holding', r'international', r'systeme', r'technik', r'logistik', r'solutions', r'services', r'management', r'consulting', r'produktion', r'vertrieb', r'entwicklung', r'maschinenbau', r'anlagenbau', r'engineering', r'technologie' ] forms_escaped = [re.escape(form) for form in forms] pattern = r'\b(?:' + '|'.join(forms_escaped) + r')\b' normalized = re.sub(pattern, '', name, flags=re.IGNORECASE) normalized = re.sub(r'[.,;:]', '', normalized) normalized = re.sub(r'[\-–/]', ' ', normalized) normalized = re.sub(r'\s+', ' ', normalized).strip() return normalized.lower() def _get_col_letter(col_num): """Konvertiert eine 1-basierte Spaltennummer in einen Buchstaben (A, B, ..., Z, AA, AB).""" string = "" while col_num > 0: col_num, remainder = divmod(col_num - 1, 26) string = chr(65 + remainder) + string return string def fuzzy_similarity(str1, str2): """Berechnet Aehnlichkeit zwischen 0 und 1 (case-insensitive).""" if not str1 or not str2: return 0.0 return SequenceMatcher(None, str(str1).lower(), str(str2).lower()).ratio() def extract_numeric_value(raw_value, is_umsatz=False): """ Extrahiert und normalisiert einen numerischen Wert aus einem String. Gibt 'k.A.' bei Fehlern oder nicht gefundenen Werten zurück. Umsatz wird in Millionen zurückgegeben, Mitarbeiter als absolute Zahl. """ logger = logging.getLogger(__name__ + ".extract_numeric_value") if raw_value is None or pd.isna(raw_value): return "k.A." raw_value_str_original_for_debug = str(raw_value) text_to_parse = str(raw_value).strip() if not text_to_parse or text_to_parse.lower() in ['k.a.', 'n/a', '-']: return "k.A." text_to_parse = text_to_parse.replace("’", "'").replace("‘", "'") test_val_for_zero = text_to_parse.replace(',', '.').replace(' ', '') if test_val_for_zero in ['0', '0.0', '0.00', '0.000']: logger.debug(f"Input '{raw_value_str_original_for_debug}' direkt als '0' interpretiert.") return "0" try: text_processed = text_to_parse prefixes_to_remove = [ r'ca\.?\s*', r'circa\s*', r'rund\s*', r'etwa\s*', r'über\s*', r'unter\s*', r'mehr als\s*', r'weniger als\s*', r'bis zu\s*', r'about\s*', r'over\s*', r'approx\.?\s*', r'around\s*', r'up to\s*', r'~\s*', r'rd\.?\s*' ] for prefix_pattern in prefixes_to_remove: text_processed = re.sub(f'(?i)^{prefix_pattern}', '', text_processed).strip() currency_patterns = [ r'(?:US|USD)\$\s*', r'US\$\s*', r'EUR\s*€?\s*', r'€\s*', r'CHF\s*', r'GBP\s*£?\s*', r'£\s*', r'JPY\s*¥?\s*', r'¥\s*', r'₹\s*', r'[Cc][Hh][Ff]\s*' ] for curr_pattern in currency_patterns: text_processed = re.sub(curr_pattern, '', text_processed, flags=re.IGNORECASE).strip() text_processed = re.sub(r'\(.*?\)|\[.*?\]', '', text_processed).strip() text_processed = re.split(r'\s*(-|–|bis)\s*', text_processed, 1)[0].strip() if not text_processed: logger.debug(f"Text nach erweiterter Vorreinigung leer für '{raw_value_str_original_for_debug}'") return "k.A." text_cleaned_for_units = clean_text(text_processed).lower() num_match = re.search(r'([\d.,\'\s]+)', text_processed) num_str_candidate = "" unit_part_str = "" if num_match: num_str_candidate = num_match.group(1).strip() potential_unit_start_index = num_match.end() unit_part_str = text_processed[potential_unit_start_index:].strip() else: logger.debug(f"Kein Zahlen-Match in '{text_processed}' (Original: '{raw_value_str_original_for_debug}')") return "k.A." if not num_str_candidate: logger.debug(f"Zahlenkandidat war leer oder nur Whitespace für '{raw_value_str_original_for_debug}' nach match in '{text_processed}'") return "k.A." cleaned_num_str = num_str_candidate.replace("'", "").replace(" ", "") if not cleaned_num_str: logger.debug(f"Zahlenkandidat '{num_str_candidate}' wurde zu leerem String nach Entfernung von ' und Leerraum.") return "k.A." has_dot = '.' in cleaned_num_str has_comma = ',' in cleaned_num_str if has_dot and has_comma: last_dot_pos = cleaned_num_str.rfind('.') last_comma_pos = cleaned_num_str.rfind(',') if last_dot_pos > last_comma_pos: cleaned_num_str = cleaned_num_str.replace(',', '') else: cleaned_num_str = cleaned_num_str.replace('.', '') cleaned_num_str = cleaned_num_str.replace(',', '.') elif has_comma: if is_umsatz: if re.search(r',\d{1,2}$', cleaned_num_str): parts = cleaned_num_str.rsplit(',', 1) integer_part = parts[0].replace(',', '') cleaned_num_str = f"{integer_part}.{parts[1]}" else: cleaned_num_str = cleaned_num_str.replace(',', '') else: cleaned_num_str = cleaned_num_str.replace(',', '') elif has_dot: if re.search(r'\.\d{1,2}$', cleaned_num_str): parts = cleaned_num_str.rsplit('.', 1) integer_part = parts[0].replace('.', '') cleaned_num_str = f"{integer_part}.{parts[1]}" else: cleaned_num_str = cleaned_num_str.replace('.', '') if not re.fullmatch(r'-?\d+(\.\d+)?', cleaned_num_str): logger.debug(f"Kein gültiger numerischer String nach Trennzeichenbehandlung: '{cleaned_num_str}' (Num-Kandidat: '{num_str_candidate}', Original: '{raw_value_str_original_for_debug}')") return "k.A." num_as_float = float(cleaned_num_str) scaled_num = num_as_float string_for_unit_search = unit_part_str.lower() if unit_part_str else text_cleaned_for_units logger.debug(f"String für Einheitensuche: '{string_for_unit_search}' (num_as_float: {num_as_float})") if is_umsatz: multiplikator = 1.0 einheit_gefunden = False if re.search(r'\b(mrd\.?|milliarden|billion|mia\.?)\b', string_for_unit_search): multiplikator = 1000.0 einheit_gefunden = True elif re.search(r'\bcrore\b', string_for_unit_search): multiplikator = 10.0 einheit_gefunden = True elif re.search(r'\b(mio\.?|mill\.?|millionen|mn)\b', string_for_unit_search): multiplikator = 1.0 einheit_gefunden = True elif re.search(r'\b(tsd\.?|tausend|k\b(?!\w))\b', string_for_unit_search): multiplikator = 0.001 einheit_gefunden = True logger.debug(f"Umsatz: num_as_float={num_as_float}, gefundene Einheit? {einheit_gefunden}, Multiplikator={multiplikator}") scaled_num = num_as_float * multiplikator else: multiplikator = 1.0 if re.search(r'\b(mrd\.?|milliarden|billion|mia\.?)\b', string_for_unit_search): multiplikator = 1000000000.0 elif re.search(r'\b(mio\.?|mill\.?|millionen|mn)\b', string_for_unit_search): multiplikator = 1000000.0 elif re.search(r'\b(tsd\.?|tausend|k\b(?!\w))\b', string_for_unit_search): multiplikator = 1000.0 logger.debug(f"Mitarbeiter: num_as_float={num_as_float}, Multiplikator={multiplikator}") scaled_num = num_as_float * multiplikator if pd.isna(scaled_num): return "k.A." if scaled_num >= 0: return str(int(round(scaled_num))) else: logger.debug(f"Negative Zahl nach Skalierung: {scaled_num} für Input '{raw_value_str_original_for_debug}'") return "k.A." except ValueError as e: logger.debug(f"ValueError bei Konvertierung zu float: '{e}' (cleaned_num_str: '{cleaned_num_str if 'cleaned_num_str' in locals() else 'N/A'}', Original: '{raw_value_str_original_for_debug[:30]}...')") return "k.A." except Exception as e_general: logger.error(f"Unerwarteter Fehler in extract_numeric_value für '{raw_value_str_original_for_debug[:50]}...': {e_general}") logger.debug(traceback.format_exc()) return "k.A." def get_numeric_filter_value(value_str, is_umsatz=False): """ Extrahiert und normalisiert Zahlenwerte aus Strings für Vergleichslogik. Gibt 0.0 für Umsatz oder 0 für Mitarbeiter zurück, wenn kein Wert gefunden wird. """ logger = logging.getLogger(__name__ + ".get_numeric_filter_value") if value_str is None or pd.isna(value_str) or str(value_str).strip() == '': return 0.0 if is_umsatz else 0 raw_value_str_original = str(value_str).strip() if raw_value_str_original.lower() in ['k.a.', 'n/a', '-', '0', '0.0', '0,0', '0.00', '0,000', '0.000']: return 0.0 if is_umsatz else 0 try: processed_value = clean_text(raw_value_str_original) if processed_value.lower() in ['k.a.', 'n/a', '-']: return 0.0 if is_umsatz else 0 processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|ueber|unter|mehr als|weniger als|bis zu)\s+', '', processed_value) processed_value = re.sub(r'[€$£¥]', '', processed_value).strip() processed_value = re.split(r'\s*(-|–|bis)\s*', processed_value, 1)[0].strip() num_extraction_str = re.sub(r'\(.*?\)', '', processed_value).strip() num_extraction_str = num_extraction_str.replace("'", "") num_extraction_str = re.sub(r'(?<=\d)\s+(?=\d)', '', num_extraction_str) if not num_extraction_str: return 0.0 if is_umsatz else 0 has_dot = '.' in num_extraction_str has_comma = ',' in num_extraction_str if has_dot and has_comma: if num_extraction_str.rfind('.') > num_extraction_str.rfind(','): num_extraction_str = num_extraction_str.replace(',', '') else: num_extraction_str = num_extraction_str.replace('.', '') num_extraction_str = num_extraction_str.replace(',', '.') elif has_comma: if num_extraction_str.count(',') == 1 and re.search(r',\d{1,2}$', num_extraction_str) and not re.search(r',\d{3}(,|\s|\Z)', num_extraction_str): num_extraction_str = num_extraction_str.replace(',', '.') else: num_extraction_str = num_extraction_str.replace(',', '') elif has_dot: if num_extraction_str.count('.') == 1 and re.search(r'\.\d{1,2}$', num_extraction_str) and not re.search(r'\.\d{3}(?!\d)', num_extraction_str): pass else: num_extraction_str = num_extraction_str.replace('.', '') if not re.fullmatch(r'-?\d+(\.\d+)?', num_extraction_str): logger.debug(f"Kein gültiger numerischer String nach Trennzeichenbehandlung: '{num_extraction_str}' (Original: '{raw_value_str_original}')") return 0.0 if is_umsatz else 0 num_as_float = float(num_extraction_str) scaled_num = num_as_float original_lower = raw_value_str_original.lower() if is_umsatz: if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower): scaled_num = num_as_float * 1000.0 elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): scaled_num = num_as_float / 1000.0 else: if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower): scaled_num = num_as_float * 1000000000.0 elif re.search(r'\bmio\s*\b|\bmillionen\s*\b|\bmill[.]?\s*\b', original_lower): scaled_num = num_as_float * 1000000.0 elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): scaled_num = num_as_float * 1000.0 return scaled_num if scaled_num > 0 else (0.0 if is_umsatz else 0) except ValueError as e: logger.debug(f"ValueError '{e}' bei Konvertierung (get_numeric_filter_value) von '{num_extraction_str if 'num_extraction_str' in locals() and isinstance(num_extraction_str, str) else raw_value_str_original[:30]}...'") return 0.0 if is_umsatz else 0 except Exception as e_general: logger.error(f"Unerwarteter Fehler in get_numeric_filter_value für '{raw_value_str_original[:50]}...': {e_general}") logger.debug(traceback.format_exc()) return 0.0 if is_umsatz else 0 @retry_on_failure def _call_genderize_api(name, api_key): """ Interne Hilfsfunktion, um die Genderize API mit Retry-Logik aufzurufen. Wird von get_gender aufgerufen. """ params = {"name": name, "apikey": api_key, "country_id": "DE"} response = requests.get("https://api.genderize.io", params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) response.raise_for_status() data = response.json() return data def get_gender(firstname): """Ermittelt Geschlecht via gender-guesser und Fallback Genderize API.""" logger = logging.getLogger(__name__) if not firstname or not isinstance(firstname, str): return "unknown" firstname_clean = str(firstname).strip().split(" ")[0] if not firstname_clean: return "unknown" # 1. Versuch: gender-guesser (lokal) result_gg = "unknown" if gender_detector: try: result_gg = gender_detector.get_gender(firstname_clean) except Exception as e_gg: logger.warning(f"Fehler bei gender-guesser fuer '{firstname_clean}': {e_gg}") result_gg = "unknown" # 2. Fallback: Genderize API (nur wenn gender-guesser unsicher ist) if result_gg in ["andy", "unknown", "mostly_male", "mostly_female"]: genderize_key = Config.API_KEYS.get('genderize') if not genderize_key: return result_gg if result_gg.startswith("mostly_") else "unknown" try: genderize_data = _call_genderize_api(firstname_clean, genderize_key) api_gender = genderize_data.get("gender") probability = genderize_data.get("probability", 0) count = genderize_data.get("count", 0) if api_gender and probability is not None and probability > 0.7 and count is not None and count > 0: logger.debug(f" -> Uebernehme Genderize Ergebnis '{api_gender}' (Prob: {probability}, Count: {count}) fuer '{firstname_clean}'") return api_gender else: return result_gg if result_gg.startswith("mostly_") else "unknown" except Exception as e: logger.error(f"FEHLER bei der Genderize API-Anfrage fuer '{firstname_clean}' nach Retries: {e}") return result_gg if result_gg.startswith("mostly_") else "unknown" else: return result_gg def get_email_address(firstname, lastname, website): """ Generiert eine moegliche E-Mail-Adresse im Format vorname.nachname@domain.tld. Normalisiert Namen und extrahiert die Domain aus der Website-URL. """ logger = logging.getLogger(__name__) if not all([firstname, lastname, website]) or not all(isinstance(x, str) and x.strip() for x in [firstname, lastname, website]): logger.debug("get_email_address skipped: Fehlende oder ungueltige Eingabe (Name, Website).") return "" domain = simple_normalize_url(website) if domain == "k.A." or '.' not in domain: logger.debug(f"get_email_address skipped: Ungueltige Domain extrahiert aus '{website}'.") return "" normalized_first = normalize_string(firstname).lower() normalized_last = normalize_string(lastname).lower() normalized_first = re.sub(r'\s+', '-', normalized_first) normalized_last = re.sub(r'\s+', '-', normalized_last) normalized_first = re.sub(r'[^\w\-]+', '', normalized_first) normalized_last = re.sub(r'[^\w\-]+', '', normalized_last) normalized_first = normalized_first.strip('-') normalized_last = normalized_last.strip('-') if normalized_first and normalized_last and domain: email_address = f"{normalized_first}.{normalized_last}@{domain}" return email_address else: logger.debug("get_email_address skipped: Vorname oder Nachname leer nach Bereinigung.") return "" # ============================================================================== # 7. SCHEMA LOADING UTILITY # ============================================================================== def initialize_target_schema(): """ Initialisiert das Ziel-Branchenschema aus der Config und gibt es als Dictionary zurück. Setzt KEINE globalen Variablen mehr. """ logger = logging.getLogger(__name__) from config import Config logger.info("Lade Ziel-Schema aus Config.BRANCH_GROUP_MAPPING...") if not hasattr(Config, 'BRANCH_GROUP_MAPPING') or not isinstance(Config.BRANCH_GROUP_MAPPING, dict): logger.critical("FEHLER: Config.BRANCH_GROUP_MAPPING ist nicht vorhanden oder kein Dictionary.") return None allowed_branches = sorted(list(Config.BRANCH_GROUP_MAPPING.keys()), key=str.lower) if not allowed_branches: logger.error("Keine Branchen im BRANCH_GROUP_MAPPING in der Config gefunden.") return None # Baue den Prompt-String zusammen schema_lines = ["Ziel-Branchenschema: Folgende Branchenbereiche sind gueltig (Kurzformen):"] schema_lines.extend(f"- {branch}" for branch in allowed_branches) schema_lines.append("\nBitte ordne das Unternehmen ausschliesslich in einen dieser Bereiche ein.") schema_lines.append("Antworte ausschliesslich im folgenden Format (keine Einleitung, kein Schlusssatz):") schema_lines.append("Branche: ") schema_lines.append("Konfidenz: ") schema_lines.append("Begruendung: ") schema_string = "\n".join(schema_lines) logger.info(f"Ziel-Schema initialisiert: {len(allowed_branches)} eindeutige Zielbranchen gefunden.") # Gib ein sauberes Dictionary zurück return { "allowed_branches": allowed_branches, "schema_prompt_string": schema_string } # ============================================================================== # 8. OPENAI API WRAPPERS (CHAT & SUMMARY) # ============================================================================== @retry_on_failure def call_openai_chat(prompt, temperature=0.3, model=None, response_format_json=False): """ Zentrale Funktion fuer OpenAI Chat API Aufrufe. Wird von anderen globalen Helfern oder DataProcessor Methoden aufgerufen. NEU: Unterstützt explizite Anforderung des JSON-Formats. Args: prompt (str): Der Prompt-Text an die API. temperature (float, optional): Die Temperatur fuer die Textgenerierung. Defaults to 0.3. model (str, optional): Das zu verwendende OpenAI Modell. Defaults to Config.TOKEN_MODEL. response_format_json (bool, optional): Wenn True, wird die API um eine JSON-Antwort gebeten. Defaults to False. Returns: str: Der bereinigte Antwortstring von der API. Wirft Exception bei API-Fehlern nach Retries. """ logger = logging.getLogger(__name__) if not Config.API_KEYS.get('openai'): logger.error("Fehler: OpenAI API Key nicht konfiguriert.") raise openai.error.AuthenticationError("OpenAI API Key nicht konfiguriert.") if not prompt or not isinstance(prompt, str) or not prompt.strip(): logger.error("Fehler: Leerer Prompt fuer OpenAI.") raise ValueError("Leerer Prompt fuer OpenAI.") current_model = model if model else getattr(Config, 'TOKEN_MODEL', 'gpt-4o') try: # Baue die Anfrage-Payload dynamisch auf request_payload = { "model": current_model, "messages": [{"role": "user", "content": prompt}], "temperature": temperature } # Füge den response_format Parameter hinzu, wenn er benötigt wird if response_format_json: logger.debug("Fordere JSON-Output von der OpenAI API an.") request_payload["response_format"] = {"type": "json_object"} # Hinweis: GPT-4o, GPT-4-Turbo und GPT-3.5-Turbo-Modelle unterstützen diesen Modus. # Führe den API-Call mit dem dynamischen Payload aus response = openai.ChatCompletion.create(**request_payload) if not response or not hasattr(response, 'choices') or not response.choices: logger.error(f"OpenAI Call erfolgreich, aber keine Choices in der Antwort erhalten. Response: {str(response)[:200]}...") raise openai.error.APIError("Keine Choices in OpenAI Antwort erhalten.") result = response.choices[0].message.content.strip() if hasattr(response.choices[0], 'message') and hasattr(response.choices[0].message, 'content') else "" if not result: logger.warning(f"OpenAI Call erfolgreich, erhielt aber leeren Inhalt in der Antwort. Prompt Anfang: {prompt[:100]}...") return "" return result except Exception as e: # Wird vom @retry_on_failure Decorator gefangen und behandelt. # Wir heben die Exception erneut auf, damit der Decorator sie sehen kann. raise e @retry_on_failure def summarize_website_content(raw_text, company_name): """ Analysiert den Rohtext einer Website, um sowohl das Kerngeschäft als auch die Relevanz für Field Service Management zu verstehen. Gibt eine strukturierte, zweiteilige Zusammenfassung zurück. """ logger = logging.getLogger(__name__) if not raw_text or len(raw_text.split()) < 50: logger.debug(f"Rohtext für {company_name} zu kurz ({len(raw_text.split())} Wörter), überspringe Zusammenfassung.") return "k.A. (Text zu kurz)" max_tokens_for_summary = 10000 truncated_text = raw_text[:max_tokens_for_summary] # --- SICHERE PROMPT-ERSTELLUNG AUS EINER LISTE --- # Diese Methode ist immun gegen Copy-Paste-Fehler mit unsichtbaren Zeichen. prompt_parts = [ "**Rolle:** Du bist ein erfahrener B2B-Marktanalyst.", f"**Aufgabe:** Analysiere den Website-Text des Unternehmens '{company_name}' und erstelle ein prägnantes Dossier.", "**Deine Analyse besteht aus ZWEI TEILEN:**", "**TEIL 1: Geschäftsmodell-Analyse**", "1. Identifiziere die Kernprodukte und/oder Dienstleistungen des Unternehmens.", "2. Fasse in 2-3 prägnanten Sätzen zusammen, was das Unternehmen macht und für welche Kunden.", "**TEIL 2: Field Service Management (FSM) Potenzial-Analyse**", "1. Scanne den Text gezielt nach Hinweisen auf einen technischen Außendienst (Schlüsselwörter: Service, Wartung, Reparatur, Montage, Installation, Techniker, Kundendienst vor Ort, Instandhaltung).", "2. Bewerte das Potenzial für einen relevanten Außendienst (mind. 50 Techniker) auf einer Skala.", "3. Extrahiere die 1-2 wichtigsten Sätze, die diese Service-Tätigkeiten belegen.", "**Antworte AUSSCHLIESSLICH im folgenden exakten Format:**", "GESCHÄFTSMODELL: ", "FSM-POTENZIAL: ", "SERVICE-BEWEISE: ", "**Hier ist der Website-Text:**", "---", truncated_text, "---" ] prompt = "\n".join(prompt_parts) try: summary = call_openai_chat(prompt, temperature=0.1, model="gpt-4o-mini") if not summary: return "k.A. (API-Antwort leer)" # Formatierung für bessere Lesbarkeit im Sheet summary = summary.replace("--- TEIL 1: GESCHAEFTSMODELL ---", "**GESCHÄFTSMODELL**") summary = summary.replace("Zusammenfassung:", "Zusammenfassung:") summary = summary.replace("--- TEIL 2: FSM-POTENZIAL ---", "\n\n**FSM-POTENZIAL**") summary = summary.replace("Service-Potenzial:", "Potenzial:") summary = summary.replace("Service-Beweise:", "Beweise:") return summary.strip() except Exception as e: logger.error(f"Fehler bei der Website-Zusammenfassung für {company_name}: {e}") return f"k.A. (Fehler bei Zusammenfassung: {e})" @retry_on_failure def summarize_wikipedia_article(full_text, company_name): """ Fasst den Rohtext eines Wikipedia-Artikels gezielt für den Geschäftskontext zusammen. """ logger = logging.getLogger(__name__) if not full_text or len(full_text) < 100: logger.debug("Wikipedia-Rohtext zu kurz für eine Zusammenfassung.") return "k.A. (Text zu kurz)" prompt = ( f"Du bist ein Wirtschaftsanalyst. Fasse den folgenden Wikipedia-Artikel über das Umfeld des Unternehmens '{company_name}' zusammen.\n" "Fokus: Geschäftsmodell, Hauptprodukte, bediente Märkte und die jüngere Unternehmensgeschichte.\n" "Erstelle eine prägnante Zusammenfassung von maximal 150 Wörtern, die die Kernaussagen auf den Punkt bringt.\n\n" "--- Artikeltext ---\n" f"{full_text[:8000]}\n" # Begrenzung auf ca. 8k Zeichen zur Kostenkontrolle "--- Zusammenfassung ---" ) try: summary = call_openai_chat(prompt, temperature=0.3, model="gpt-4o-mini") return summary if summary else "k.A. (Zusammenfassung fehlgeschlagen)" except Exception as e: logger.error(f"Fehler bei der Wikipedia-Zusammenfassung für {company_name}: {e}") return "FEHLER (API-Fehler bei Zusammenfassung)" # ============================================================================== # 9. OPENAI API WRAPPER (BRANCH EVALUATION) # ============================================================================== @retry_on_failure def evaluate_branche_chatgpt(company_name, website_summary, wiki_absatz): """ Führt eine kontextbasierte Brancheneinstufung (v2.0) durch. Das Unternehmensprofil wird mit dem vollständigen, definierten Branchenschema abgeglichen, um die bestmögliche Zuordnung zu finden. """ logger = logging.getLogger(__name__) # 1. Baue das Unternehmensprofil zusammen unternehmens_profil = [f"- Name: {company_name}"] if website_summary and 'k.a.' not in website_summary.lower(): unternehmens_profil.append(f"- Website-Zusammenfassung: {website_summary}") if wiki_absatz and 'k.a.' not in wiki_absatz.lower(): unternehmens_profil.append(f"- Wikipedia-Auszug: {wiki_absatz}") if len(unternehmens_profil) == 1: logger.warning(f"Zu wenige Informationen für Brancheneinstufung von '{company_name}'. Breche ab.") return { "branch": "FEHLER (Mangelnde Daten)", "confidence": "N/A", "justification": "Keine Website- oder Wikipedia-Daten zur Analyse vorhanden." } # 2. Baue das Ziel-Branchenschema als Textblock auf schema_text_parts = [] for i, (branch, details) in enumerate(Config.BRANCH_GROUP_MAPPING.items()): schema_text_parts.append(f"{i+1}. Branche: {branch}") if details.get("definition"): schema_text_parts.append(f" Definition: {details['definition']}") if details.get("beispiele"): schema_text_parts.append(f" Beispiele: {details['beispiele']}") ziel_branchenschema_text = "\n".join(schema_text_parts) # 3. Baue den Master-Prompt zusammen prompt_parts = [ "Du bist ein erfahrener Branchenanalyst bei einer führenden Unternehmensberatung. Deine Aufgabe ist es, ein Unternehmen präzise einer von 54 vordefinierten Branchenkategorien zuzuordnen. Nutze dafür ausschließlich die bereitgestellten Definitionen.", "\n--- UNTERNEHMENS-PROFIL ---", "\n".join(unternehmens_profil), "\n--- ZIEL-BRANCHENSCHEMA (Deine einzig gültige Wissensbasis) ---", ziel_branchenschema_text, "\n--- DEINE AUFGABE (DENKPROZESS) ---", "1. **Analysiere das Unternehmens-Profil:** Was ist die exakte, primäre Geschäftstätigkeit des Unternehmens?", "2. **Vergleiche diese Tätigkeit mit ALLEN Definitionen** im Ziel-Branchenschema. Achte genau auf die Abgrenzungen.", "3. **Identifiziere die Definition, die am besten passt.** Nutze die Beispielunternehmen zur Validierung deiner Wahl.", "4. **Triff eine Entscheidung** und gib das Ergebnis im folgenden JSON-Format aus:", ''' { "Branche": "", "Konfidenz": "", "Begruendung": "" } ''' ] prompt = "\n".join(prompt_parts) # 4. API-Aufruf try: response_str = call_openai_chat(prompt, temperature=0.0, model="gpt-4o", response_format_json=True) if not response_str: raise APIError("Keine Antwort von OpenAI erhalten.") response_json = json.loads(response_str) # Validierung des Ergebnisses final_branch = response_json.get("Branche") if final_branch not in Config.BRANCH_GROUP_MAPPING: logger.warning(f"KI hat eine ungültige Branche zurückgegeben: '{final_branch}'. Markiere als Fehler.") response_json["Branche"] = "FEHLER (Ungültige Antwort)" response_json["Begruendung"] = f"Originalantwort: {final_branch}. " + response_json.get("Begruendung", "") return { "branch": response_json.get("Branche", "FEHLER (Parsing)"), "confidence": response_json.get("Konfidenz", "N/A"), "justification": response_json.get("Begruendung", "Keine Begründung erhalten.") } except Exception as e: logger.error(f"Endgültiger FEHLER beim OpenAI-Aufruf für Brancheneinstufung von {company_name}: {e}") return { "branch": "FEHLER (API)", "confidence": "N/A", "justification": f"Fehler bei der API-Kommunikation: {str(e)[:100]}" } def evaluate_branches_batch(companies_data): """ Führt eine kontextbasierte Brancheneinstufung für einen Batch von Unternehmen durch. Sendet das Branchenschema nur einmal, um Kosten zu sparen. Args: companies_data (list): Eine Liste von Dictionaries, jedes mit 'row_num', 'name', 'summary', 'wiki'. Returns: list: Eine Liste von Dictionaries mit den Ergebnissen für jedes Unternehmen. """ logger = logging.getLogger(__name__) # 1. Baue das ZIEL-BRANCHENSCHEMA als Textblock auf (wird nur einmal gesendet) schema_text_parts = [] for i, (branch, details) in enumerate(Config.BRANCH_GROUP_MAPPING.items()): schema_text_parts.append(f"{i+1}. Branche: {branch}") if details.get("definition"): schema_text_parts.append(f" Definition: {details['definition']}") if details.get("beispiele"): schema_text_parts.append(f" Beispiele: {details['beispiele']}") ziel_branchenschema_text = "\n".join(schema_text_parts) # 2. Baue die Liste der Unternehmensprofile für den Prompt auf unternehmensprofile_text_parts = [] for i, company in enumerate(companies_data): profil = [f"--- UNTERNEHMEN #{i+1} (Original-Zeilennummer: {company['row_num']}) ---"] profil.append(f"- Name: {company.get('name', 'N/A')}") if company.get('summary'): profil.append(f"- Website-Zusammenfassung: {company['summary']}") if company.get('wiki'): profil.append(f"- Wikipedia-Auszug: {company['wiki']}") unternehmensprofile_text_parts.append("\n".join(profil)) unternehmensprofile_text = "\n\n".join(unternehmensprofile_text_parts) # 3. Baue den Master-Prompt zusammen prompt_parts = [ "Du bist ein erfahrener Branchenanalyst. Deine Aufgabe ist es, eine Liste von Unternehmen präzise einer von 54 vordefinierten Branchenkategorien zuzuordnen. Nutze dafür ausschließlich das bereitgestellte ZIEL-BRANCHENSCHEMA.", "\n--- ZIEL-BRANCHENSCHEMA (Deine einzig gültige Wissensbasis) ---", ziel_branchenschema_text, "\n--- ZU ANALYSIERENDE UNTERNEHMEN ---", unternehmensprofile_text, "\n--- DEINE AUFGABE ---", f"Analysiere jedes der {len(companies_data)} Unternehmen Schritt für Schritt.", "Vergleiche jedes Unternehmensprofil mit den Definitionen im ZIEL-BRANCHENSCHEMA.", "Gib als Ergebnis eine JSON-Liste zurück, die exakt ein JSON-Objekt für jedes Unternehmen in der gleichen Reihenfolge enthält.", "Jedes JSON-Objekt muss folgende Schlüssel haben: 'row_num', 'Branche', 'Konfidenz', 'Begruendung'.", "Die 'row_num' muss exakt der 'Original-Zeilennummer' aus dem Input entsprechen.", "Beispiel-Format für die Antwort: \n" ''' [ { "row_num": 123, "Branche": "Maschinenbau", "Konfidenz": "Hoch", "Begruendung": "Das Unternehmen stellt komplexe Maschinen her, was der Definition entspricht." }, { "row_num": 456, "Branche": "Facility Management", "Konfidenz": "Mittel", "Begruendung": "Bietet Dienstleistungen rund um Immobilien an, passt zur Definition." } ] ''' ] prompt = "\n".join(prompt_parts) # 4. API-Aufruf try: response_str = call_openai_chat(prompt, temperature=0.0, model="gpt-4o", response_format_json=True) if not response_str: raise APIError("Keine Antwort von OpenAI erhalten.") # Das Ergebnis sollte direkt eine Liste von Dictionaries sein results_list = json.loads(response_str) # Validierung, ob die Antwort eine Liste ist if not isinstance(results_list, list): logger.error(f"KI-Antwort ist keine Liste, sondern {type(results_list)}. Breche Batch ab.") return None return results_list except Exception as e: logger.error(f"Endgültiger FEHLER beim Batch-API-Aufruf: {e}") return None @retry_on_failure def verify_wiki_article_chatgpt(company_name, parent_name, website, wiki_title, wiki_summary): """ Verifiziert faktenbasiert, ob ein Wikipedia-Artikel zum Unternehmen passt, indem der notwendige Kontext direkt im Prompt mitgeliefert wird. """ logger = logging.getLogger(__name__) logger.info(f"Starte kontextbasierte KI-Verifizierung für '{company_name}'...") prompt_template = """ Aufgabe: Bewerte als erfahrener Datenanalyst, ob der Wikipedia-Artikel die relevanteste Seite für das angegebene Unternehmen ist. Deine Bewertungsregeln: 1. **Muttergesellschaft prüfen:** Wird der Name des Zielunternehmens oder des Mutterkonzerns im Titel oder in der Zusammenfassung erwähnt? Das ist ein sehr starkes positives Signal. 2. **Inhalt prüfen:** Vergleiche den Geschäftszweck aus der Artikel-Zusammenfassung mit der Website. Gibt es eine plausible Übereinstimmung? 3. **Irrelevanz erkennen:** Ist der Artikel über etwas völlig anderes (z.B. eine Stadt, eine Person, ein allgemeines Konzept)? Dann ist er falsch. --- Unternehmensdaten --- Name des Zielunternehmens: {company_name} Website des Zielunternehmens: {website} Mutterkonzern (falls bekannt): {parent_name} --- Vorgegebener Wikipedia-Artikel --- Titel: {wiki_title} Zusammenfassung: {wiki_summary} --- Antwortformat (strikt einhalten!) --- Konsistenz: Begründung: """ prompt = prompt_template.format( company_name=company_name, website=website, parent_name=parent_name if parent_name else "Nicht bekannt", wiki_title=wiki_title, wiki_summary=wiki_summary[:1500] ) try: response = call_openai_chat(prompt, temperature=0.0, model="gpt-4o-mini") lines = response.strip().lower().split('\n') result = {'consistency': 'X', 'justification': 'Fehler: KI-Antwort unvollständig'} for line in lines: if line.startswith('konsistenz:'): result['consistency'] = line.split(':', 1)[1].strip().upper() elif line.startswith('begründung:'): result['justification'] = line.split(':', 1)[1].strip().capitalize() return result except Exception as e: logger.error(f"Fehler bei der Verarbeitung der ChatGPT-Antwort für Wiki-Verifizierung: {e}") return {'consistency': 'X', 'justification': 'Fehler: KI-Antwort konnte nicht geparst werden'} @retry_on_failure def generate_fsm_pitch( company_name, company_short_name, ki_branche, website_summary, wiki_absatz, anzahl_ma, anzahl_techniker, techniker_bucket_ml, ): """ Generiert einen FSM-Pitch mit maximaler stilistischer Qualität und einer hohen Erfolgsquote, basierend auf dem finalen Master-Prompt (v2.3.4). """ logger = logging.getLogger(__name__) # 1. Datenaufbereitung parts = [] if website_summary and '**GESCHÄFTSMODELL**' in website_summary: try: summary_text = website_summary.split('Zusammenfassung:')[1].split('**FSM-POTENZIAL**')[0].strip() if summary_text: parts.append(f"Website-Zusammenfassung: {summary_text}") except IndexError: pass elif website_summary and 'k.a.' not in website_summary.lower(): parts.append(f"Zusammenfassung der Website: {website_summary}") if wiki_absatz and 'k.a.' not in wiki_absatz.lower(): parts.append(f"Wikipedia-Einleitung: {wiki_absatz}") combined = "\n".join(parts) if not combined: return "FEHLER (Mangelnde Daten)" # 2. Namenswahl & 3. Personalinfo display_name = company_short_name if company_short_name and company_short_name.lower() != 'k.a.' else company_name personal_info = "" try: tech_val = int(anzahl_techniker or 0) ma_val = int(anzahl_ma or 0) if tech_val > 0: personal_info = f"Ihrer Serviceorganisation mit rund {int(round(tech_val, -1))} Technikern" elif techniker_bucket_ml and 'k.a.' not in techniker_bucket_ml.lower(): if "Klein" in techniker_bucket_ml: personal_info = "Ihrem wachsenden Serviceteam" elif "Mittel" in techniker_bucket_ml: personal_info = "Ihrer mittelgroßen Serviceorganisation" elif "Gross" in techniker_bucket_ml: personal_info = "Ihrer großen Serviceorganisation mit über 250 Technikern" elif ma_val > 0: personal_info = f"Ihrem Unternehmen mit über {int(round(ma_val, -2))} Mitarbeitern" except (ValueError, TypeError): pass # 4. Der finale, eleganteste Prompt prompt_parts = [ "Du bist ein exzellenter B2B-Stratege und Texter mit einem tiefen Verständnis für operative Prozesse im Außendienst. Deine Aufgabe ist es, einen hochpersonalisierten, scharfsinnigen und wertschätzenden Einleitungssatz für eine E-Mail an ein potenzielles Kundenunternehmen zu formulieren.", "", "--- Denkprozess & Stilvorgaben ---", "1. **Analysiere den Kontext:** Verstehe das Kerngeschäft des Unternehmens aus der Beschreibung. Was ist die spezifischste, physische Service-Tätigkeit, die ihre mobilen Teams durchführen? (z.B. 'Installation von Solaranlagen', 'Wartung von BHKWs', 'Reparatur von Druckmaschinen').", "2. **Identifiziere den Hebel:** Was ist der entscheidende Erfolgsfaktor für diese Tätigkeit? (z.B. 'reibungslose Planung', 'effiziente Koordination', 'schnelle Reaktionszeiten').", "3. **Formuliere den Satz (ca. 20-35 Wörter):**", " - Wähle einen eleganten, aktiven Einstieg wie 'Speziell im Bereich...' oder 'Die reibungslose Koordination...'. Vermeide starre Floskeln.", " - Verbinde die **spezifische Tätigkeit** mit dem **Hebel** und den **geschäftlichen Konsequenzen** (z.B. 'Kundenzufriedenheit', 'Anlagenverfügbarkeit', 'Projekterfolg').", " - Integriere die Personalinfo, wenn sie spezifisch und aussagekräftig ist.", " - **WICHTIG:** Formuliere immer als positive Beobachtung über eine Kernkompetenz. Du implizierst die Herausforderung durch die Betonung der Wichtigkeit der Lösung.", " - Verwende IMMER den bereitgestellten 'Kurznamen'.", "", "--- Exzellente Beispiele für die Zielsprache ---", "• Beispiel 1: Für ein Unternehmen wie 2G Energy, das bei über 8.500 installierten Modulen weltweit den Service sicherstellt, ist die effiziente Koordination ihrer 200 Servicetechniker entscheidend für die maximale Anlagenverfügbarkeit.", "• Beispiel 2: Speziell im Bereich der schnellen und effizienten Installation von integrierten Energielösungen ist für 1KOMMA5° die reibungslose Planung ihres wachsenden Serviceteams der Schlüssel zur Sicherstellung der Kundenzufriedenheit.", "• Beispiel 3: Die präzise Projektplanung und -umsetzung ist besonders für einen Hersteller von Highend-Produktionsanlagen wie 3CON entscheidend, um den hohen Qualitätsansprüchen anspruchsvoller Industriekunden gerecht zu werden.", "", "--- Wichtige Regel ---", "**Generiere IMMER einen Satz.** Wenn die Daten unspezifisch sind, formuliere einen etwas allgemeineren, aber immer noch relevanten Satz über die 'Effizienz des Service-Teams bei der Betreuung von Kundenprojekten'.", "", "--- Unternehmenskontext für deine Aufgabe ---", f"Kurzname des Unternehmens: {display_name}", f"Beschreibung:\n{combined}", f"Personalinfo für den Satz: {personal_info if personal_info else 'Keine Angabe'}", "", "--- Deine Ausgabe ---", "Gib NUR den finalen, perfekt formulierten Satz aus.", ] prompt = "\n".join(prompt_parts) try: fsm_pitch = call_openai_chat(prompt, temperature=0.7, model="gpt-4o") if not fsm_pitch or len(fsm_pitch.split()) < 7: logger.warning(f"KI konnte keinen validen FSM-Pitch für {company_name} generieren (Antwort zu kurz).") return "FEHLER (KI-Antwort unzureichend)" return fsm_pitch.strip().replace('"', '') except Exception as e: logger.error(f"Fehler bei der Generierung des FSM-Pitches für {company_name}: {e}") return "FEHLER (API-Fehler)" @retry_on_failure def serp_website_lookup(company_name): """ Ermittelt die offizielle Website eines Unternehmens ueber SerpAPI (Google Suche). Gibt die URL als String oder "k.A." zurück. """ logger = logging.getLogger(__name__) serp_key = Config.API_KEYS.get('serpapi') if not serp_key: logger.warning("SerpAPI Key nicht konfiguriert. Website-Suche via SerpAPI übersprungen.") return "k.A. (SerpAPI Key fehlt)" if not company_name or str(company_name).strip() == "": logger.warning("serp_website_lookup: Kein Firmenname angegeben.") return "k.A. (Kein Firmenname)" blacklist = ["bloomberg.com", "northdata.de", "finanzen.net", "handelsblatt.com", "wikipedia.org", "linkedin.com", "xing.com", "youtube.com", "facebook.com", "twitter.com", "instagram.com", "glassdoor.com", "kununu.com"] query = f'{company_name} offizielle Website' logger.info(f"Starte SerpAPI Website-Suche fuer '{company_name}' mit Query: '{query[:100]}...'") params = { "engine": "google", "q": query, "api_key": serp_key, "hl": "de", "gl": "de", "safe": "active" } api_url = "https://serpapi.com/search" try: response = requests.get(api_url, params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) response.raise_for_status() data = response.json() if "knowledge_graph" in data and "website" in data["knowledge_graph"]: kg_url = data["knowledge_graph"].get("website") if kg_url and isinstance(kg_url, str): if any(bad_domain in kg_url.lower() for bad_domain in blacklist): logger.debug(f" -> SerpAPI Website Lookup: KG URL '{kg_url[:100]}...' auf Blacklist. Uebersprungen.") else: normalized_url = simple_normalize_url(kg_url) if normalized_url != "k.A.": logger.info(f"SERP Lookup: Website '{normalized_url}' aus Knowledge Graph fuer '{company_name}' gefunden.") return normalized_url if "organic_results" in data: for result in data["organic_results"][:5]: url = result.get("link", "") title = result.get("title", "") snippet = result.get("snippet", "") if url and isinstance(url, str) and url.lower().startswith(("http://", "https://")) and not any(bad_domain in url.lower() for bad_domain in blacklist): normalized_url = simple_normalize_url(url) if normalized_url != "k.A.": normalized_company = normalize_company_name(company_name) domain_part_normalized = normalized_url.replace('www.', '').split('.')[0] title_lower = title.lower() snippet_lower = snippet.lower() domain_name_match = domain_part_normalized in normalized_company name_in_result_text = normalized_company in title_lower or normalized_company in snippet_lower if domain_name_match or name_in_result_text: logger.info(f"SERP Lookup: Website '{normalized_url}' aus Organic Results fuer '{company_name}' gefunden.") return normalized_url logger.info(f"SERP Lookup: Keine passende Website fuer '{company_name}' gefunden nach Pruefung KG und Top Organic Results.") return "k.A." except Exception as e: logger.error(f"FEHLER bei der SerpAPI Website Suche fuer '{company_name}': {e}") return f"k.A. (Fehler Suche: {str(e)[:100]}...)" @retry_on_failure def search_linkedin_contacts(company_name, website, position_query, crm_kurzform, num_results=10): """ Sucht LinkedIn Kontakte fuer ein Unternehmen und eine Position ueber SerpAPI (Google). """ logger = logging.getLogger(__name__) serp_key = Config.API_KEYS.get('serpapi') if not serp_key: logger.error("Fehler: SerpAPI Key nicht verfuegbar fuer LinkedIn Suche.") raise ConnectionRefusedError("SerpAPI Key nicht konfiguriert.") if not all([company_name, position_query, crm_kurzform]) or not all(isinstance(x, str) and x.strip() for x in [company_name, position_query, crm_kurzform]): logger.warning(f"search_linkedin_contacts: Fehlende oder ungueltige Eingabedaten.") raise ValueError("Fehlende oder ungueltige Eingabedaten fuer LinkedIn Suche.") query = f'site:linkedin.com/in/ "{position_query.strip()}" "{crm_kurzform.strip()}"' logger.info(f"Starte SerpAPI LinkedIn-Suche fuer '{crm_kurzform}' (Position: '{position_query}') mit Query: '{query[:100]}...'") params = { "engine": "google", "q": query, "api_key": serp_key, "hl": "de", "gl": "de", "num": num_results } api_url = "https://serpapi.com/search" found_contacts = [] try: response = requests.get(api_url, params=params, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15)) response.raise_for_status() data = response.json() if "organic_results" in data: for result in data["organic_results"]: title = result.get("title", "") linkedin_url = result.get("link", "") snippet = result.get("snippet", "") if not linkedin_url or not isinstance(linkedin_url, str) or "linkedin.com/in/" not in linkedin_url.lower() or "/sales/" in linkedin_url.lower(): continue title_lower = title.lower() snippet_lower = snippet.lower() crm_kurzform_lower = crm_kurzform.lower() position_query_lower = position_query.lower() is_relevant_result = (crm_kurzform_lower in title_lower or crm_kurzform_lower in snippet_lower) or \ (position_query_lower in title_lower or position_query_lower in snippet_lower) if not is_relevant_result: continue name_part = "" pos_part = position_query separators = [" – ", " - ", " | ", " at ", " bei "] title_cleaned = title.replace("...", "").strip() found_sep = False for sep in separators: if sep in title_cleaned: parts = title_cleaned.split(sep, 1) name_part = parts[0].strip() potential_pos_company = parts[1].strip() potential_pos_company = re.sub(r'[\s|\-]*LinkedIn[\s|\-]*Profile.*$', '', potential_pos_company, flags=re.IGNORECASE).strip() potential_pos_company = re.sub(r'[\s|\-]*LinkedIn$', '', potential_pos_company, flags=re.IGNORECASE).strip() if crm_kurzform_lower in potential_pos_company.lower(): pos_company_cleaned = re.sub(r'\b' + re.escape(crm_kurzform_lower) + r'\b', '', potential_pos_company, flags=re.IGNORECASE).strip() pos_company_cleaned = re.sub(r'\s+', ' ', pos_company_cleaned).strip() else: pos_company_cleaned = potential_pos_company pos_part = pos_company_cleaned if pos_company_cleaned else position_query found_sep = True break if not found_sep: if position_query_lower in title_lower: name_before_pos = title_lower.split(position_query_lower, 1)[0].strip() name_part = title_cleaned[:len(name_before_pos)].strip() pos_part = position_query else: name_part = re.sub(r'[\s|\-]*LinkedIn[\s|\-]*Profile.*$', '', title_cleaned, flags=re.IGNORECASE).strip() name_part = re.sub(r'[\s|\-]*LinkedIn$', '', name_part, flags=re.IGNORECASE).strip() pos_part = position_query firstname, lastname = "", "" name_parts = name_part.split() if len(name_parts) > 1: firstname = name_parts[0] lastname = " ".join(name_parts[1:]) elif len(name_parts) == 1: firstname = name_parts[0] if not firstname or not name_part.strip(): continue contact_data = { "Firmenname": company_name, "CRM Kurzform": crm_kurzform, "Website": website, "Vorname": firstname, "Nachname": lastname, "Position": pos_part, "LinkedInURL": linkedin_url } found_contacts.append(contact_data) logger.info(f"LinkedIn Suche fuer '{position_query}' bei '{crm_kurzform}' ergab {len(found_contacts)} Kontakte.") return found_contacts except Exception as e: logger.error(f"FEHLER bei der SerpAPI LinkedIn Suche (Query: '{position_query}', Firma: '{crm_kurzform}'): {e}") return [] # ============================================================================== # 11. WEBSITE SCRAPING & VALIDATION UTILITIES # ============================================================================== def get_website_raw(url, max_length=30000, verify_cert=False): """ Holt Textinhalt von einer Website mit intelligenter Inhalts- und Banner-Erkennung. Basiert auf der bewährten Logik aus v1.7.9. """ logger = logging.getLogger(__name__) if not url or not isinstance(url, str) or url.strip().lower() in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"]: logger.debug(f"get_website_raw übersprungen: Ungültige URL '{url}'.") return "k.A." if not url.lower().startswith(("http://", "https://")): url = "https://" + url headers = {"User-Agent": random.choice(USER_AGENTS)} response = None error_reason = "Unbekannter Fehler" return_marker = False try: logger.debug(f"Versuche Website abzurufen: {url[:100]}... (verify={verify_cert})") response = requests.get( url, timeout=getattr(Config, 'REQUEST_TIMEOUT', 20), headers=headers, verify=verify_cert, allow_redirects=True, stream=False ) response.raise_for_status() error_reason = None except requests.exceptions.SSLError as e_ssl: error_reason = f"SSL Fehler: {str(e_ssl)[:100]}..." logger.warning(f"SSL Fehler für {url[:100]}...: {e_ssl}") except requests.exceptions.Timeout: error_reason = f"Timeout ({getattr(Config, 'REQUEST_TIMEOUT', 20)}s)" logger.warning(f"{error_reason} für {url[:100]}...") except requests.exceptions.ConnectionError as e_conn: error_reason = f"Connection Error: {str(e_conn)[:100]}..." logger.warning(f"{error_reason} für {url[:100]}...") if any(err_str in str(e_conn) for err_str in ["[Errno -2]", "[Errno -3]", "[Errno 111]", "[Errno 113]", "Failed to establish"]): return_marker = True except requests.exceptions.HTTPError as e_http: error_reason = f"HTTP Error {e_http.response.status_code}" logger.warning(f"{error_reason} für {url[:100]}...") if e_http.response.status_code == 404: return_marker = True except Exception as e_gen: error_reason = f"Allg. Fehler: {type(e_gen).__name__}" logger.error(f"Allgemeiner Fehler beim Abrufen von {url[:100]}...: {e_gen}", exc_info=True) if return_marker: logger.warning(f"Markiere URL {url[:100]}... zur erneuten Prüfung (Grund: {error_reason}).") return URL_CHECK_MARKER elif response is None or error_reason: return f"k.A. ({error_reason})" try: # Erfolgreiche Antwort verarbeiten response.encoding = response.apparent_encoding soup = BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser')) # Intelligente Inhalts-Selektion content_selectors = ['main', 'article', '#content', '.content', 'div[role="main"]'] content_area = soup.find('body') # Fallback auf body for selector in content_selectors: selected_area = soup.select_one(selector) if selected_area: content_area = selected_area logger.debug(f"Haupt-Inhaltsbereich mit '{selector}' gefunden.") break if not content_area: logger.warning(f"Kein oder spezifischer Inhaltsbereich in {url[:100]}... gefunden.") return "k.A. (Kein Body gefunden)" # Intelligente Banner-Entfernung banner_selectors = ['[id*="cookie"]', '[class*="cookie"]', '[id*="consent"]', '[class*="consent"]', '[role="dialog"]'] for selector in banner_selectors: for banner in content_area.select(selector): banner.decompose() # Text extrahieren und säubern text = content_area.get_text(separator=' ', strip=True) text = re.sub(r'\s+', ' ', text).strip() # Finale Prüfung auf reinen Banner-Inhalt banner_keywords_strict = ["cookie", "zustimmen", "ablehnen", "datenschutz", "privacy", "akzeptieren"] text_lower_sample = text[:1000].lower() # Prüfe nur den Anfang des Textes keyword_hits = sum(1 for keyword in banner_keywords_strict if keyword in text_lower_sample) if len(text) < 500 and keyword_hits >= 2: logger.warning(f"Text für {url[:100]}... scheint nur Cookie-Banner zu sein. Verwerfe Text.") return "k.A. (Nur Cookie-Banner erkannt)" logger.debug(f"Website {url[:100]}... erfolgreich gescrapt. Extrahierter Text (Länge {len(text)}).") return text[:max_length] if text else "k.A. (Extraktion leer)" except Exception as e_parse: logger.error(f"Fehler beim Parsen von HTML von {url[:100]}...: {e_parse}", exc_info=True) return f"k.A. (Fehler Parsing)" def scrape_website_details(url): """Extrahiert Meta-Details (Titel, Beschreibung, H1s) von einer URL. Ist gehärtet.""" logger = logging.getLogger(__name__) try: if not url or not isinstance(url, str) or url.strip().lower() in ["k.a."]: return "k.A. (Ungueltige URL)" if not url.lower().startswith(('http://', 'https://')): url = "https://" + url response = requests.get(url, timeout=15, headers={"User-Agent": random.choice(USER_AGENTS)}, verify=False) response.raise_for_status() soup = BeautifulSoup(response.content, 'html.parser') title = soup.find('title').get_text(strip=True) if soup.find('title') else '' desc_tag = soup.find('meta', attrs={'name': 'description'}) description = desc_tag['content'] if desc_tag and desc_tag.get('content') else '' h1s = [h.get_text(strip=True) for h in soup.find_all('h1')] return f"Title: {title[:100]} | Description: {description[:150]} | H1s: {', '.join(h1s)[:100]}" except requests.exceptions.HTTPError as e: return f"k.A. (HTTP Fehler {e.response.status_code})" except requests.exceptions.RequestException as e: return f"k.A. (Netzwerkfehler: {type(e).__name__})" except Exception as e: return f"k.A. (Allg. Fehler: {type(e).__name__})" def is_valid_wikipedia_article_url(url): """ Prüft, ob eine Wikipedia-URL auf einen tatsächlich existierenden Artikel verweist. Erkennt "red links" (nicht existierende Seiten), die trotzdem Status 200 zurückgeben. """ logger = logging.getLogger(__name__) if not url or not "wikipedia.org/wiki/" in url.lower(): return False headers = {"User-Agent": random.choice(USER_AGENTS)} try: # GET-Request ist nötig, um Weiterleitungen zu folgen response = requests.get(url, timeout=7, allow_redirects=True, headers=headers) # 1. Primär-Check: Prüfe die finale URL auf den 'redlink=1' Parameter if 'action=edit&redlink=1' in response.url: logger.warning(f"URL-Check fehlgeschlagen (redlink=1): {url[:100]}") return False # 2. Sekundär-Check: Prüfe auf Status-Code und typische "Seite existiert nicht"-Texte if response.status_code == 200: content_lower = response.text.lower() # Deutsche und englische Phrasen für nicht-existente Seiten if "diese seite existiert nicht" in content_lower or "wikipedia does not have an article with this exact name" in content_lower: logger.warning(f"URL-Check fehlgeschlagen (Text 'Seite existiert nicht' gefunden): {url[:100]}") return False logger.debug(f"URL-Check erfolgreich (200 OK, kein Redlink): {url[:100]}") return True else: logger.warning(f"URL-Check fehlgeschlagen (Status {response.status_code}): {url[:100]}") return False except requests.exceptions.RequestException as e: logger.warning(f"URL-Check fehlgeschlagen (Request-Fehler): {url[:100]} - {e}") return False # ============================================================================== # 12. ALIGNMENT DEMO UTILITY # ============================================================================== def alignment_demo(sheet): """ Schreibt die Header-Struktur (v2.1.4, 71 Spalten) ins Google Sheet zur Dokumentation. Dies ist die Single Source of Truth für die Spaltenbedeutung. Version mit ungekürzten Texten. """ logger = logging.getLogger(__name__) logger.info("Starte Alignment Demo für das Hauptblatt (v2.1.4)...") new_headers = [ [ # Zeile 1: Spaltenname (71 Spalten, A-BS) "ReEval Flag", "CRM Name", "CRM Kurzform", "Parent Account Name", "CRM Website", "CRM Ort", "CRM Land", "CRM Beschreibung", "CRM Branche", "CRM Beschreibung Branche extern", "CRM Anzahl Techniker", "CRM Umsatz", "CRM Anzahl Mitarbeiter", "CRM Vorschlag Wiki URL", "System Vorschlag Parent Account", "Parent Vorschlag Status", "Parent Vorschlag Timestamp", "Wiki URL", "Wiki Sitz Stadt", "Wiki Sitz Land", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Wikipedia Timestamp", "Wiki Verif. Timestamp", "SerpAPI Wiki Search Timestamp", "Chat Wiki Konsistenzpruefung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", "Begründung bei Abweichung", "Website Rohtext", "Website Zusammenfassung", "Website Meta-Details", "Website Scrape Timestamp", "URL Prüfstatus", "Chat Vorschlag Branche", "Chat Branche Konfidenz", "Chat Konsistenz Branche", "Chat Begruendung Abweichung Branche", "Chat Prüfung FSM Relevanz", "Chat Begründung für FSM Relevanz", "Chat Schätzung Anzahl Mitarbeiter", "Chat Konsistenzprüfung Mitarbeiterzahl", "Chat Begruendung Abweichung Mitarbeiterzahl", "Chat Einschätzung Anzahl Servicetechniker", "Chat Begründung Abweichung Anzahl Servicetechniker", "Chat Schätzung Umsatz", "Chat Begründung Abweichung Umsatz", "FSM Pitch", "FSM Pitch Timestamp", "Linked Serviceleiter gefunden", "Linked It-Leiter gefunden", "Linked Management gefunden", "Linked Disponent gefunden", "Contact Search Timestamp", "Finaler Umsatz (Wiki>CRM)", "Finaler Mitarbeiter (Wiki>CRM)", "Geschaetzter Techniker Bucket", "Plausibilität Umsatz", "Plausibilität Mitarbeiter", "Plausibilität Umsatz/MA Ratio", "Abweichung Umsatz CRM/Wiki", "Abweichung MA CRM/Wiki", "Plausibilität Begründung", "Plausibilität Prüfdatum", "Timestamp letzte Pruefung", "Version", "Tokens", "CRM ID" ], [ # Zeile 2: Quelle der Daten "CRM", "CRM", "CRM", "CRM/Manuell", "CRM", "CRM", "CRM/Manuell", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "System", "Manuell/System", "System", "Wikipediascraper/SerpAPI/ChatGPT/Manuell", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "System", "System", "System", "ChatGPT API", "ChatGPT API", "ChatGPT API", "System/Manuell", "Web Scraper", "ChatGPT API", "Web Scraper", "System", "System/Web Scraper", "ChatGPT API", "ChatGPT API", "System/ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API", "System/ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API", "System", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "System", "Skript (Wiki/CRM Logik)", "Skript (Wiki/CRM Logik)", "ML Modell / Skript", "Skript (Plausi-Check)", "Skript (Plausi-Check)", "Skript (Plausi-Check)", "Skript (Plausi-Check)", "Skript (Plausi-Check)", "Skript (Plausi-Check)", "System (Plausi-Check TS)", "System", "System", "System", "CRM" ], [ # Zeile 3: Feldkategorie "Prozess", "Firmenname", "Firmenname", "Konzernstruktur", "Website", "Ort", "Land", "Beschreibung (Text)", "Branche", "Branche", "Anzahl Servicetechniker", "Umsatz", "Anzahl Mitarbeiter", "Wikipedia Artikel URL", "Konzernstruktur (Vorschlag)", "Konzernstruktur (Status)", "Timestamp", "Wikipedia Artikel URL", "Ort", "Land", "Beschreibung (Text)", "Branche", "Umsatz", "Anzahl Mitarbeiter", "Kategorien (Text)", "Timestamp", "Timestamp", "Timestamp", "Verifizierung Wiki-Artikel", "Begründung Verifizierung", "Wikipedia Artikel URL (Vorschlag)", "Begründung URL-Abweichung", "Website-Content", "Website-Content (Zusammenfassung)", "Website-Content (Meta)", "Timestamp", "Prozess-Status", "Branche (Vorschlag KI)", "Branche (Konfidenz KI)", "Branche (Konsistenz)", "Branche (Begründung KI)", "FSM Relevanz (KI)", "FSM Relevanz (Begründung KI)", "Anzahl Mitarbeiter (KI)", "Anzahl Mitarbeiter (Konsistenz KI)", "Anzahl Mitarbeiter (Begründung KI)", "Anzahl Servicetechniker (KI)", "Anzahl Servicetechniker (Begründung KI)", "Umsatz (KI)", "Umsatz (Begründung KI)", "Argumentation", "Timestamp", "Kontakte (Anzahl)", "Kontakte (Anzahl)", "Kontakte (Anzahl)", "Kontakte (Anzahl)", "Timestamp", "Umsatz (Konsolidiert)", "Anzahl Mitarbeiter (Konsolidiert)", "Anzahl Servicetechniker (Bucket ML)", "Plausibilität", "Plausibilität", "Plausibilität", "Datenqualitäts-Indikator", "Datenqualitäts-Indikator", "Plausibilität (Text)", "Timestamp (Plausi)", "Timestamp", "Skript Version", "API Tokens", "System-ID" ], [ # Zeile 4: Kurze Beschreibung (UNGEKÜRZT) "Systemspalte, irrelevant für den Prompt. Wird genutzt um die manuelle Neuprüfung dieses Accounts durchzuführen.", "Enthält den Firmennamen nach bestem Gewissen. Firmennamen sind manchmal herausfordernd, insbesondere was unterschiedliche Schreibweisen, Firmierung, Tochter/Mutterfirmen etc. anbelangt. Zur besseren Trefferquote in der Wikipedia-Suche normalisieren wir den Firmennamen und entfernen sämtliche Firmenformen, wie z.B. AG, GmbH, SE etc.", "Enthält eine manuell gepflegte (normalisierte) Kurzform des Firmennamens, wie auch ein Mensch die Firma nennen würde. Dies bedeutet insbesondere, dass die Firmenform wie z.B. GmbH oder AG aus dem Namen entfernt wird. Meist entspricht die Kurzform den ersten beiden Worten des Firmennamens. Manchmal sind auch Worte nötig, wenn die ersten beiden worte zu wenig Aussagekraft haben. Beispiele dafür sind beispielsweise Firmen wie 'Schmidt & Söhne', bei denen 'Schmidt &' wenig Sinn machen würde, oder 'Philip Morris Tabakwaren' - weil in diesem Fall 'Philip Morris' zu generisch wäre bzw. wenig eindeutig.", "Name der direkten Muttergesellschaft / des Hauptkonzerns (falls zutreffend). Manuell gepflegt oder aus CRM. Beeinflusst Konsolidierung und Plausi-Checks.", "Von uns ermittelte Website des Unternehmens, sofern verfügbar.", "von uns ermittelter Ort des Unternehmens", "Land des Unternehmenssitzes laut CRM oder manueller Recherche. Wichtig für regionale Analysen (z.B. DACH).", "Kurze Beschreibung der Haupttätigkeit des Unternehmens aus dem CRM-System. Dient als Input für KI-Analysen.", "Branchenzuweisung aus dem CRM-System. Entspricht idealerweise einer Branche aus dem Ziel-Branchenschema.", "Von externen Datenanbietern (z.B. Dealfront) gelieferte Beschreibung der Branche des Unternehmens. Diese Branchenbeschreibung sollte in den allermeisten Fällen sehr zutreffend sein und ist vermutlich verlässlicher als die aktuelle Branche aus Spalte I.", "Bekannte Anzahl der Servicetechniker des Unternehmens (aus CRM oder Recherche). Dient als Ground Truth für ML.", "Umsatz des Unternehmens in Millionen Euro laut CRM oder Recherche.", "Anzahl der Mitarbeiter des Unternehmens laut CRM oder Recherche.", "Enthält aus einer alten Recherche Vorschläge für die Wikipedia URL zum Unternehmen. Dieser muss aber nicht stimmen. Sollte als Ausgangs- und Vergleichspunkt für die nachgelagerte Wikipedia-Suche dienen. Der Wert soll mit den üblichen Methoden geprüft werden z.B. kommt die normalisierte Website vor, Ähnlichkeitsprüfung des Firmennamens mit dem Artikelnamen von Wikipedia etc.", "Vom System heuristisch ermittelter Vorschlag für den Parent Account (basierend auf Namensähnlichkeiten, Wiki-Infos etc.).", "Status des System-Vorschlags für Parent Account (z.B. 'x' für akzeptiert, '-' für abgelehnt, '?' für unklar zur manuellen Prüfung).", "Zeitstempel der letzten Generierung/Änderung des Parent-Vorschlags/-Status.", "Wikipedia URL aus der Recherche im laufenden Prozess", "Aus Wikipedia-Infobox extrahierte Stadt des Unternehmenssitzes.", "Aus Wikipedia-Infobox extrahiertes Land des Unternehmenssitzes.", "Erster Absatz des Wikipedia-Artikels", "Branche aus Wikipedia-Artikel soweit verfügbar", "Umsatz aus Wikipediaartikel soweit verfügbar.", "Anzahl Mitarbeiter laut Wikipedia sofern verfügbar.", "Komma-separierte Liste der Kategorien, denen der Artikel in Wikipedia zugewiesen wurde. Hier ist auch häufig eine Branche enthalten, häufig auch noch weitere Informationen etwa zur Gründung, ob sie etwa im DAX gelistet ist etc. Guter Anhaltspunkt zur Differenzierung von Unternehmenseinträgen und Wikipedia-Seiten, die kein Unternehmen beschreiben und fälschlicherweise zugewiesen wurden. \nBei jeder Unternehmensseite MUSS das Wort unternehmen in irgendeiner Art und Weise vorkommen.\nNEGATIVSIGNAL: EHEMALIGES UNTERNEHMEN -> Weist darauf hin, dass das Unternehmen nicht mehr besteht.", "Zeitstempel der letzten Wikipedia-Suche und Datenextraktion für diese Zeile (jetzt für Spalten R-Y).", "Zeitstempel der letzten Wikipedia-Artikel-Verifizierung durch ChatGPT (Ergebnis in Spalten AC-AE).", "Zeitstempel des letzten Versuchs, eine fehlende Wiki-URL (R) über SerpAPI zu suchen.", "\"OK\" wird bei Firmen eingetragen, wo Firma und Wikipedia-Eintrag zusammenpassen. \"X\" wird bei Firmen eingetragen, wo Firma und Wikipedia-Eintrag nicht zusammenpassen.", "Begründung welche Inkonsistenz aus den Daten hervorgeht.", "URL des durch ChatGPT recherchierten Wikipedia-Artikels", "XXX derzeit nicht verwendet, wird vermutlich gelöscht xxx", "Roh extrahierter Textinhalt der Firmenwebsite. Basis für Zusammenfassung und KI-Analysen.", "KI-generierte Zusammenfassung des Website-Rohtextes (AG). Input für Branchenbewertung.", "Extrahierte Meta-Daten der Website (Title, Description, H-Tags). Für schnelle Analyse & Validierung.", "Zeitstempel des letzten Website-Scraping/Summarization-Versuchs (für AG-AI).", "Status der URL-Prüfung (z.B. 'URL_CHECK_NEEDED', 'URL_OK', 'FEHLER_SSL'). Wird von 'check_urls' Modus gesetzt/genutzt.", "Durch ChatGPT ermittelte Branche des Unternehmens", "Konfidenz des ChatGPT-Branchenvorschlags (AL), z.B. Hoch/Mittel/Niedrig.", "\"OK\" wird bei Firmen eingetragen, wo die Einschätzung zur Branche mit der CRM Branche übereinstimmt. \"X\" wird ausgegeben, wenn die Einschätzungen nicht zusammenpassen.", "Begründung für Abweichung der Branche von CRM Branche", "\"OK\" wird bei Firmen eingetragen, für die FSM relevant ist, \"X\" für Firmen, für die FSM irrelevant ist.", "Begründung für die Beurteilung in Spalte Chat Begründung für FSM Relevanz", "Anzahl der Mitarbeiter durch ChatGPT geschätzt.", "\"OK\" wird bei Firmen eingetragen, für die Anzahl der Mitarbeiter grob mit der aus Spalte CRM Anzahl Mitarbeiter bzw. der Spalte Wiki Mitarbeiter übereinstimmt. \"X\" für Firmen, bei denen dies nicht zutrifft.", "Begründung für Abweichende Mitarbeiterzahl", "Anzahl der Servicetechniker geschätzt durch Chat GPT", "Begründung für Abweichungen zur Anzahl der Techniker", "Umsatz durch ChatGTP geschätzt", "Begründung für Abweichungen zum Umsatz", "Ein maßgeschneiderter Satz (ca. 20-35 Wörter), der den Nutzen von FSM im spezifischen Unternehmenskontext beleuchtet. Ideal für E-Mail-Automationen.", "Timestamp, wann der FSM Pitch generiert wurde. Steuert die Wiederholung.", "Anzahl der Kontakte die zur Suche 'Serviceleiter', 'Leiter Service', 'technischer Leiter', 'Service Manager', 'Leiter Kundendienst' gefunden wurden", "Anzahl der Kontakte die zur Suche 'Leiter IT', 'IT Leiter', 'Head of IT', 'IT-Leiter', 'CIO' gefunden wurden", "Anzahl der Kontakte die zur Suche 'Geschäftsführer', 'Geschäftsführung', 'GF', 'CEO', 'Geschäftsführerin', 'Managing Director', 'Geschäftsführender Gesellschafter' gefunden wurden", "Anzahl der Kontakte die zur Suche 'Disponent', 'Einsatzplaner' gefunden wurden", "Timestamp des Zeitpunkts zu dem die Kontaktsuche fertiggestellt wurde", "Konsolidierter Umsatzwert in Millionen Euro. Priorisiert Wiki (W) > CRM (L). Berücksichtigt Parent-Account (D).", "Konsolidierte Mitarbeiterzahl (absolut). Priorisiert Wiki (X) > CRM (M). Berücksichtigt Parent-Account (D).", "Ergebnis der Schätzung durch das trainierte Machine-Learning-Modell (Techniker-Bucket).", "Plausibilitätsstatus für den finalen Umsatzwert (BD) (z.B. OK, WARNUNG_HOCH, FEHLER_FORMAT).", "Plausibilitätsstatus für die finale Mitarbeiterzahl (BE) (z.B. OK, WARNUNG_NIEDRIG).", "Plausibilitätsstatus für die Umsatz-pro-Mitarbeiter-Ratio (BD/BE).", "Indikator für Abweichung (>30%) zwischen CRM-Umsatz (L) und Wiki-Umsatz (W). Berücksichtigt Parent-Logik.", "Indikator für Abweichung (>30%) zwischen CRM-MA (M) und Wiki-MA (X). Berücksichtigt Parent-Logik.", "Gesammelte Begründungen für Plausibilitätswarnungen oder -fehler aus den Spalten BG-BK.", "Zeitstempel des letzten Laufs der Plausibilitäts-Checks für diese Zeile.", "Timestamp des Zeitpunkts zu dem die Validierung durch ChatGPT durchgeführt wurde", "Systemspalte zur Ausgabe der Skriptversion die das Ergebnis generiert hat", "Zeigt an, wie viele Tokens für den Request benötigt wurden", "Die eindeutige ID des Accounts aus dem CRM (z.B. Dynamics 365)." ], [ # Zeile 5: Aufgabe / Funktion (UNGEKÜRZT) "Datenquelle/Prozesssteuerung: 'x' markiert Zeile für Re-Evaluation.", "Datenquelle", "Datenquelle/Such-Input", "Datenquelle/Konsolidierungs-Logik", "Datenquelle/Scraping-Ziel", "Datenquelle", "Datenquelle", "Datenquelle/KI-Input", "Datenquelle/Referenz", "Datenquelle/KI-Input", "Datenquelle/ML-Groundtruth", "Datenquelle", "Datenquelle", "Quelle (Priorität 1)", "Ziel/System", "Prozesssteuerung", "System", "Ziel/Quelle (Priorität 2)", "Ziel", "Ziel", "Ziel/KI-Input", "Ziel/KI-Input", "Ziel/Konsolidierung", "Ziel/Konsolidierung", "Ziel/KI-Input", "System", "System", "System", "Ziel", "Ziel", "Ziel", "Manuell", "Quelle/Ziel", "Ziel/KI-Input", "Ziel", "System", "System/Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel (E-Mail-Automation)", "System", "Ziel", "Ziel", "Ziel", "Ziel", "System", "Ziel/ML-Input", "Ziel/ML-Input", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "System", "System", "System", "System", "Datenquelle" ] ] num_cols = len(new_headers[0]) if not all(len(row) == num_cols for row in new_headers): logger.critical(f"FEHLER in alignment_demo: Inkonsistente Spaltenanzahl! Erwartet {num_cols}. Längen: {[len(r) for r in new_headers]}") return end_col_letter = self.sheet_handler._get_col_letter(num_cols) header_range = f"A1:{end_col_letter}{len(new_headers)}" logger.info(f"Schreibe Alignment-Demo Header in Bereich {header_range}...") try: sheet.update(values=new_headers, range_name=header_range, value_input_option='USER_ENTERED') logger.info("Alignment-Demo Header erfolgreich geschrieben.") except Exception as e: logger.error(f"FEHLER beim Schreiben der Alignment-Demo Header: {e}") def alignment_demo(sheet_handler): """ Schreibt die Header-Struktur (basierend auf v2.1.4, 71 Spalten) ins Google Sheet zur Dokumentation. Dies ist die Single Source of Truth für die Spaltenbedeutung. Version mit ungekürzten Texten. """ logger = logging.getLogger(__name__) logger.info("Starte Alignment Demo für das Hauptblatt (basierend auf v2.1.4)...") # Holen des worksheet-Objekts aus dem übergebenen handler sheet = sheet_handler.sheet # HIER IST IHR CODE FÜR DIE HEADER-DATEN new_headers = [ [ # Zeile 1: Spaltenname (71 Spalten, A-BS) "ReEval Flag", "CRM Name", "CRM Kurzform", "Parent Account Name", "CRM Website", "CRM Ort", "CRM Land", "CRM Beschreibung", "CRM Branche", "CRM Beschreibung Branche extern", "CRM Anzahl Techniker", "CRM Umsatz", "CRM Anzahl Mitarbeiter", "CRM Vorschlag Wiki URL", "System Vorschlag Parent Account", "Parent Vorschlag Status", "Parent Vorschlag Timestamp", "Wiki URL", "Wiki Sitz Stadt", "Wiki Sitz Land", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Wikipedia Timestamp", "Wiki Verif. Timestamp", "SerpAPI Wiki Search Timestamp", "Chat Wiki Konsistenzpruefung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", "Begründung bei Abweichung", "Website Rohtext", "Website Zusammenfassung", "Website Meta-Details", "Website Scrape Timestamp", "URL Prüfstatus", "Chat Vorschlag Branche", "Chat Branche Konfidenz", "Chat Konsistenz Branche", "Chat Begruendung Abweichung Branche", "Chat Prüfung FSM Relevanz", "Chat Begründung für FSM Relevanz", "Chat Schätzung Anzahl Mitarbeiter", "Chat Konsistenzprüfung Mitarbeiterzahl", "Chat Begruendung Abweichung Mitarbeiterzahl", "Chat Einschätzung Anzahl Servicetechniker", "Chat Begründung Abweichung Anzahl Servicetechniker", "Chat Schätzung Umsatz", "Chat Begründung Abweichung Umsatz", "FSM Pitch", "FSM Pitch Timestamp", "Linked Serviceleiter gefunden", "Linked It-Leiter gefunden", "Linked Management gefunden", "Linked Disponent gefunden", "Contact Search Timestamp", "Finaler Umsatz (Wiki>CRM)", "Finaler Mitarbeiter (Wiki>CRM)", "Geschaetzter Techniker Bucket", "Plausibilität Umsatz", "Plausibilität Mitarbeiter", "Plausibilität Umsatz/MA Ratio", "Abweichung Umsatz CRM/Wiki", "Abweichung MA CRM/Wiki", "Plausibilität Begründung", "Plausibilität Prüfdatum", "Timestamp letzte Pruefung", "Version", "Tokens", "CRM ID" ], [ # Zeile 2: Quelle der Daten "CRM", "CRM", "CRM", "CRM/Manuell", "CRM", "CRM", "CRM/Manuell", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "System", "Manuell/System", "System", "Wikipediascraper/SerpAPI/ChatGPT/Manuell", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "System", "System", "System", "ChatGPT API", "ChatGPT API", "ChatGPT API", "System/Manuell", "Web Scraper", "ChatGPT API", "Web Scraper", "System", "System/Web Scraper", "ChatGPT API", "ChatGPT API", "System/ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API", "System/ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API", "ChatGPT API", "System", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "System", "Skript (Wiki/CRM Logik)", "Skript (Wiki/CRM Logik)", "ML Modell / Skript", "Skript (Plausi-Check)", "Skript (Plausi-Check)", "Skript (Plausi-Check)", "Skript (Plausi-Check)", "Skript (Plausi-Check)", "Skript (Plausi-Check)", "System (Plausi-Check TS)", "System", "System", "System", "CRM" ], [ # Zeile 3: Feldkategorie "Prozess", "Firmenname", "Firmenname", "Konzernstruktur", "Website", "Ort", "Land", "Beschreibung (Text)", "Branche", "Branche", "Anzahl Servicetechniker", "Umsatz", "Anzahl Mitarbeiter", "Wikipedia Artikel URL", "Konzernstruktur (Vorschlag)", "Konzernstruktur (Status)", "Timestamp", "Wikipedia Artikel URL", "Ort", "Land", "Beschreibung (Text)", "Branche", "Umsatz", "Anzahl Mitarbeiter", "Kategorien (Text)", "Timestamp", "Timestamp", "Timestamp", "Verifizierung Wiki-Artikel", "Begründung Verifizierung", "Wikipedia Artikel URL (Vorschlag)", "Begründung URL-Abweichung", "Website-Content", "Website-Content (Zusammenfassung)", "Website-Content (Meta)", "Timestamp", "Prozess-Status", "Branche (Vorschlag KI)", "Branche (Konfidenz KI)", "Branche (Konsistenz)", "Branche (Begründung KI)", "FSM Relevanz (KI)", "FSM Relevanz (Begründung KI)", "Anzahl Mitarbeiter (KI)", "Anzahl Mitarbeiter (Konsistenz KI)", "Anzahl Mitarbeiter (Begründung KI)", "Anzahl Servicetechniker (KI)", "Anzahl Servicetechniker (Begründung KI)", "Umsatz (KI)", "Umsatz (Begründung KI)", "Argumentation", "Timestamp", "Kontakte (Anzahl)", "Kontakte (Anzahl)", "Kontakte (Anzahl)", "Kontakte (Anzahl)", "Timestamp", "Umsatz (Konsolidiert)", "Anzahl Mitarbeiter (Konsolidiert)", "Anzahl Servicetechniker (Bucket ML)", "Plausibilität", "Plausibilität", "Plausibilität", "Datenqualitäts-Indikator", "Datenqualitäts-Indikator", "Plausibilität (Text)", "Timestamp (Plausi)", "Timestamp", "Skript Version", "API Tokens", "System-ID" ], [ # Zeile 4: Kurze Beschreibung (UNGEKÜRZT) "Systemspalte, irrelevant für den Prompt. Wird genutzt um die manuelle Neuprüfung dieses Accounts durchzuführen.", "Enthält den Firmennamen nach bestem Gewissen. Firmennamen sind manchmal herausfordernd, insbesondere was unterschiedliche Schreibweisen, Firmierung, Tochter/Mutterfirmen etc. anbelangt. Zur besseren Trefferquote in der Wikipedia-Suche normalisieren wir den Firmennamen und entfernen sämtliche Firmenformen, wie z.B. AG, GmbH, SE etc.", "Enthält eine manuell gepflegte (normalisierte) Kurzform des Firmennamens, wie auch ein Mensch die Firma nennen würde. Dies bedeutet insbesondere, dass die Firmenform wie z.B. GmbH oder AG aus dem Namen entfernt wird. Meist entspricht die Kurzform den ersten beiden Worten des Firmennamens. Manchmal sind auch Worte nötig, wenn die ersten beiden worte zu wenig Aussagekraft haben. Beispiele dafür sind beispielsweise Firmen wie 'Schmidt & Söhne', bei denen 'Schmidt &' wenig Sinn machen würde, oder 'Philip Morris Tabakwaren' - weil in diesem Fall 'Philip Morris' zu generisch wäre bzw. wenig eindeutig.", "Name der direkten Muttergesellschaft / des Hauptkonzerns (falls zutreffend). Manuell gepflegt oder aus CRM. Beeinflusst Konsolidierung und Plausi-Checks.", "Von uns ermittelte Website des Unternehmens, sofern verfügbar.", "von uns ermittelter Ort des Unternehmens", "Land des Unternehmenssitzes laut CRM oder manueller Recherche. Wichtig für regionale Analysen (z.B. DACH).", "Kurze Beschreibung der Haupttätigkeit des Unternehmens aus dem CRM-System. Dient als Input für KI-Analysen.", "Branchenzuweisung aus dem CRM-System. Entspricht idealerweise einer Branche aus dem Ziel-Branchenschema.", "Von externen Datenanbietern (z.B. Dealfront) gelieferte Beschreibung der Branche des Unternehmens. Diese Branchenbeschreibung sollte in den allermeisten Fällen sehr zutreffend sein und ist vermutlich verlässlicher als die aktuelle Branche aus Spalte I.", "Bekannte Anzahl der Servicetechniker des Unternehmens (aus CRM oder Recherche). Dient als Ground Truth für ML.", "Umsatz des Unternehmens in Millionen Euro laut CRM oder Recherche.", "Anzahl der Mitarbeiter des Unternehmens laut CRM oder Recherche.", "Enthält aus einer alten Recherche Vorschläge für die Wikipedia URL zum Unternehmen. Dieser muss aber nicht stimmen. Sollte als Ausgangs- und Vergleichspunkt für die nachgelagerte Wikipedia-Suche dienen. Der Wert soll mit den üblichen Methoden geprüft werden z.B. kommt die normalisierte Website vor, Ähnlichkeitsprüfung des Firmennamens mit dem Artikelnamen von Wikipedia etc.", "Vom System heuristisch ermittelter Vorschlag für den Parent Account (basierend auf Namensähnlichkeiten, Wiki-Infos etc.).", "Status des System-Vorschlags für Parent Account (z.B. 'x' für akzeptiert, '-' für abgelehnt, '?' für unklar zur manuellen Prüfung).", "Zeitstempel der letzten Generierung/Änderung des Parent-Vorschlags/-Status.", "Wikipedia URL aus der Recherche im laufenden Prozess", "Aus Wikipedia-Infobox extrahierte Stadt des Unternehmenssitzes.", "Aus Wikipedia-Infobox extrahiertes Land des Unternehmenssitzes.", "Erster Absatz des Wikipedia-Artikels", "Branche aus Wikipedia-Artikel soweit verfügbar", "Umsatz aus Wikipediaartikel soweit verfügbar.", "Anzahl Mitarbeiter laut Wikipedia sofern verfügbar.", "Komma-separierte Liste der Kategorien, denen der Artikel in Wikipedia zugewiesen wurde. Hier ist auch häufig eine Branche enthalten, häufig auch noch weitere Informationen etwa zur Gründung, ob sie etwa im DAX gelistet ist etc. Guter Anhaltspunkt zur Differenzierung von Unternehmenseinträgen und Wikipedia-Seiten, die kein Unternehmen beschreiben und fälschlicherweise zugewiesen wurden. \nBei jeder Unternehmensseite MUSS das Wort unternehmen in irgendeiner Art und Weise vorkommen.\nNEGATIVSIGNAL: EHEMALIGES UNTERNEHMEN -> Weist darauf hin, dass das Unternehmen nicht mehr besteht.", "Zeitstempel der letzten Wikipedia-Suche und Datenextraktion für diese Zeile (jetzt für Spalten R-Y).", "Zeitstempel der letzten Wikipedia-Artikel-Verifizierung durch ChatGPT (Ergebnis in Spalten AC-AE).", "Zeitstempel des letzten Versuchs, eine fehlende Wiki-URL (R) über SerpAPI zu suchen.", "\"OK\" wird bei Firmen eingetragen, wo Firma und Wikipedia-Eintrag zusammenpassen. \"X\" wird bei Firmen eingetragen, wo Firma und Wikipedia-Eintrag nicht zusammenpassen.", "Begründung welche Inkonsistenz aus den Daten hervorgeht.", "URL des durch ChatGPT recherchierten Wikipedia-Artikels", "XXX derzeit nicht verwendet, wird vermutlich gelöscht xxx", "Roh extrahierter Textinhalt der Firmenwebsite. Basis für Zusammenfassung und KI-Analysen.", "KI-generierte Zusammenfassung des Website-Rohtextes (AG). Input für Branchenbewertung.", "Extrahierte Meta-Daten der Website (Title, Description, H-Tags). Für schnelle Analyse & Validierung.", "Zeitstempel des letzten Website-Scraping/Summarization-Versuchs (für AG-AI).", "Status der URL-Prüfung (z.B. 'URL_CHECK_NEEDED', 'URL_OK', 'FEHLER_SSL'). Wird von 'check_urls' Modus gesetzt/genutzt.", "Durch ChatGPT ermittelte Branche des Unternehmens", "Konfidenz des ChatGPT-Branchenvorschlags (AL), z.B. Hoch/Mittel/Niedrig.", "\"OK\" wird bei Firmen eingetragen, wo die Einschätzung zur Branche mit der CRM Branche übereinstimmt. \"X\" wird ausgegeben, wenn die Einschätzungen nicht zusammenpassen.", "Begründung für Abweichung der Branche von CRM Branche", "\"OK\" wird bei Firmen eingetragen, für die FSM relevant ist, \"X\" für Firmen, für die FSM irrelevant ist.", "Begründung für die Beurteilung in Spalte Chat Begründung für FSM Relevanz", "Anzahl der Mitarbeiter durch ChatGPT geschätzt.", "\"OK\" wird bei Firmen eingetragen, für die Anzahl der Mitarbeiter grob mit der aus Spalte CRM Anzahl Mitarbeiter bzw. der Spalte Wiki Mitarbeiter übereinstimmt. \"X\" für Firmen, bei denen dies nicht zutrifft.", "Begründung für Abweichende Mitarbeiterzahl", "Anzahl der Servicetechniker geschätzt durch Chat GPT", "Begründung für Abweichungen zur Anzahl der Techniker", "Umsatz durch ChatGTP geschätzt", "Begründung für Abweichungen zum Umsatz", "Ein maßgeschneiderter Satz (ca. 20-35 Wörter), der den Nutzen von FSM im spezifischen Unternehmenskontext beleuchtet. Ideal für E-Mail-Automationen.", "Timestamp, wann der FSM Pitch generiert wurde. Steuert die Wiederholung.", "Anzahl der Kontakte die zur Suche 'Serviceleiter', 'Leiter Service', 'technischer Leiter', 'Service Manager', 'Leiter Kundendienst' gefunden wurden", "Anzahl der Kontakte die zur Suche 'Leiter IT', 'IT Leiter', 'Head of IT', 'IT-Leiter', 'CIO' gefunden wurden", "Anzahl der Kontakte die zur Suche 'Geschäftsführer', 'Geschäftsführung', 'GF', 'CEO', 'Geschäftsführerin', 'Managing Director', 'Geschäftsführender Gesellschafter' gefunden wurden", "Anzahl der Kontakte die zur Suche 'Disponent', 'Einsatzplaner' gefunden wurden", "Timestamp des Zeitpunkts zu dem die Kontaktsuche fertiggestellt wurde", "Konsolidierter Umsatzwert in Millionen Euro. Priorisiert Wiki (W) > CRM (L). Berücksichtigt Parent-Account (D).", "Konsolidierte Mitarbeiterzahl (absolut). Priorisiert Wiki (X) > CRM (M). Berücksichtigt Parent-Account (D).", "Ergebnis der Schätzung durch das trainierte Machine-Learning-Modell (Techniker-Bucket).", "Plausibilitätsstatus für den finalen Umsatzwert (BD) (z.B. OK, WARNUNG_HOCH, FEHLER_FORMAT).", "Plausibilitätsstatus für die finale Mitarbeiterzahl (BE) (z.B. OK, WARNUNG_NIEDRIG).", "Plausibilitätsstatus für die Umsatz-pro-Mitarbeiter-Ratio (BD/BE).", "Indikator für Abweichung (>30%) zwischen CRM-Umsatz (L) und Wiki-Umsatz (W). Berücksichtigt Parent-Logik.", "Indikator für Abweichung (>30%) zwischen CRM-MA (M) und Wiki-MA (X). Berücksichtigt Parent-Logik.", "Gesammelte Begründungen für Plausibilitätswarnungen oder -fehler aus den Spalten BG-BK.", "Zeitstempel des letzten Laufs der Plausibilitäts-Checks für diese Zeile.", "Timestamp des Zeitpunkts zu dem die Validierung durch ChatGPT durchgeführt wurde", "Systemspalte zur Ausgabe der Skriptversion die das Ergebnis generiert hat", "Zeigt an, wie viele Tokens für den Request benötigt wurden", "Die eindeutige ID des Accounts aus dem CRM (z.B. Dynamics 365)." ], [ # Zeile 5: Aufgabe / Funktion (UNGEKÜRZT) "Datenquelle/Prozesssteuerung: 'x' markiert Zeile für Re-Evaluation.", "Datenquelle", "Datenquelle/Such-Input", "Datenquelle/Konsolidierungs-Logik", "Datenquelle/Scraping-Ziel", "Datenquelle", "Datenquelle", "Datenquelle/KI-Input", "Datenquelle/Referenz", "Datenquelle/KI-Input", "Datenquelle/ML-Groundtruth", "Datenquelle", "Datenquelle", "Quelle (Priorität 1)", "Ziel/System", "Prozesssteuerung", "System", "Ziel/Quelle (Priorität 2)", "Ziel", "Ziel", "Ziel/KI-Input", "Ziel/KI-Input", "Ziel/Konsolidierung", "Ziel/Konsolidierung", "Ziel/KI-Input", "System", "System", "System", "Ziel", "Ziel", "Ziel", "Manuell", "Quelle/Ziel", "Ziel/KI-Input", "Ziel", "System", "System/Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel (E-Mail-Automation)", "System", "Ziel", "Ziel", "Ziel", "Ziel", "System", "Ziel/ML-Input", "Ziel/ML-Input", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "Ziel", "System", "System", "System", "System", "Datenquelle" ] ] num_cols = len(new_headers[0]) if not all(len(row) == num_cols for row in new_headers): logger.critical(f"FEHLER in alignment_demo: Inkonsistente Spaltenanzahl! Erwartet {num_cols}. Längen: {[len(r) for r in new_headers]}") return # KORRIGIERT: Verwende die neue Hilfsfunktion anstatt 'self' end_col_letter = _get_col_letter(num_cols) header_range = f"A1:{end_col_letter}{len(new_headers)}" logger.info(f"Schreibe Alignment-Demo Header in Bereich {header_range}...") try: sheet.update(values=new_headers, range_name=header_range, value_input_option='USER_ENTERED') # Formatierungen hier hinzufügen, falls gewünscht sheet.format(header_range, {"wrapStrategy": "WRAP"}) sheet.format(f"A1:{end_col_letter}1", {"textFormat": {"bold": True}, "backgroundColor": {"red": 0.85, "green": 0.85, "blue": 0.85}}) logger.info("Alignment-Demo Header erfolgreich geschrieben und formatiert.") except Exception as e: logger.error(f"FEHLER beim Schreiben der Alignment-Demo Header: {e}")