5408 lines
304 KiB
Python
5408 lines
304 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
v1.6.7: Behebt strukturelle/Syntax-Fehler; passt Filter für Wiki-Suche via SerpAPI an
|
||
|
||
Git-Änderungsbeschreibung:
|
||
- Inkrementiere Versionsnummer auf v1.6.7.
|
||
- Behebe kritischen AttributeError: Korrigiere die Einrückung für mehrere Verarbeitungsmethoden (_process_single_row, process_reevaluation_rows, process_serp_website_lookup_for_empty, process_website_details_for_marked_rows, prepare_data_for_modeling, process_rows_sequentially, process_find_wiki_with_serp), sodass diese korrekt als Methoden innerhalb der Klasse DataProcessor definiert sind.
|
||
- Behebe SyntaxError: Löse das Problem mit komplexen f-Strings in _process_single_row und potenziell anderen Stellen, indem die String-Konstruktion von Ausdrücken innerhalb der f-String-Syntax getrennt wird.
|
||
- Passe Filterlogik für Modus 'find_wiki_serp' an: Die SerpAPI-Suche nach fehlenden Wiki-URLs (M=k.A./leer) wird nun ausgelöst, wenn (CRM Umsatz (J) > 200 Mio ODER CRM Anzahl Mitarbeiter (K) > 500). Implementiere robuste numerische Extraktion für J und K innerhalb der Filterlogik.
|
||
- Stelle sicher, dass SerpAPI Wiki Search Timestamp (AY) immer nach einem Suchversuch im Modus 'find_wiki_serp' gesetzt wird, unabhängig vom Ergebnis.
|
||
- Diverse Logging-Anpassungen für Klarheit und Debugging (z.B. im Wiki-Verarbeitungsschritt).
|
||
"""
|
||
|
||
import os
|
||
import time
|
||
import re
|
||
import gspread
|
||
import wikipedia
|
||
import requests
|
||
import openai
|
||
from bs4 import BeautifulSoup
|
||
from oauth2client.service_account import ServiceAccountCredentials
|
||
from datetime import datetime
|
||
from difflib import SequenceMatcher
|
||
import unicodedata
|
||
import csv
|
||
import gender_guesser.detector as gender
|
||
# --- HIER unquote hinzufügen ---
|
||
from urllib.parse import urlparse, urlencode, unquote
|
||
import argparse
|
||
import pandas as pd
|
||
import numpy as np
|
||
from sklearn.model_selection import train_test_split, GridSearchCV
|
||
from sklearn.impute import SimpleImputer
|
||
from sklearn.tree import DecisionTreeClassifier, export_text
|
||
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
|
||
import json
|
||
import pickle
|
||
import concurrent.futures
|
||
import threading
|
||
import traceback # Importiere traceback für detailliertere Fehlermeldungen
|
||
import logging
|
||
from urllib.parse import unquote # Wird für API-Check benötigt
|
||
# --- Ende neue Importe ---
|
||
|
||
# Optional: tiktoken für Token-Zählung (Modus 8)
|
||
try:
|
||
import tiktoken
|
||
except ImportError:
|
||
tiktoken = None
|
||
|
||
# ==================== KONSTANTEN ====================
|
||
CREDENTIALS_FILE = "service_account.json"
|
||
API_KEY_FILE = "api_key.txt"
|
||
SERP_API_KEY_FILE = "serpApiKey.txt"
|
||
GENDERIZE_API_KEY_FILE = "genderize_API_Key.txt"
|
||
BRANCH_MAPPING_FILE = "ziel_Branchenschema.csv"
|
||
LOG_DIR = "Log"
|
||
# --- NEU: Dateinamen für Modell-Artefakte ---
|
||
MODEL_FILE = "technician_decision_tree_model.pkl"
|
||
IMPUTER_FILE = "median_imputer.pkl"
|
||
PATTERNS_FILE_TXT = "technician_patterns.txt"
|
||
PATTERNS_FILE_JSON = "technician_patterns.json" # Optional
|
||
|
||
# ==================== KONFIGURATION ====================
|
||
class Config:
|
||
# ... (Alle deine bisherigen Config-Einstellungen) ...
|
||
VERSION = "v1.6.7" # Versionsnummer erhöhen
|
||
LANG = "de"
|
||
SHEET_URL = "https://docs.google.com/spreadsheets/d/1u_gHr9JUfmV1-iviRzbSe3575QEp7KLhK5jFV_gJcgo"
|
||
MAX_RETRIES = 3
|
||
RETRY_DELAY = 5
|
||
SIMILARITY_THRESHOLD = 0.65
|
||
DEBUG = True
|
||
WIKIPEDIA_SEARCH_RESULTS = 5
|
||
HTML_PARSER = "html.parser"
|
||
TOKEN_MODEL = "gpt-3.5-turbo"
|
||
|
||
# --- Konfiguration für Batching & Parallelisierung ---
|
||
BATCH_SIZE = 10 # Batch-Größe für Wiki Verification (_process_batch)
|
||
PROCESSING_BATCH_SIZE = 20 # Wie viele Zeilen pro Verarbeitungs-Batch sammeln (Website Scraping)
|
||
OPENAI_BATCH_SIZE_LIMIT = 4 # Max. Texte pro OpenAI Call in summarize_batch_openai
|
||
MAX_SCRAPING_WORKERS = 10 # Threads für paralleles Website-Scraping
|
||
UPDATE_BATCH_ROW_LIMIT = 50 # Zeilen sammeln für gebündelte Sheet Updates
|
||
MAX_BRANCH_WORKERS = 10 # Threads für parallele Branch-Bewertung?
|
||
OPENAI_CONCURRENCY_LIMIT = 5 # Max. gleichzeitige OpenAI Calls für Branch Bewertung
|
||
PROCESSING_BRANCH_BATCH_SIZE = PROCESSING_BATCH_SIZE # Nutze dieselbe Batch-Größe wie Website, oder definiere neu (z.B. 20)
|
||
|
||
API_KEYS = {}
|
||
@classmethod
|
||
def load_api_keys(cls):
|
||
"""Lädt API-Schlüssel aus den definierten Dateien."""
|
||
logging.info("Lade API-Schlüssel...")
|
||
cls.API_KEYS['openai'] = cls._load_key_from_file(API_KEY_FILE)
|
||
cls.API_KEYS['serpapi'] = cls._load_key_from_file(SERP_API_KEY_FILE)
|
||
cls.API_KEYS['genderize'] = cls._load_key_from_file(GENDERIZE_API_KEY_FILE)
|
||
if cls.API_KEYS.get('openai'):
|
||
openai.api_key = cls.API_KEYS['openai']
|
||
logging.info("OpenAI API Key erfolgreich geladen.")
|
||
else:
|
||
# Dies ist eine Warnung, keine blockierende Fehlermeldung
|
||
logging.warning("OpenAI API Key konnte nicht geladen werden (Datei fehlt oder ist leer?).")
|
||
|
||
@staticmethod
|
||
def _load_key_from_file(filepath):
|
||
"""Hilfsfunktion zum Laden eines Schlüssels aus einer Datei."""
|
||
try:
|
||
with open(filepath, "r", encoding="utf-8") as f: # Encoding hinzugefügt
|
||
key = f.read().strip()
|
||
if key:
|
||
logging.debug(f"Schlüssel aus '{filepath}' erfolgreich geladen.")
|
||
return key
|
||
else:
|
||
logging.warning(f"Datei '{filepath}' ist leer.")
|
||
return None
|
||
except FileNotFoundError:
|
||
# Info, da das Fehlen eines Keys nicht immer ein Fehler sein muss
|
||
logging.info(f"API-Schlüsseldatei '{filepath}' nicht gefunden.")
|
||
return None
|
||
except Exception as e:
|
||
# Error, wenn beim Lesen ein anderer Fehler auftritt
|
||
logging.error(f"Fehler beim Lesen der Schlüsseldatei '{filepath}': {e}")
|
||
return None
|
||
|
||
# Globales Mapping-Dictionary und Schema-String
|
||
BRANCH_MAPPING = {}
|
||
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar."
|
||
ALLOWED_TARGET_BRANCHES = []
|
||
|
||
# Logging-Setup (nehme an, es ist global konfiguriert)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# Globales Spalten-Mapping (Beispiel basierend auf Zeile 4 - Kurze Beschreibung)
|
||
# TODO: Dieses Mapping vervollständigen und durchgängig verwenden!
|
||
COLUMN_MAP = {
|
||
"ReEval Flag": 0, # A
|
||
"CRM Name": 1, # B
|
||
"CRM Kurzform": 2, # C
|
||
"CRM Website": 3, # D
|
||
"CRM Ort": 4, # E
|
||
"CRM Beschreibung": 5, # F
|
||
"CRM Branche": 6, # G
|
||
"CRM Beschreibung Branche extern": 7, # H
|
||
"CRM Anzahl Techniker": 8, # I
|
||
"CRM Umsatz": 9, # J
|
||
"CRM Anzahl Mitarbeiter": 10, # K
|
||
"CRM Vorschlag Wiki URL": 11, # L
|
||
"Wiki URL": 12, # M
|
||
"Wiki Absatz": 13, # N
|
||
"Wiki Branche": 14, # O
|
||
"Wiki Umsatz": 15, # P
|
||
"Wiki Mitarbeiter": 16, # Q
|
||
"Wiki Kategorien": 17, # R
|
||
"Chat Wiki Konsistenzprüfung": 18, # S
|
||
"Chat Begründung Wiki Inkonsistenz": 19, # T
|
||
"Chat Vorschlag Wiki Artikel": 20, # U
|
||
"Begründung bei Abweichung": 21, # V
|
||
"Chat Vorschlag Branche": 22, # W
|
||
"Chat Konsistenz Branche": 23, # X
|
||
"Chat Begründung Abweichung Branche": 24, # Y
|
||
"Chat Prüfung FSM Relevanz": 25, # Z
|
||
"Chat Begründung für FSM Relevanz": 26, # AA
|
||
"Chat Schätzung Anzahl Mitarbeiter": 27, # AB
|
||
"Chat Konsistenzprüfung Mitarbeiterzahl": 28, # AC
|
||
"Chat Begründung Abweichung Mitarbeiterzahl": 29, # AD
|
||
"Chat Einschätzung Anzahl Servicetechniker": 30, # AE
|
||
"Chat Begründung Abweichung Anzahl Servicetechniker": 31, # AF
|
||
"Chat Schätzung Umsatz": 32, # AG
|
||
"Chat Begründung Abweichung Umsatz": 33, # AH
|
||
"Linked Serviceleiter gefunden": 34, # AI
|
||
"Linked It-Leiter gefunden": 35, # AJ
|
||
"Linked Management gefunden": 36, # AK
|
||
"Linked Disponent gefunden": 37, # AL
|
||
"Contact Search Timestamp": 38, # AM
|
||
"Wikipedia Timestamp": 39, # AN (Zeitpunkt der Datenextraktion)
|
||
"Timestamp letzte Prüfung": 40, # AO (Zeitpunkt der Branch-Einschätzung)
|
||
"Version": 41, # AP
|
||
"Tokens": 42, # AQ
|
||
"Website Rohtext": 43, # AR
|
||
"Website Zusammenfassung": 44, # AS
|
||
"Website Scrape Timestamp": 45, # AT
|
||
"Geschätzter Techniker Bucket": 46, # AU
|
||
"Finaler Umsatz (Wiki>CRM)": 47,# AV
|
||
"Finaler Mitarbeiter (Wiki>CRM)": 48, # AW
|
||
"Wiki Verif. Timestamp": 49, # AX (NEU)
|
||
"SerpAPI Wiki Search Timestamp": 50 # AY (NEU)
|
||
}
|
||
# Hinweis: Index ist 0-basiert, Spaltenbuchstaben sind 1-basiert (A=1, AW=49)
|
||
|
||
# Annahme: COLUMN_MAP ist global definiert und enthält mindestens:
|
||
# "CRM Name", "CRM Branche", "CRM Umsatz", "Wiki Umsatz",
|
||
# "CRM Anzahl Mitarbeiter", "Wiki Mitarbeiter", "CRM Anzahl Techniker" (oder wo immer die bekannte Technikerzahl steht)
|
||
|
||
# Stelle sicher, dass die globale COLUMN_MAP verfügbar ist
|
||
# Beispielhafte Definition (bitte an deine Spalten anpassen!)
|
||
# COLUMN_MAP = { ... dein komplettes Mapping ... }
|
||
|
||
# ==================== RETRY-DECORATOR ====================
|
||
def retry_on_failure(func):
|
||
def wrapper(*args, **kwargs):
|
||
func_name = func.__name__
|
||
# Versuche, das 'self' Argument für Methoden zu extrahieren
|
||
self_arg = args[0] if args and hasattr(args[0], func_name) else None
|
||
effective_func_name = f"{self_arg.__class__.__name__}.{func_name}" if self_arg else func_name
|
||
|
||
for attempt in range(Config.MAX_RETRIES):
|
||
try:
|
||
return func(*args, **kwargs)
|
||
except Exception as e:
|
||
error_msg = str(e)
|
||
# Spezifische Fehlerbehandlung (Beispiel)
|
||
if isinstance(e, gspread.exceptions.APIError):
|
||
if e.response.status_code == 429: # Rate Limit
|
||
wait_time = Config.RETRY_DELAY * (attempt + 1) # Exponential backoff
|
||
print(f"🚦 Rate Limit bei {effective_func_name} (Versuch {attempt+1}). Warte {wait_time}s... Fehler: {error_msg[:100]}")
|
||
time.sleep(wait_time)
|
||
continue # Direkt zum nächsten Versuch
|
||
else:
|
||
print(f"⚠️ Google API Fehler bei {effective_func_name} (Versuch {attempt+1}): {error_msg[:100]}")
|
||
elif isinstance(e, requests.exceptions.RequestException):
|
||
print(f"⚠️ Netzwerkfehler bei {effective_func_name} (Versuch {attempt+1}): {error_msg[:100]}")
|
||
elif isinstance(e, openai.error.OpenAIError):
|
||
print(f"⚠️ OpenAI Fehler bei {effective_func_name} (Versuch {attempt+1}): {error_msg[:100]}")
|
||
else:
|
||
print(f"⚠️ Unbekannter Fehler bei {effective_func_name} (Versuch {attempt+1}): {type(e).__name__} - {error_msg[:100]}")
|
||
|
||
if attempt < Config.MAX_RETRIES - 1:
|
||
time.sleep(Config.RETRY_DELAY)
|
||
else:
|
||
print(f"❌ Endgültiger Fehler bei {effective_func_name} nach {Config.MAX_RETRIES} Versuchen.")
|
||
return None # Oder eine spezifische Fehlerkennung zurückgeben
|
||
return None # Sollte nicht erreicht werden, aber zur Sicherheit
|
||
return wrapper
|
||
|
||
@retry_on_failure # Annahme: Decorator existiert
|
||
def serp_wikipedia_lookup(company_name, website=None, min_score=0.4):
|
||
"""
|
||
Sucht über SerpAPI (Google) nach dem wahrscheinlichsten Wikipedia-Artikel.
|
||
Verwendet flexible Query, sammelt Top-10-Kandidaten, bewertet nach Titelähnlichkeit
|
||
und Keywords, bevorzugt deutsche/englische Artikel.
|
||
|
||
Args:
|
||
company_name (str): Der Name des Unternehmens.
|
||
website (str, optional): Die Website des Unternehmens. Defaults to None.
|
||
min_score (float, optional): Mindest-Score (Kombination aus Ähnlichkeit
|
||
und Boni) für einen gültigen Treffer. Defaults to 0.4.
|
||
|
||
Returns:
|
||
str: Die URL des relevantesten Wikipedia-Artikels oder None.
|
||
"""
|
||
serp_key = Config.API_KEYS.get('serpapi')
|
||
if not serp_key:
|
||
logging.error("SerpAPI Key nicht verfügbar für Wikipedia Lookup.")
|
||
return None
|
||
if not company_name:
|
||
logging.warning("serp_wikipedia_lookup: Kein Firmenname angegeben.")
|
||
return None
|
||
|
||
# --- Flexiblere Query Konstruktion ---
|
||
# Ohne Anführungszeichen für breitere Suche
|
||
query = f'{company_name} Wikipedia'
|
||
logging.info(f"Starte SerpAPI Wikipedia-Suche für '{company_name}' mit Query: '{query}'")
|
||
# --- Ende Query ---
|
||
|
||
params = {
|
||
"engine": "google", "q": query, "api_key": serp_key,
|
||
"hl": "de", "gl": "de",
|
||
"num": 10 # Top 10 Ergebnisse
|
||
}
|
||
api_url = "https://serpapi.com/search"
|
||
|
||
try:
|
||
response = requests.get(api_url, params=params, timeout=15)
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
|
||
candidates = [] # Liste von Dictionaries: {'url': str, 'title': str}
|
||
if "organic_results" in data:
|
||
logging.debug(f" -> Prüfe {len(data['organic_results'])} organische Ergebnisse...")
|
||
for result in data["organic_results"]: # Prüfe alle 10
|
||
link = result.get("link")
|
||
# Filtere gültige Wiki-Artikel-Links (de oder en)
|
||
if link and "wikipedia.org/wiki/" in link.lower() \
|
||
and (link.startswith("https://de.wikipedia.org") or link.startswith("https://en.wikipedia.org")) \
|
||
and not any(x in link for x in ['Datei:', 'Spezial:', 'Portal:', 'Hilfe:', 'Diskussion:']):
|
||
try:
|
||
# Extrahiere Titel aus URL
|
||
title_part = link.split('/wiki/', 1)[1]
|
||
# Handle evtl. Anchors (#)
|
||
title_part = title_part.split('#')[0]
|
||
title = unquote(title_part).replace('_', ' ')
|
||
candidates.append({'url': link, 'title': title})
|
||
logging.debug(f" -> Kandidat gefunden: '{title}' ({link})")
|
||
except Exception as e_title_extract:
|
||
logging.warning(f" -> Fehler beim Extrahieren des Titels aus Link {link}: {e_title_extract}")
|
||
continue # Nächsten Kandidaten prüfen
|
||
|
||
if not candidates:
|
||
logging.warning(f" -> SerpAPI: Keine de/en Wikipedia-Kandidaten-URLs in Ergebnissen für '{company_name}' gefunden.")
|
||
return None
|
||
|
||
# Bewerte Kandidaten
|
||
best_match_url = None
|
||
highest_score = -1.0
|
||
normalized_search_name = normalize_company_name(company_name) # Annahme: existiert
|
||
|
||
logging.debug(f" -> Bewerte {len(candidates)} Kandidaten...")
|
||
for cand in candidates:
|
||
url = cand['url']
|
||
title = cand['title']
|
||
try: # Füge Try-Except um die Normalisierung hinzu
|
||
normalized_title = normalize_company_name(title)
|
||
title_lower = title.lower() # Für Keyword-Suche
|
||
except Exception as e_norm:
|
||
logging.warning(f"Fehler beim Normalisieren des Titels '{title}': {e_norm}")
|
||
continue # Überspringe diesen Kandidaten
|
||
|
||
# 1. Basisscore: Titelähnlichkeit
|
||
similarity = SequenceMatcher(None, normalized_title, normalized_search_name).ratio()
|
||
score = similarity
|
||
logging.debug(f" -> Kandidat '{title}': Basis-Ähnlichkeit={similarity:.2f}")
|
||
|
||
# 2. Bonus für Keywords im Titel
|
||
bonus = 0.0
|
||
if "(unternehmen)" in title_lower:
|
||
bonus += 0.2 # Starker Bonus
|
||
logging.debug(" -> Bonus +0.2 für '(unternehmen)'")
|
||
elif any(form in title_lower for form in [' gmbh', ' ag', ' kg', ' ltd', ' inc', ' corp', ' s.a.', ' se', ' group']): # 'group' hinzugefügt
|
||
bonus += 0.1 # Kleinerer Bonus
|
||
logging.debug(" -> Bonus +0.1 für Rechtsform/Gruppen-Keyword")
|
||
|
||
# 3. Bonus für Sprache (Deutsch bevorzugt)
|
||
if url.startswith("https://de.wikipedia.org"):
|
||
bonus += 0.05
|
||
logging.debug(" -> Bonus +0.05 für de.wikipedia.org")
|
||
|
||
# Gesamtscore
|
||
total_score = score + bonus
|
||
logging.debug(f" -> Gesamtscore für '{title}': {total_score:.3f} (Ähnlichkeit={similarity:.2f}, Bonus={bonus:.2f})")
|
||
|
||
# Aktualisiere besten Treffer
|
||
if total_score > highest_score and total_score >= min_score:
|
||
highest_score = total_score
|
||
best_match_url = url
|
||
logging.debug(f" ====> Neuer bester Kandidat: {best_match_url} (Score: {highest_score:.3f}) ====")
|
||
|
||
if best_match_url:
|
||
logging.info(f" -> SerpAPI: Bester relevanter Wikipedia-Link ausgewählt: {best_match_url} (Score: {highest_score:.3f})")
|
||
return best_match_url
|
||
else:
|
||
logging.warning(f" -> SerpAPI: Keiner der {len(candidates)} Kandidaten erreichte den Mindestscore ({min_score}) für '{company_name}'.")
|
||
return None
|
||
|
||
except requests.exceptions.RequestException as e:
|
||
logging.error(f"Fehler bei der SerpAPI Wikipedia Suche für '{company_name}': {e}")
|
||
raise e # Fehler weitergeben für Retry
|
||
except Exception as e:
|
||
logging.error(f"Allgemeiner Fehler bei der SerpAPI Wikipedia Suche für '{company_name}': {e}")
|
||
return None # Bei unerwarteten Fehlern None zurückgeben
|
||
|
||
# Annahme: COLUMN_MAP ist global definiert und enthält mindestens:
|
||
# "CRM Name" (B), "CRM Anzahl Mitarbeiter" (K), "CRM Umsatz" (J),
|
||
# "Wiki URL" (M), "ReEval Flag" (A), "Wiki Absatz" (N), ..., "Wiki Verif. Timestamp" (AX), "SerpAPI Wiki Search Timestamp" (AY)
|
||
# Annahme: serp_wikipedia_lookup und simple_normalize_url sind definiert und nutzen logging/retry
|
||
# Annahme: clean_text und normalize_company_name sind definiert
|
||
|
||
# Die extract_numeric_value Funktion wird für die Extraktion aus Wikipedia-Daten verwendet.
|
||
# Wir benötigen hier eine ähnliche Logik, die aber 0 statt 'k.A.' zurückgibt.
|
||
|
||
# --- Hilfsfunktion zur sicheren numerischen Extraktion FÜR DIE FILTERLOGIK ---
|
||
def get_numeric_filter_value(value_str, is_umsatz=False):
|
||
"""
|
||
Extrahiert und normalisiert Zahlenwerte für die Filterlogik (Umsatz in Mio, Mitarbeiter int).
|
||
Gibt 0 zurück, wenn der Wert leer, k.A., nicht numerisch ist, oder 0 ergibt.
|
||
Beachtet Einheiten (Tsd, Mio, Mrd) für Umsatz.
|
||
"""
|
||
if value_str is None or pd.isna(value_str) or str(value_str).strip() == '':
|
||
return 0 # Leer oder k.A. -> 0
|
||
|
||
raw_value_str = str(value_str).strip()
|
||
if raw_value_str.lower() in ['k.a.', 'n/a', '-']:
|
||
return 0
|
||
|
||
try:
|
||
# Bereinigung ähnlich wie in clean_text und extract_numeric_value
|
||
processed_value = clean_text(raw_value_str) # Annahme: clean_text existiert
|
||
if processed_value == "k.A.": return 0
|
||
|
||
processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|über|unter|mehr als|weniger als|bis zu)\s+', '', processed_value)
|
||
processed_value = re.sub(r'[€$£¥]', '', processed_value).strip()
|
||
processed_value = re.split(r'\s*(-|–|bis)\s*', processed_value, 1)[0].strip()
|
||
processed_value_no_thousands = processed_value.replace('.', '').replace("'", "") # Entferne Punkte UND Apostrophe als Tausendertrenner
|
||
processed_value_final = processed_value_no_thousands.replace(',', '.') # Ersetze Komma durch Punkt für Dezimaltrennung
|
||
|
||
match = re.search(r'([\d.]+)', processed_value_final)
|
||
if not match:
|
||
# logging.debug(f"get_numeric_filter_value: Keine numerischen Zeichen gefunden in '{processed_value_final}'")
|
||
return 0 # Keine numerischen Zeichen gefunden
|
||
|
||
num_str = match.group(1)
|
||
if not num_str or num_str == '.': return 0 # Leerer oder nur Punkt String
|
||
|
||
num = float(num_str) # Konvertiere zum float
|
||
|
||
# --- Einheiten-Skalierung basierend auf ORIGINALSTRING ---
|
||
# Dies ist der kritische Teil für den Umsatz aus Spalte J, wenn er Einheiten enthalten kann
|
||
original_lower = raw_value_str.lower()
|
||
scale_factor = 1.0 # Skalierungsfaktor, um alles in die Basiseinheit zu bringen (z.B. 1 für Tsd, 1000 für Mio, 1000000 für Mrd)
|
||
is_scaled = False # Flag, ob eine Einheit gefunden wurde
|
||
|
||
if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower):
|
||
scale_factor = 1000000.0 # 1 Mrd = 1000 Mio, also 1000*1000 = 1.000.000 Base units (assuming base is 1)
|
||
is_scaled = True
|
||
elif re.search(r'\bmio\s*\b|\bmillionen\s*\b|\bmill\.\s*\b', original_lower):
|
||
# Wenn der Wert bereits in Millionen angegeben ist (wie Spalte J),
|
||
# und die Einheit 'Mio' gefunden wird, muss keine Skalierung angewendet werden,
|
||
# da wir das Ergebnis in Millionen benötigen.
|
||
# ABER: Wir müssen prüfen, ob der *Schwellenwert* in Millionen ist.
|
||
# Der min_umsatz ist in Millionen (z.B. 200). Wir wollen den Wert in Spalte J
|
||
# auch in Millionen haben für den Vergleich.
|
||
# Wenn Original "6815", Einheit "Mio" -> num=6815, scale_factor=1, Ergebnis=6815 (Mio)
|
||
# Wenn Original "6.8 Mrd", Einheit "Mrd" -> num=6.8, scale_factor=1000 (Mio), Ergebnis=6800 (Mio)
|
||
# Das ist komplex. Vereinfachte Annahme: Wenn "Mrd" gefunden, multipliziere mit 1000 für Mio.
|
||
# Wenn "Tsd" gefunden, teile durch 1000 für Mio.
|
||
# Wenn "Mio" oder keine Einheit gefunden, nehme Zahl direkt als Mio.
|
||
if is_umsatz:
|
||
if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower): # Wenn Mrd gefunden
|
||
num = num * 1000.0 # Skaliere zu Millionen
|
||
is_scaled = True
|
||
elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): # Wenn Tsd gefunden
|
||
num = num / 1000.0 # Skaliere zu Millionen
|
||
is_scaled = True
|
||
# Wenn "Mio" gefunden oder keine Einheit und is_umsatz=True,
|
||
# nehmen wir an, der Wert ist bereits in Millionen, keine weitere Skalierung nötig.
|
||
elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): # Wenn Tsd gefunden (für MA)
|
||
num = num * 1000.0 # Skaliere zu Tausendern
|
||
is_scaled = True
|
||
# Andere Einheiten (Mio, Mrd für MA) werden hier ignoriert, was bei MA OK ist.
|
||
|
||
|
||
# Das Ergebnis muss 0 oder positiv sein für die Filterlogik
|
||
result_num = num if num > 0 else 0 # Werte <= 0 zählen nicht
|
||
|
||
if is_umsatz:
|
||
# Rückgabe als Wert in Millionen
|
||
# Wenn keine Einheit gefunden wurde (und is_umsatz), gehen wir davon aus, dass es Millionen sind.
|
||
# Wenn eine Einheit (Tsd, Mrd) gefunden und skaliert wurde, ist es auch in Millionen.
|
||
return result_num # Der Wert sollte jetzt in Millionen sein, bereit für Vergleich mit min_umsatz
|
||
|
||
else: # Mitarbeiterzahl
|
||
# Rückgabe als ganze Zahl
|
||
return round(result_num) # Mitarbeiterzahl runden
|
||
|
||
except Exception as e:
|
||
logging.debug(f"Fehler in get_numeric_filter_value für Wert '{raw_value_str}': {e}")
|
||
return 0
|
||
|
||
# --- Ende Hilfsfunktion für Filter ---
|
||
|
||
|
||
# Nun die process_find_wiki_with_serp Funktion mit der neuen Logik
|
||
|
||
def process_find_wiki_with_serp(sheet_handler, row_limit=None, min_employees=500, min_umsatz=200):
|
||
"""
|
||
Sucht fehlende Wikipedia-URLs (Spalte M = k.A.) für Unternehmen mit
|
||
(Umsatz CRM > min_umsatz MIO € ODER Mitarbeiter CRM > min_employees)
|
||
über SerpAPI und trägt gefundene URLs in Spalte M ein. Setzt ReEval-Flag (A)
|
||
und löscht abhängige Wiki-Spalten (N-V, AN, AO, AP, AX).
|
||
Merkt sich in Spalte AY, wann die Suche durchgeführt wurde.
|
||
|
||
Args:
|
||
sheet_handler (GoogleSheetHandler): Initialisierte Instanz.
|
||
row_limit (int, optional): Maximale Anzahl zu prüfender Zeilen. Defaults to None.
|
||
min_employees (int, optional): Mindestanzahl Mitarbeiter (Spalte K) als Teilfilter. Defaults to 500.
|
||
min_umsatz (int, optional): Mindestumsatz in MIO € (Spalte J) als Teilfilter. Defaults to 200.
|
||
"""
|
||
logging.info(f"Starte Modus 'find_wiki_serp': Suche fehlende Wiki-URLs für Firmen mit (Umsatz CRM > {min_umsatz} MIO € ODER Mitarbeiter CRM > {min_employees})...")
|
||
|
||
if not sheet_handler.load_data(): return
|
||
all_data = sheet_handler.get_all_data_with_headers()
|
||
# Annahme: 5 Header-Zeilen
|
||
header_rows = 5
|
||
if not all_data or len(all_data) <= header_rows:
|
||
logging.warning("Keine oder zu wenige Daten im Sheet für 'find_wiki_serp' gefunden.")
|
||
return
|
||
data_rows = all_data[header_rows:] # Daten ab Zeile 6
|
||
|
||
# Benötigte Spaltenindizes holen (inkl. aller zu löschenden Spalten)
|
||
# Verwenden Sie hier das COLUMN_MAP robust
|
||
col_indices = {}
|
||
required_keys = [
|
||
"ReEval Flag", "CRM Anzahl Mitarbeiter", "CRM Umsatz", "Wiki URL", "CRM Name", "CRM Website", # Website wird jetzt für SerpAPI Suche benötigt
|
||
"Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien",
|
||
"Chat Wiki Konsistenzprüfung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel",
|
||
"Begründung bei Abweichung", "Wikipedia Timestamp", "Timestamp letzte Prüfung",
|
||
"Version", "Wiki Verif. Timestamp", "SerpAPI Wiki Search Timestamp"
|
||
]
|
||
all_keys_found = True
|
||
for key in required_keys:
|
||
idx = COLUMN_MAP.get(key)
|
||
col_indices[key] = idx
|
||
if idx is None:
|
||
logging.critical(f"FEHLER: Benötigter Spaltenschlüssel '{key}' nicht in COLUMN_MAP gefunden! Modus abgebrochen.")
|
||
all_keys_found = False
|
||
|
||
if not all_keys_found:
|
||
return # Abbruch, da Spalten fehlen
|
||
|
||
# Hilfsfunktion zur Konvertierung Spaltenindex -> Buchstabe
|
||
col_letters = {key: sheet_handler._get_col_letter(idx + 1) for key, idx in col_indices.items()}
|
||
|
||
|
||
all_sheet_updates = []
|
||
processed_rows_count = 0 # Zählt Zeilen, für die SerpAPI versucht wurde
|
||
found_urls_count = 0 # Zählt Zeilen, wo eine URL gefunden wurde
|
||
skipped_timestamp_ay_count = 0
|
||
skipped_size_count = 0
|
||
skipped_m_filled_count = 0
|
||
|
||
now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
|
||
# Iteriere durch die Datenzeilen
|
||
for idx, row in enumerate(data_rows):
|
||
row_num_in_sheet = idx + header_rows + 1 # 1-basierte Sheet-Zeilennummer
|
||
|
||
# Limit-Prüfung
|
||
if row_limit is not None and processed_rows_count >= row_limit:
|
||
logging.info(f"Zeilenlimit ({row_limit}) für durchgeführte Suchen erreicht.")
|
||
break
|
||
|
||
# Sicherstellen, dass die Zeile lang genug für alle benötigten Spalten ist
|
||
max_needed_idx = max(col_indices.values())
|
||
if len(row) <= max_needed_idx:
|
||
logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz für benötigte Spalten, erwartet > {max_needed_idx}, hat {len(row)}).")
|
||
continue
|
||
|
||
|
||
# Prüfe AY Timestamp: Überspringe, wenn SerpAPI Suche für diese Zeile schon versucht wurde
|
||
ts_ay_val = row[col_indices["SerpAPI Wiki Search Timestamp"]]
|
||
if ts_ay_val and ts_ay_val.strip():
|
||
skipped_timestamp_ay_count += 1
|
||
continue
|
||
|
||
# Prüfe Wiki URL (M): Überspringe, wenn bereits gefüllt (nicht k.A. oder leer)
|
||
m_value = row[col_indices["Wiki URL"]]
|
||
if m_value and str(m_value).strip().lower() != "k.a.":
|
||
skipped_m_filled_count += 1
|
||
continue
|
||
|
||
# --- Prüfe Unternehmensgröße (J Umsatz ODER K Mitarbeiter) ---
|
||
umsatz_val_str = row[col_indices["CRM Umsatz"]]
|
||
ma_val_str = row[col_indices["CRM Anzahl Mitarbeiter"]]
|
||
|
||
# Nutze die neue Hilfsfunktion, um die Werte für den Vergleich zu bekommen
|
||
umsatz_val_mio = get_numeric_filter_value(umsatz_val_str, is_umsatz=True) # Ergebnis in Mio €
|
||
ma_val_num = get_numeric_filter_value(ma_val_str, is_umsatz=False) # Ergebnis als Integer
|
||
|
||
# Filterlogik: Umsatz > min_umsatz MIO € ODER Mitarbeiter > min_employees
|
||
# Wenn *nicht* (Umsatz > min_umsatz ODER Mitarbeiter > min_employees), dann überspringe
|
||
if not (umsatz_val_mio > min_umsatz or ma_val_num > min_employees):
|
||
logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Größe nicht ausreichend. Umsatz (Mio): {umsatz_val_mio:.2f}, MA: {ma_val_num}). Schwellen: Umsatz > {min_umsatz} Mio, MA > {min_employees}.")
|
||
skipped_size_count += 1
|
||
continue
|
||
# --- Ende Größenprüfung ---
|
||
|
||
|
||
# Kandidat gefunden: M leer/k.A., AY leer, und Größe passt
|
||
company_name = row[col_indices["CRM Name"]]
|
||
if not company_name or str(company_name).strip() == "":
|
||
logging.warning(f"Zeile {row_num_in_sheet}: Übersprungen, kein Firmenname für Suche vorhanden.")
|
||
# Setze AY Timestamp, damit wir nicht immer wieder versuchen
|
||
ay_col_letter = col_letters["SerpAPI Wiki Search Timestamp"]
|
||
all_sheet_updates.append({'range': f'{ay_col_letter}{row_num_in_sheet}', 'values': [[now_timestamp_str]]})
|
||
continue
|
||
|
||
# SerpAPI Suche
|
||
logging.info(f"Zeile {row_num_in_sheet}: Suche Wiki-URL für '{company_name}' (Umsatz (Mio): {umsatz_val_mio:.2f}, MA: {ma_val_num})...")
|
||
processed_rows_count += 1 # Zähle VOR dem Call, dass ein Versuch gestartet wird
|
||
|
||
# Hole Website für SerpAPI Kontext
|
||
website_url = row[col_indices["CRM Website"]] if col_indices["CRM Website"] is not None and len(row) > col_indices["CRM Website"] else None
|
||
|
||
# Annahme: serp_wikipedia_lookup existiert und nutzt logging/retry
|
||
wiki_url_found = serp_wikipedia_lookup(company_name, website=website_url)
|
||
|
||
# Updates vorbereiten
|
||
# Timestamp AY IMMER setzen, nachdem der Versuch gemacht wurde
|
||
ay_col_letter = col_letters["SerpAPI Wiki Search Timestamp"]
|
||
all_sheet_updates.append({'range': f'{ay_col_letter}{row_num_in_sheet}', 'values': [[now_timestamp_str]]})
|
||
|
||
|
||
if wiki_url_found and wiki_url_found.strip() and wiki_url_found != "k.A.":
|
||
logging.info(f" -> URL gefunden: {wiki_url_found}. Bereite Update vor (Setze M, A; Lösche N-V, AN, AO, AP, AX).")
|
||
found_urls_count += 1
|
||
|
||
# Indizes und Buchstaben für Updates holen
|
||
m_l = col_letters["Wiki URL"]
|
||
a_l = col_letters["ReEval Flag"]
|
||
# Spalten N-V leeren
|
||
n_idx = col_indices["Wiki Absatz"]
|
||
v_idx = col_indices["Begründung bei Abweichung"]
|
||
n_l=sheet_handler._get_col_letter(n_idx+1)
|
||
v_l=sheet_handler._get_col_letter(v_idx+1)
|
||
# Timestamps AN, AO, AX, Version AP leeren
|
||
an_l = col_letters["Wikipedia Timestamp"]
|
||
ao_l = col_letters["Timestamp letzte Prüfung"]
|
||
ap_l = col_letters["Version"]
|
||
ax_l = col_letters["Wiki Verif. Timestamp"]
|
||
|
||
|
||
all_sheet_updates.extend([
|
||
{'range': f'{m_l}{row_num_in_sheet}', 'values': [[wiki_url_found]]}, # URL setzen in M
|
||
{'range': f'{a_l}{row_num_in_sheet}', 'values': [['x']]}, # ReEval Flag setzen in A
|
||
# --- Spalten N-V leeren, damit sie neu befüllt werden ---
|
||
# Range N:V leeren
|
||
{'range': f'{n_l}{row_num_in_sheet}:{v_l}{row_num_in_sheet}', 'values': [[''] * (v_idx - n_idx + 1)]},
|
||
{'range': f'{an_l}{row_num_in_sheet}', 'values': [['']]}, # AN leeren
|
||
{'range': f'{ao_l}{row_num_in_sheet}', 'values': [['']]}, # AO leeren
|
||
{'range': f'{ap_l}{row_num_in_sheet}', 'values': [['']]}, # AP leeren
|
||
{'range': f'{ax_l}{row_num_in_sheet}', 'values': [['']]} # AX leeren
|
||
])
|
||
else:
|
||
logging.info(f" -> Keine Wiki-URL für '{company_name}' via SerpAPI gefunden.")
|
||
# Nur AY Timestamp wird gesetzt, was bereits oben passiert ist.
|
||
|
||
# Kleiner Sleep nach jeder SerpAPI-Suche, auch wenn es ein Retry gibt
|
||
# Der Decorator kümmert sich um Retries mit Backoff, dies ist nur eine globale Rate-Limit-Vorsorge.
|
||
time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.3)
|
||
|
||
|
||
# --- Batch Update am Ende ---
|
||
if all_sheet_updates:
|
||
logging.info(f"Sende Batch-Update für {processed_rows_count} geprüfte Zeilen ({found_urls_count} URLs gefunden, {len(all_sheet_updates)} Zellen)...")
|
||
# Annahme: sheet_handler.batch_update_cells existiert und nutzt logging/retry
|
||
success = sheet_handler.batch_update_cells(all_sheet_updates)
|
||
if success:
|
||
logging.info(f"Sheet-Update für 'find_wiki_serp' erfolgreich.")
|
||
# Der else-Fall wird von batch_update_cells geloggt
|
||
else:
|
||
logging.info("Keine Zeilen gefunden, für die eine SerpAPI Wiki-Suche durchgeführt werden musste/konnte.")
|
||
|
||
logging.info(f"Modus 'find_wiki_serp' abgeschlossen.")
|
||
logging.info(f" Durchgeführte Suchen in diesem Lauf: {processed_rows_count}")
|
||
logging.info(f" Gefundene & eingetragene URLs: {found_urls_count}")
|
||
logging.info(f" Übersprungen (AY bereits gesetzt): {skipped_timestamp_ay_count}")
|
||
logging.info(f" Übersprungen (Größe nicht ausreichend): {skipped_size_count}")
|
||
logging.info(f" Übersprungen (M bereits gefüllt): {skipped_m_filled_count}")
|
||
|
||
def prepare_data_for_modeling(sheet_handler):
|
||
"""
|
||
Lädt Daten aus dem Google Sheet, bereitet sie für das Decision Tree Modell vor:
|
||
- Wählt relevante Spalten aus.
|
||
- Konsolidiert Umsatz/Mitarbeiter (Wiki > CRM Priorität).
|
||
- Filtert nach gültiger Technikerzahl (> 0).
|
||
- Erstellt die Zielvariable (Techniker-Bucket).
|
||
- Bereitet Features auf (One-Hot Encoding für Branche).
|
||
- Behält NaNs in numerischen Features für spätere Imputation.
|
||
|
||
Args:
|
||
sheet_handler (GoogleSheetHandler): Instanz mit geladenen Sheet-Daten.
|
||
|
||
Returns:
|
||
pandas.DataFrame: Vorbereiteter DataFrame für Training/Test-Split,
|
||
oder None bei Fehlern.
|
||
"""
|
||
logging.info("Starte Datenvorbereitung für Modellierung...")
|
||
|
||
try:
|
||
# --- 1. Daten laden & Spalten auswählen ---
|
||
all_data = sheet_handler.get_all_data_with_headers()
|
||
if len(all_data) <= 5: # Annahme: 5 Header-Zeilen
|
||
logging.error("Fehler: Nicht genügend Datenzeilen im Sheet gefunden für Modellierung.")
|
||
return None
|
||
headers = all_data[0] # Nimm die erste Zeile als Header für Pandas
|
||
data_rows = all_data[5:] # Daten ohne die ersten 5 Header-Zeilen
|
||
|
||
# Erstelle DataFrame
|
||
df = pd.DataFrame(data_rows, columns=headers)
|
||
logging.info(f"DataFrame für Modellierung erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.")
|
||
|
||
# Wähle benötigte Spalten aus ( passe die Schlüssel an deine COLUMN_MAP an!)
|
||
required_cols_keys = [
|
||
"CRM Name", # Zur Identifikation, wird später entfernt
|
||
"CRM Branche",
|
||
"CRM Umsatz",
|
||
"Wiki Umsatz",
|
||
"CRM Anzahl Mitarbeiter",
|
||
"Wiki Mitarbeiter",
|
||
"CRM Anzahl Techniker" # ÄNDERE DIESEN SCHLÜSSEL, falls die bekannte Zahl woanders steht!
|
||
]
|
||
|
||
try:
|
||
# Verwende direkt die Schlüssel als Spaltennamen (Annahme)
|
||
# oder implementiere hier eine robustere Zuordnung über Header
|
||
df_subset = df[required_cols_keys].copy() # Kopie erstellen
|
||
except KeyError as e:
|
||
logging.error(f"FEHLER: Benötigte Spalte nicht im DataFrame gefunden: {e}. Verfügbare Spalten: {list(df.columns)}")
|
||
return None
|
||
|
||
logging.info(f"Benötigte Spalten für Modellierung ausgewählt.")
|
||
|
||
# --- 2. Features konsolidieren (Umsatz, Mitarbeiter) ---
|
||
def get_valid_numeric(value_str):
|
||
# (Implementierung wie gehabt, aber mit Logging bei Fehlern)
|
||
if value_str is None or pd.isna(value_str) or str(value_str).strip() == '': return np.nan
|
||
original_value = value_str # Für Logging speichern
|
||
try:
|
||
cleaned_str = str(value_str).replace('.', '').replace(',', '.') # Tausender raus, Komma zu Punkt
|
||
val = float(cleaned_str)
|
||
return val if val > 0 else np.nan # Nur Werte > 0 sind gültig
|
||
except (ValueError, TypeError):
|
||
# Logging auf DEBUG-Level, da dies häufig vorkommen kann
|
||
logging.debug(f"Konntze Wert '{original_value}' nicht direkt in Float umwandeln.")
|
||
# Fallback über extract_numeric_value (falls vorhanden und float zurückgibt)
|
||
# try:
|
||
# num_val = extract_numeric_value(str(value_str)) # Annahme: gibt float/int oder Exception/None/NaN zurück
|
||
# if isinstance(num_val, (int, float)) and num_val > 0: return float(num_val)
|
||
# except Exception: pass # Fehler ignorieren, Fallback weiter unten
|
||
# --- VEREINFACHTER FALLBACK ---
|
||
cleaned_str = re.sub(r'[^\d.]', '', str(value_str)) # Nur Ziffern und Punkt behalten
|
||
if not cleaned_str: return np.nan
|
||
try:
|
||
val = float(cleaned_str)
|
||
return val if val > 0 else np.nan
|
||
except ValueError:
|
||
logging.debug(f"Konntze auch bereinigten String '{cleaned_str}' aus '{original_value}' nicht umwandeln.")
|
||
return np.nan
|
||
|
||
cols_to_process = {
|
||
'Umsatz': ('Wiki Umsatz', 'CRM Umsatz', 'Finaler_Umsatz'),
|
||
'Mitarbeiter': ('Wiki Mitarbeiter', 'CRM Anzahl Mitarbeiter', 'Finaler_Mitarbeiter')
|
||
}
|
||
|
||
for base_name, (wiki_col, crm_col, final_col) in cols_to_process.items():
|
||
logging.info(f"Verarbeite und konsolidiere '{base_name}' (Priorität: Wiki > CRM)...")
|
||
wiki_numeric = df_subset[wiki_col].apply(get_valid_numeric)
|
||
crm_numeric = df_subset[crm_col].apply(get_valid_numeric)
|
||
|
||
df_subset[final_col] = np.where(
|
||
wiki_numeric.notna(), # & (wiki_numeric > 0) ist durch get_valid_numeric abgedeckt
|
||
wiki_numeric,
|
||
np.where(
|
||
crm_numeric.notna(), # & (crm_numeric > 0)
|
||
crm_numeric,
|
||
np.nan
|
||
)
|
||
)
|
||
# Info-Log über Ergebnis
|
||
logging.info(f" -> {df_subset[final_col].notna().sum()} gültige '{final_col}' Werte erstellt.")
|
||
|
||
# --- 3. Zielvariable vorbereiten (Technikerzahl) ---
|
||
techniker_col = "CRM Anzahl Techniker" # ÄNDERE DAS WENN NÖTIG!
|
||
logging.info(f"Verarbeite Zielvariable '{techniker_col}'...")
|
||
|
||
# Konvertiere zu Numerisch (Fehler -> NaN)
|
||
df_subset['Anzahl_Servicetechniker_Numeric'] = pd.to_numeric(df_subset[techniker_col], errors='coerce')
|
||
|
||
# Filtere Zeilen: Behalte nur die mit gültiger, positiver Technikerzahl
|
||
initial_rows = len(df_subset)
|
||
df_filtered = df_subset[
|
||
df_subset['Anzahl_Servicetechniker_Numeric'].notna() &
|
||
(df_subset['Anzahl_Servicetechniker_Numeric'] > 0)
|
||
].copy()
|
||
filtered_rows = len(df_filtered)
|
||
removed_rows = initial_rows - filtered_rows
|
||
# Info, wenn Zeilen entfernt wurden
|
||
if removed_rows > 0:
|
||
logging.info(f"{removed_rows} Zeilen entfernt aufgrund fehlender/ungültiger Technikerzahl (Wert <= 0 oder nicht numerisch).")
|
||
logging.info(f"Verbleibende Zeilen für Modellierung: {filtered_rows}")
|
||
|
||
if filtered_rows == 0:
|
||
logging.error("FEHLER: Keine Zeilen mit gültiger Technikerzahl (>0) übrig für Modellierung!")
|
||
return None
|
||
|
||
# --- 4. Techniker-Buckets erstellen ---
|
||
bins = [-1, 0, 19, 49, 99, 249, 499, float('inf')] # -1 um 0 einzuschließen (obwohl wir >0 filtern)
|
||
labels = ['Bucket_1_(0)', 'Bucket_2_(<20)', 'Bucket_3_(<50)', 'Bucket_4_(<100)', 'Bucket_5_(<250)', 'Bucket_6_(<500)', 'Bucket_7_(>499)'] # Namen angepasst
|
||
df_filtered['Techniker_Bucket'] = pd.cut(
|
||
df_filtered['Anzahl_Servicetechniker_Numeric'],
|
||
bins=bins,
|
||
labels=labels,
|
||
right=True # 19 gehört zu <20 etc.
|
||
)
|
||
logging.info("Techniker-Buckets erstellt.")
|
||
# Verteilung als Debug-Info
|
||
logging.debug(f"Verteilung der Buckets:\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True).round(3)}")
|
||
|
||
# --- 5. Kategoriale Features vorbereiten (Branche) ---
|
||
branche_col = "CRM Branche" # Annahme: CRM Branche ist die zu verwendende
|
||
logging.info(f"Verarbeite kategoriales Feature '{branche_col}' für One-Hot Encoding...")
|
||
|
||
# Stelle sicher, dass die Spalte String ist und fülle evtl. NaNs
|
||
df_filtered[branche_col] = df_filtered[branche_col].astype(str).fillna('Unbekannt').str.strip() # .str.strip() hinzugefügt
|
||
|
||
# One-Hot Encoding
|
||
df_encoded = pd.get_dummies(df_filtered, columns=[branche_col], prefix='Branche', dummy_na=False) # dummy_na=False
|
||
logging.info(f"One-Hot Encoding für '{branche_col}' durchgeführt. Neue Spaltenanzahl: {len(df_encoded.columns)}")
|
||
|
||
# --- 6. Finale Auswahl der Features für das Modell ---
|
||
feature_columns = [col for col in df_encoded.columns if col.startswith('Branche_')]
|
||
feature_columns.extend(['Finaler_Umsatz', 'Finaler_Mitarbeiter'])
|
||
target_column = 'Techniker_Bucket'
|
||
|
||
# Erstelle den finalen DataFrame (nur benötigte Spalten)
|
||
# Behalte Originaldaten für spätere Analyse / Zuordnung
|
||
original_data_cols = ['CRM Name', 'Anzahl_Servicetechniker_Numeric']
|
||
df_model_ready = df_encoded[original_data_cols + feature_columns + [target_column]].copy()
|
||
|
||
# Optional: Spalten auf einfache Typen reduzieren (kann Speicher sparen)
|
||
for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter']:
|
||
df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce')
|
||
|
||
# Reset Index für saubere Verarbeitung im nächsten Schritt
|
||
df_model_ready = df_model_ready.reset_index(drop=True)
|
||
|
||
logging.info("Datenvorbereitung für Modellierung abgeschlossen.")
|
||
logging.debug(f"Finaler DataFrame für Modellierung hat {len(df_model_ready)} Zeilen und {len(df_model_ready.columns)} Spalten.")
|
||
logging.debug(f"Feature-Spalten: {feature_columns}")
|
||
logging.debug(f"Ziel-Spalte: {target_column}")
|
||
|
||
# WICHTIG: Info über fehlende Werte vor Imputation
|
||
nan_counts = df_model_ready[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].isna().sum()
|
||
logging.info(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}")
|
||
|
||
return df_model_ready
|
||
|
||
except Exception as e:
|
||
# exception loggt automatisch den Traceback
|
||
logging.exception(f"FEHLER während der Datenvorbereitung: {e}")
|
||
return None
|
||
|
||
# --- Beispielhafter Aufruf (zum Testen) ---
|
||
# if __name__ == '__main__':
|
||
# # Annahme: Config, COLUMN_MAP, debug_print sind definiert
|
||
# # Annahme: GoogleSheetHandler existiert und verbindet sich
|
||
# Config.load_api_keys() # Nur falls für extract_numeric_value nötig
|
||
# LOG_FILE = create_log_filename("dataprep_test")
|
||
# debug_print("Starte Test der Datenvorbereitung...")
|
||
# try:
|
||
# sheet_handler_instance = GoogleSheetHandler()
|
||
# prepared_df = prepare_data_for_modeling(sheet_handler_instance)
|
||
# if prepared_df is not None:
|
||
# print("\n--- Vorbereiteter DataFrame (erste 5 Zeilen): ---")
|
||
# print(prepared_df.head())
|
||
# print("\n--- DataFrame Info: ---")
|
||
# prepared_df.info()
|
||
# print("\n--- Beschreibung numerischer Features: ---")
|
||
# print(prepared_df[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].describe())
|
||
# else:
|
||
# print("Datenvorbereitung fehlgeschlagen.")
|
||
# except Exception as e:
|
||
# print(f"Fehler beim Testaufruf: {e}")
|
||
|
||
|
||
|
||
# ==================== LOGGING & HELPER FUNCTIONS ====================
|
||
|
||
LOG_FILE = None # Wird in main() gesetzt
|
||
|
||
def create_log_filename(mode):
|
||
if not os.path.exists(LOG_DIR):
|
||
os.makedirs(LOG_DIR)
|
||
now = datetime.now().strftime("%d-%m-%Y_%H-%M")
|
||
ver_short = Config.VERSION.replace(".", "")
|
||
return os.path.join(LOG_DIR, f"{now}_{ver_short}_Modus{mode}.txt")
|
||
|
||
def debug_print(message):
|
||
global LOG_FILE
|
||
log_message = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {message}"
|
||
if Config.DEBUG:
|
||
print(log_message)
|
||
if LOG_FILE:
|
||
try:
|
||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||
f.write(log_message + "\n")
|
||
except Exception as e:
|
||
print(f"[CRITICAL] Log-Schreibfehler: {e}")
|
||
|
||
|
||
def simple_normalize_url(url):
|
||
"""Normalisiert URL zu www.domain.tld oder k.A."""
|
||
if not url or not isinstance(url, str):
|
||
return "k.A."
|
||
url = url.strip()
|
||
if not url or url.lower() == 'k.a.': # Prüfe auch auf 'k.a.'
|
||
return "k.A."
|
||
|
||
# Falls kein Schema vorhanden ist, hinzufügen (HTTPS bevorzugen)
|
||
if not url.lower().startswith(("http://", "https://")):
|
||
url = "https://" + url
|
||
try:
|
||
parsed = urlparse(url)
|
||
domain_part = parsed.netloc
|
||
if not domain_part: # Wenn netloc leer ist (z.B. bei relativen Pfaden oder mailto:)
|
||
logging.warning(f"URL '{url}' konnte nicht sinnvoll geparst werden (leerer netloc).")
|
||
return "k.A."
|
||
|
||
# Entferne einen eventuellen Port (z.B. ":8080")
|
||
domain_part = domain_part.split(":", 1)[0]
|
||
# Entferne evtl. User/Passwort-Teile (user:pass@domain)
|
||
if '@' in domain_part:
|
||
domain_part = domain_part.split('@', 1)[1]
|
||
|
||
# Wandle Punycode (IDN) in Unicode um für Lesbarkeit (optional, aber oft sinnvoll)
|
||
try:
|
||
domain_part = domain_part.encode('ascii').decode('idna')
|
||
except UnicodeDecodeError:
|
||
logging.warning(f"Fehler bei IDNA-Dekodierung für Domain '{domain_part}' aus URL '{url}'. Behalte Original.")
|
||
# Behalte den ursprünglichen domain_part, wenn Dekodierung fehlschlägt
|
||
pass
|
||
|
||
domain_part = domain_part.lower() # Einheitliche Kleinschreibung
|
||
|
||
# Wenn die Domain nicht mit "www." beginnt, hinzufügen (außer bei sehr kurzen Domains oder IPs)
|
||
if not domain_part.startswith("www.") and '.' in domain_part:
|
||
# Ausnahme für IP-Adressen oder ungewöhnliche Namen ohne TLD
|
||
if not re.match(r"^\d{1,3}(\.\d{1,3}){3}$", domain_part):
|
||
# Prüfe, ob es eine bekannte TLD ist (einfache Prüfung)
|
||
if domain_part.split('.')[-1].isalpha() and len(domain_part.split('.')[-1]) > 1:
|
||
domain_part = "www." + domain_part
|
||
|
||
# Optional: Unerwünschte Pfade entfernen (alles nach dem ersten /)
|
||
# return domain_part # Gibt nur www.domain.tld zurück
|
||
|
||
# Oder: Normalisierte URL mit Schema zurückgeben?
|
||
# return f"{parsed.scheme}://{domain_part}"
|
||
|
||
# Aktuell: Nur Domain zurückgeben
|
||
return domain_part
|
||
|
||
except Exception as e:
|
||
# Error loggen, da Parsen fehlschlug
|
||
logging.error(f"Fehler bei URL-Normalisierung für '{url}': {e}")
|
||
return "k.A."
|
||
|
||
def normalize_string(s):
|
||
"""Normalisiert Umlaute und Sonderzeichen."""
|
||
if not s or not isinstance(s, str):
|
||
return ""
|
||
replacements = {
|
||
'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue', 'ß': 'ss', 'ä': 'ae', 'ö': 'oe', 'ü': 'ue',
|
||
'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Å': 'A', 'Æ': 'AE', 'à': 'a', 'á': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'æ': 'ae',
|
||
'Ç': 'C', 'ç': 'c', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e',
|
||
'Ì': 'I', 'Í': 'I', 'Î': 'I', 'Ï': 'I', 'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i', 'Ñ': 'N', 'ñ': 'n',
|
||
'Ò': 'O', 'Ó': 'O', 'Ô': 'O', 'Õ': 'O', 'Ø': 'O', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', 'ø': 'o', 'Œ': 'OE', 'œ': 'oe',
|
||
'Š': 'S', 'š': 's', 'Ž': 'Z', 'ž': 'z', 'Ý': 'Y', 'ý': 'y', 'ÿ': 'y', 'Đ': 'D', 'đ': 'd',
|
||
'č': 'c', 'Č': 'C', 'ć': 'c', 'Ć': 'C', 'ł': 'l', 'Ł': 'L', 'ğ': 'g', 'Ğ': 'G', 'ş': 's', 'Ş': 'S',
|
||
'ă': 'a', 'Ă': 'A', 'ı': 'i', 'İ': 'I', 'ň': 'n', 'Ň': 'N', 'ř': 'r', 'Ř': 'R',
|
||
'ő': 'o', 'Ő': 'O', 'ű': 'u', 'Ű': 'U', 'ț': 't', 'Ț': 'T', 'ș': 's', 'Ș': 'S'
|
||
}
|
||
# unicodedata Normalisierung zuerst (kann einige Akzente entfernen)
|
||
try:
|
||
# Versuche NFKD Normalisierung, um Kompatibilitätszeichen zu zerlegen
|
||
s = unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode('ascii')
|
||
except:
|
||
# Fallback, wenn NFKD fehlschlägt (sollte selten sein)
|
||
pass
|
||
# Dann manuelle Ersetzungen
|
||
for src, target in replacements.items():
|
||
s = s.replace(src, target)
|
||
return s
|
||
|
||
def clean_text(text):
|
||
"""Bereinigt Text von Wikipedia etc. (Unicode, Referenzen, Whitespace)."""
|
||
if text is None: return "k.A." # Behandle None explizit
|
||
try:
|
||
text = str(text) # Sicherstellen, dass es ein String ist
|
||
if not text.strip(): return "k.A." # Leere oder nur Whitespace-Strings
|
||
|
||
# Normalisiert Whitespace, Ligaturen etc.
|
||
# NFKC ist oft aggressiver, NFKD zerlegt mehr, NFD ist oft gut für Akzententfernung
|
||
text = unicodedata.normalize("NFC", text) # NFC ist oft ein guter Kompromiss
|
||
|
||
# Entfernt Referenz-Tags wie [1], [2], [Bearbeiten | Quelltext bearbeiten] etc.
|
||
text = re.sub(r'\[\d+\]', '', text)
|
||
text = re.sub(r'\[Bearbeiten\s*\|\s*Quelltext bearbeiten\]', '', text, flags=re.IGNORECASE)
|
||
|
||
# Ersetzt multiple Leerzeichen/Tabs/Newlines durch ein einzelnes Leerzeichen
|
||
text = re.sub(r'\s+', ' ', text).strip()
|
||
|
||
# Wenn nach Bereinigung leer, gib k.A. zurück
|
||
return text if text else "k.A."
|
||
except Exception as e:
|
||
# Fehlermeldung beim Bereinigen
|
||
logging.error(f"Fehler bei clean_text für Input '{str(text)[:50]}...': {e}")
|
||
return "k.A."
|
||
|
||
|
||
def normalize_company_name(name):
|
||
"""Entfernt Rechtsformzusätze etc. für Vergleiche."""
|
||
if not name: return ""
|
||
name = clean_text(name) # Vorab bereinigen
|
||
# Umfassendere Liste von Rechtsformen und Zusätzen
|
||
forms = [
|
||
r'gmbh', r'ges\.?\s*m\.?\s*b\.?\s*h\.?', r'gesellschaft mit beschränkter haftung',
|
||
r'ug', r'u\.g\.', r'unternehmergesellschaft', r'haftungsbeschränkt',
|
||
r'ag', r'a\.g\.', r'aktiengesellschaft',
|
||
r'ohg', r'o\.h\.g\.', r'offene handelsgesellschaft',
|
||
r'kg', r'k\.g\.', r'kommanditgesellschaft',
|
||
r'gmbh\s*&\s*co\.?\s*kg', r'ges\.?\s*m\.?\s*b\.?\s*h\.?\s*&\s*co\.?\s*k\.g\.?',
|
||
r'ag\s*&\s*co\.?\s*kg', r'a\.g\.?\s*&\s*co\.?\s*k\.g\.?',
|
||
r'e\.k\.', r'e\.kfm\.', r'e\.kfr\.', r'eingetragene[rn]? kauffrau', r'eingetragene[rn]? kaufmann',
|
||
r'ltd\.?', r'limited',
|
||
r'ltd\s*&\s*co\.?\s*kg',
|
||
r's\.?a\.?r\.?l\.?', r'sàrl', r'sagl',
|
||
r's\.?a\.?', r'société anonyme', r'sociedad anónima',
|
||
r's\.?p\.?a\.?', r'società per azioni',
|
||
r'b\.?v\.?', r'besloten vennootschap',
|
||
r'n\.?v\.?', r'naamloze vennootschap',
|
||
r'plc\.?', r'public limited company',
|
||
r'inc\.?', r'incorporated',
|
||
r'corp\.?', r'corporation',
|
||
r'llc\.?', r'limited liability company',
|
||
r'kgaa', r'kommanditgesellschaft auf aktien',
|
||
r'se', r'societas europaea',
|
||
r'e\.?g\.?', r'eingetragene genossenschaft', r'genossenschaft', r'genmbh',
|
||
r'e\.?v\.?', r'eingetragener verein', r'verein',
|
||
r'stiftung', r'ggmbh', r'gemeinnützige gmbh', r'gug',
|
||
r'partg', r'partnerschaftsgesellschaft', r'partgmbb',
|
||
r'og', r'o\.g\.', r'offene gesellschaft',
|
||
r'e\.u\.', r'eingetragenes unternehmen',
|
||
r'ges\.?n\.?b\.?r\.?', r'gesellschaft nach bürgerlichem recht',
|
||
r'kollektivgesellschaft', r'einzelfirma',
|
||
# Zusätzliche generische Begriffe am Ende
|
||
r'gruppe', r'holding', r'international', r'systeme', r'technik', r'logistik',
|
||
r'solutions', r'services', r'management', r'consulting', r'produktion',
|
||
r'vertrieb', r'entwicklung', r'maschinenbau', r'anlagenbau'
|
||
]
|
||
# Pattern für ganze Wörter (case-insensitive)
|
||
pattern = r'\b(' + '|'.join(forms) + r')\b'
|
||
normalized = re.sub(pattern, '', name, flags=re.IGNORECASE)
|
||
|
||
# Interpunktion entfernen/ersetzen (außer evtl. &)
|
||
normalized = re.sub(r'[.,;:]', '', normalized)
|
||
normalized = re.sub(r'[\-–/]', ' ', normalized) # Bindestriche etc. durch Leerzeichen ersetzen
|
||
normalized = re.sub(r'\s+', ' ', normalized).strip() # Multiple Leerzeichen reduzieren
|
||
|
||
return normalized.lower()
|
||
|
||
@retry_on_failure # API Calls können fehlschlagen
|
||
def is_valid_wikipedia_article_url(wiki_url):
|
||
"""
|
||
Prüft über die MediaWiki API, ob eine gegebene Wikipedia-URL
|
||
auf einen existierenden Artikel verweist (und keine Weiterleitung/Begriffsklärung ist).
|
||
|
||
Args:
|
||
wiki_url (str): Die zu prüfende Wikipedia URL.
|
||
|
||
Returns:
|
||
bool: True, wenn es ein valider Artikel zu sein scheint, sonst False.
|
||
"""
|
||
if not wiki_url or not isinstance(wiki_url, str) or not wiki_url.lower().startswith(("http://", "https://")) or "wikipedia.org/wiki/" not in wiki_url.lower(): # lower() für Robustheit
|
||
logging.debug(f"is_valid_wikipedia_article_url: Ungültiges Format oder keine Wikipedia-URL: '{wiki_url}'")
|
||
return False
|
||
|
||
title = "URL_PARSE_ERROR" # Default für Logging
|
||
try:
|
||
# Extrahiere den Artikel-Titel aus der URL
|
||
title_part = wiki_url.split('/wiki/', 1)[1]
|
||
# Dekodiere URL-kodierte Zeichen (z.B. %C3%BC -> ü)
|
||
title = unquote(title_part)
|
||
# Ersetze Unterstriche durch Leerzeichen für die API-Suche
|
||
title = title.replace('_', ' ')
|
||
|
||
# Baue die API URL (für deutsche Wikipedia)
|
||
api_url = "https://de.wikipedia.org/w/api.php"
|
||
params = {
|
||
"action": "query",
|
||
"titles": title,
|
||
"format": "json",
|
||
"formatversion": 2, # Moderneres JSON-Format
|
||
"prop": "info|pageprops", # Info und Page Properties abfragen
|
||
"redirects": 1 # Folge Weiterleitungen
|
||
}
|
||
logging.debug(f"is_valid_wikipedia_article_url: Prüfe Titel '{title}' via MediaWiki API...")
|
||
|
||
# Führe den API Call durch
|
||
response = requests.get(api_url, params=params, timeout=10, headers={'User-Agent': Config.USER_AGENT}) # Timeout und UserAgent
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
logging.debug(f" -> API Antwort für '{title}': {str(data)[:200]}...") # Logge Anfang der Antwort
|
||
|
||
# Analysiere die Antwort
|
||
if 'query' in data and 'pages' in data['query']:
|
||
pages = data['query']['pages']
|
||
if pages:
|
||
page_info = pages[0] # Nimm die erste (und einzige) Seite
|
||
# Prüfe auf 'missing': Seite existiert nicht
|
||
if page_info.get('missing', False):
|
||
logging.debug(f" API Check für '{title}': Seite fehlt (missing=True).")
|
||
return False
|
||
# Prüfe auf 'invalid': Titel ist ungültig
|
||
if page_info.get('invalid', False):
|
||
logging.debug(f" API Check für '{title}': Titel ungültig (invalid=True).")
|
||
return False
|
||
# Prüfe auf 'disambiguation': Ist eine Begriffsklärungsseite
|
||
if 'pageprops' in page_info and 'disambiguation' in page_info['pageprops']:
|
||
logging.debug(f" API Check für '{title}': Seite ist eine Begriffsklärung.")
|
||
return False
|
||
# Wenn nichts davon zutrifft, scheint es ein valider Artikel zu sein
|
||
logging.info(f" API Check für '{title}': Scheint ein valider Artikel zu sein.")
|
||
return True
|
||
else:
|
||
# Warnung, da unerwartet
|
||
logging.warning(f" API Check für '{title}': Leere 'pages'-Liste in API-Antwort.")
|
||
return False # Unerwartete Antwort
|
||
else:
|
||
# Warnung, da unerwartet
|
||
logging.warning(f" API Check für '{title}': Unerwartetes Format der API-Antwort (fehlendes 'query' oder 'pages').")
|
||
return False
|
||
|
||
except requests.exceptions.RequestException as e:
|
||
# Error bei Netzwerkproblemen
|
||
logging.error(f" API Check für '{title}': Netzwerkfehler - {e}")
|
||
# Fehler weitergeben, damit retry_on_failure greift
|
||
raise e
|
||
except Exception as e:
|
||
# Error bei anderen Problemen
|
||
logging.error(f" API Check für '{title}': Allgemeiner Fehler - {e}")
|
||
# Im Zweifel als ungültig werten, aber keinen Fehler für Retry werfen? Oder doch?
|
||
# Besser Fehler weitergeben, falls es ein temporäres Problem ist.
|
||
raise e # Fehler weitergeben
|
||
|
||
# NEUE Funktion für Wiki-Updates basierend auf ChatGPT Vorschlägen
|
||
# NEUE Funktion für Wiki-Updates basierend auf ChatGPT Vorschlägen (mit Status-Update in S)
|
||
# Komplette Funktion process_wiki_updates_from_chatgpt (Syntaxfehler behoben)
|
||
def process_wiki_updates_from_chatgpt(sheet_handler, data_processor, row_limit=None):
|
||
"""
|
||
Identifiziert Zeilen (S nicht OK/Updated/Copied/Invalid), prüft ob U eine *valide* und *andere* Wiki-URL ist.
|
||
- Wenn ja: Kopiert U->M, markiert S='X (URL Copied)', U='URL übernommen', löscht TS/Version, setzt ReEval-Flag A.
|
||
- Wenn nein (U keine URL, U==M, oder U ungültig): LÖSCHT den Inhalt von U und markiert S als 'X (Invalid Suggestion)'.
|
||
Verarbeitet maximal row_limit Zeilen.
|
||
"""
|
||
logging.info("Starte Modus: Wiki-Updates (URL-Validierung & Löschen ungültiger Vorschläge)...")
|
||
if row_limit is not None:
|
||
logging.info(f"Zeilenlimit für diesen Lauf: {row_limit}")
|
||
|
||
if not sheet_handler.load_data(): return # load_data loggt intern
|
||
all_data = sheet_handler.get_all_data_with_headers()
|
||
if not all_data or len(all_data) <= 5:
|
||
logging.warning("Keine oder zu wenige Daten im Sheet für Wiki-Updates gefunden.")
|
||
return
|
||
header_rows = 5
|
||
data_rows = all_data[header_rows:]
|
||
|
||
# --- Indizes holen ---
|
||
required_keys = [
|
||
"Chat Wiki Konsistenzprüfung", "Chat Vorschlag Wiki Artikel", "Wiki URL",
|
||
"Wikipedia Timestamp", "Wiki Verif. Timestamp", "Timestamp letzte Prüfung", "Version",
|
||
"ReEval Flag"
|
||
]
|
||
col_indices = {}
|
||
all_keys_found = True
|
||
for key in required_keys:
|
||
idx = COLUMN_MAP.get(key)
|
||
col_indices[key] = idx
|
||
if idx is None:
|
||
logging.error(f"FEHLER: Schlüssel '{key}' für Spaltenindex fehlt in COLUMN_MAP!")
|
||
all_keys_found = False
|
||
|
||
if not all_keys_found:
|
||
logging.error("Breche Wiki-Updates ab, da Spaltenindizes fehlen.")
|
||
return
|
||
# --- Ende Indizes holen ---
|
||
|
||
all_sheet_updates = []
|
||
processed_rows_count = 0 # Zählt Zeilen, die geprüft werden
|
||
updated_url_count = 0 # Zählt Zeilen, wo URL kopiert wurde
|
||
cleared_suggestion_count = 0 # Zählt Zeilen, wo Vorschlag gelöscht wurde
|
||
|
||
# Iteriere durch die Datenzeilen
|
||
for idx, row in enumerate(data_rows):
|
||
row_num_in_sheet = idx + header_rows + 1
|
||
|
||
if row_limit is not None and processed_rows_count >= row_limit:
|
||
logging.info(f"Zeilenlimit ({row_limit}) erreicht.")
|
||
break
|
||
|
||
# --- Hilfsfunktion für sicheren Zugriff ---
|
||
def get_value(key):
|
||
index = col_indices.get(key)
|
||
if index is not None and len(row) > index: return row[index]
|
||
# Logge nur auf Debug-Level, wenn ein Wert fehlt
|
||
# logging.debug(f"Zeile {row_num_in_sheet}: Wert für '{key}' nicht vorhanden (Index: {index}, Zeilenlänge: {len(row)}).")
|
||
return ""
|
||
|
||
konsistenz_s = get_value("Chat Wiki Konsistenzprüfung")
|
||
vorschlag_u = get_value("Chat Vorschlag Wiki Artikel")
|
||
url_m = get_value("Wiki URL")
|
||
|
||
# Bedingungen prüfen
|
||
konsistenz_s_upper = konsistenz_s.strip().upper()
|
||
vorschlag_u_cleaned = vorschlag_u.strip()
|
||
url_m_cleaned = url_m.strip()
|
||
|
||
# Zustand, der eine Prüfung/Aktion auslöst:
|
||
# Status S ist gesetzt UND NICHT einer der End-/Bearbeitungszustände
|
||
is_candidate_for_check = konsistenz_s_upper and konsistenz_s_upper not in ["OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)"]
|
||
|
||
if is_candidate_for_check:
|
||
logging.debug(f"Zeile {row_num_in_sheet}: Kandidat für Wiki-Update-Prüfung (Status S = '{konsistenz_s}'). Vorschlag U = '{vorschlag_u_cleaned}'")
|
||
processed_rows_count += 1 # Zähle geprüfte Zeile
|
||
|
||
# Prüfe, ob Vorschlag U eine valide URL ist und sich von M unterscheidet
|
||
is_update_candidate = False
|
||
new_url = ""
|
||
condition2_u_is_url = vorschlag_u_cleaned.lower().startswith(("http://", "https://")) and "wikipedia.org/wiki/" in vorschlag_u_cleaned.lower()
|
||
|
||
if condition2_u_is_url:
|
||
new_url = vorschlag_u_cleaned
|
||
condition3_u_differs_m = new_url != url_m_cleaned
|
||
if condition3_u_differs_m:
|
||
logging.debug(f" -> Prüfe Validität der neuen URL: {new_url}...")
|
||
condition4_u_is_valid = is_valid_wikipedia_article_url(new_url) # Nutzt die überarbeitete Funktion
|
||
if condition4_u_is_valid:
|
||
is_update_candidate = True
|
||
else:
|
||
logging.debug(f" -> URL '{new_url}' ist KEIN valider Artikel laut API Check.")
|
||
else:
|
||
logging.debug(f" -> Vorschlag U ist identisch mit URL M.")
|
||
else:
|
||
logging.debug(f" -> Vorschlag U ist keine Wikipedia URL.")
|
||
|
||
# --- Verarbeitung des Kandidaten ODER Löschen des Vorschlags ---
|
||
if is_update_candidate:
|
||
# Fall 1: Gültiges Update durchführen
|
||
logging.info(f"Zeile {row_num_in_sheet}: Update-Kandidat VALIDIERUNG ERFOLGREICH. Setze ReEval-Flag 'x' und bereite Updates vor für URL: {new_url}")
|
||
updated_url_count += 1
|
||
# Updates sammeln (M, S, U, Timestamps/Version löschen, A setzen)
|
||
m_l=sheet_handler._get_col_letter(col_indices["Wiki URL"]+1); s_l=sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"]+1); u_l=sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"]+1)
|
||
an_l=sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"]+1); ax_l=sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"]+1); ao_l=sheet_handler._get_col_letter(col_indices["Timestamp letzte Prüfung"]+1)
|
||
ap_l=sheet_handler._get_col_letter(col_indices["Version"]+1); a_l=sheet_handler._get_col_letter(col_indices["ReEval Flag"]+1)
|
||
row_updates = [
|
||
{'range': f'{m_l}{row_num_in_sheet}', 'values': [[new_url]]},
|
||
{'range': f'{s_l}{row_num_in_sheet}', 'values': [["X (URL Copied)"]]}, # Neuer Status
|
||
{'range': f'{u_l}{row_num_in_sheet}', 'values': [["URL übernommen"]]}, # Info in U
|
||
# Timestamps löschen, damit reeval greift
|
||
{'range': f'{an_l}{row_num_in_sheet}', 'values': [[""]]},
|
||
{'range': f'{ax_l}{row_num_in_sheet}', 'values': [[""]]},
|
||
{'range': f'{ao_l}{row_num_in_sheet}', 'values': [[""]]},
|
||
{'range': f'{ap_l}{row_num_in_sheet}', 'values': [[""]]},
|
||
{'range': f'{a_l}{row_num_in_sheet}', 'values': [["x"]]}, # ReEval Flag setzen!
|
||
]
|
||
all_sheet_updates.extend(row_updates)
|
||
else:
|
||
# Fall 2: Ungültigen Vorschlag löschen/markieren
|
||
logging.info(f"Zeile {row_num_in_sheet}: Status S ('{konsistenz_s}') erfordert Prüfung, aber Vorschlag U ('{vorschlag_u_cleaned}') ist ungültig/identisch. Lösche U und setze Status S.")
|
||
cleared_suggestion_count += 1
|
||
s_l=sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"]+1)
|
||
u_l=sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"]+1)
|
||
row_updates = [
|
||
{'range': f'{s_l}{row_num_in_sheet}', 'values': [["X (Invalid Suggestion)"]]}, # Neuer Status
|
||
{'range': f'{u_l}{row_num_in_sheet}', 'values': [[""]]} # Vorschlag löschen
|
||
]
|
||
all_sheet_updates.extend(row_updates)
|
||
# Kein ReEval-Flag setzen
|
||
|
||
# --- Batch Update am Ende ---
|
||
if all_sheet_updates:
|
||
# Info-Log über Anzahl der Updates
|
||
logging.info(f"Sende Batch-Update für {processed_rows_count} geprüfte Zeilen ({len(all_sheet_updates)} Zellen)...")
|
||
success = sheet_handler.batch_update_cells(all_sheet_updates) # Nutzt intern Logging
|
||
if success:
|
||
logging.info(f"Sheet-Update für Wiki-Updates erfolgreich.")
|
||
# Der else-Fall wird von batch_update_cells geloggt
|
||
else:
|
||
logging.info("Keine Zeilen gefunden, die eine Wiki-URL-Korrektur oder Vorschlagsbereinigung benötigen.")
|
||
|
||
logging.info(f"Wiki-Updates abgeschlossen. {processed_rows_count} Zeilen geprüft. {updated_url_count} URLs kopiert & für ReEval markiert, {cleared_suggestion_count} ungültige Vorschläge gelöscht/markiert.")
|
||
|
||
def extract_numeric_value(raw_value, is_umsatz=False):
|
||
"""Extrahiert und normalisiert Zahlenwerte (Umsatz in Mio, Mitarbeiter).
|
||
Berücksichtigt jetzt auch Apostroph (') als Tausendertrenner."""
|
||
if not raw_value: return "k.A."
|
||
raw_value_str = str(raw_value)
|
||
if not raw_value_str.strip() or raw_value_str.strip().lower() in ['k.a.', 'n/a', '-']:
|
||
return "k.A."
|
||
|
||
processed_value = clean_text(raw_value_str)
|
||
if processed_value == "k.A.": return "k.A."
|
||
|
||
logging.debug(f"extract_numeric_value: Verarbeite Wert: '{processed_value}' (is_umsatz={is_umsatz})")
|
||
|
||
# --- Anpassung hier: Entferne auch Apostroph ---
|
||
processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|über|unter|mehr als|weniger als|bis zu)\s+', '', processed_value)
|
||
processed_value = re.sub(r'[€$£¥]', '', processed_value).strip()
|
||
processed_value = re.split(r'\s*(-|–|bis)\s*', processed_value, 1)[0].strip()
|
||
# Entferne Punkte UND Apostrophe als Tausendertrenner
|
||
processed_value = processed_value.replace('.', '').replace("'", "")
|
||
# Ersetze Komma durch Punkt für Dezimaltrennung
|
||
processed_value = processed_value.replace(',', '.')
|
||
# --- Ende Anpassung ---
|
||
|
||
match = re.search(r'([\d.]+)', processed_value)
|
||
if not match:
|
||
logging.warning(f"Keine numerischen Zeichen gefunden nach Bereinigung von: '{raw_value_str}' -> Ergibt: '{processed_value}'")
|
||
return "k.A."
|
||
|
||
num_str = match.group(1)
|
||
try:
|
||
# Versuche, leere Strings oder nur '.' abzufangen
|
||
if not num_str or num_str == '.': raise ValueError("Leerer oder ungültiger Zahlenstring")
|
||
num = float(num_str)
|
||
except ValueError as e:
|
||
logging.error(f"Fehler bei Float-Umwandlung des extrahierten Strings '{num_str}' (aus '{processed_value}'): {e}")
|
||
return "k.A."
|
||
|
||
original_lower = raw_value_str.lower()
|
||
multiplier = 1.0
|
||
unit_found = None
|
||
|
||
if "mrd" in original_lower or "milliarden" in original_lower or "billion" in original_lower:
|
||
multiplier = 1000000000.0
|
||
unit_found = "Mrd"
|
||
elif "mio" in original_lower or "millionen" in original_lower or "mill." in original_lower:
|
||
multiplier = 1000000.0
|
||
unit_found = "Mio"
|
||
elif "tsd" in original_lower or "tausend" in original_lower:
|
||
multiplier = 1000.0
|
||
unit_found = "Tsd"
|
||
|
||
num = num * multiplier
|
||
if unit_found:
|
||
logging.debug(f" -> Multiplikator '{unit_found}' ({multiplier}) angewendet, Ergebnis: {num}")
|
||
|
||
if is_umsatz:
|
||
umsatz_mio = round(num / 1000000.0)
|
||
logging.debug(f" -> Finaler Umsatz (Mio): {umsatz_mio}")
|
||
return str(int(umsatz_mio))
|
||
else:
|
||
mitarbeiter_int = round(num)
|
||
logging.debug(f" -> Finale Mitarbeiterzahl: {mitarbeiter_int}")
|
||
return str(int(mitarbeiter_int))
|
||
|
||
def get_gender(firstname):
|
||
"""Ermittelt Geschlecht via gender-guesser und Fallback Genderize API."""
|
||
if not firstname or not isinstance(firstname, str): return "unknown"
|
||
# Nimm nur den ersten Teil des Vornamens und bereinige ihn
|
||
firstname_clean = firstname.strip().split(" ")[0]
|
||
if not firstname_clean: return "unknown"
|
||
|
||
# 1. Versuch: gender-guesser
|
||
try:
|
||
d = gender.Detector(case_sensitive=False)
|
||
# Länderkennung kann helfen, ist aber nicht immer nötig/korrekt
|
||
result_gg = d.get_gender(firstname_clean) # Ohne Land versuchen? Oder 'germany'?
|
||
logging.debug(f"GenderGuesser für '{firstname_clean}': {result_gg}")
|
||
except Exception as e_gg:
|
||
logging.warning(f"Fehler bei gender-guesser für '{firstname_clean}': {e_gg}")
|
||
result_gg = "unknown" # Fallback bei Fehler
|
||
|
||
# 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:
|
||
logging.warning("Genderize API-Schlüssel nicht verfügbar, Fallback nicht möglich.")
|
||
# Gib das Ergebnis von gender-guesser zurück, wenn es "mostly_" war, sonst unknown
|
||
return result_gg if result_gg.startswith("mostly_") else "unknown"
|
||
|
||
params = {"name": firstname_clean, "apikey": genderize_key, "country_id": "DE"}
|
||
try:
|
||
logging.debug(f"Genderize API-Anfrage für '{firstname_clean}'...")
|
||
response = requests.get("https://api.genderize.io", params=params, timeout=5)
|
||
response.raise_for_status() # Fehler bei HTTP-Status != 200
|
||
data = response.json()
|
||
logging.debug(f" -> Genderize Antwort: {data}")
|
||
|
||
api_gender = data.get("gender")
|
||
probability = data.get("probability", 0)
|
||
# Nur bei hoher Sicherheit und wenn Genderize ein Ergebnis liefert
|
||
if api_gender and probability and probability > 0.7: # Schwelle ggf. anpassen
|
||
logging.debug(f" -> Übernehme Genderize Ergebnis '{api_gender}' (Prob: {probability})")
|
||
return api_gender
|
||
else:
|
||
# Wenn Genderize unsicher ist oder null liefert, nimm gender-guesser Ergebnis (falls mostly_)
|
||
logging.debug(f" -> Genderize unsicher/kein Ergebnis. Nutze Fallback: '{result_gg}'")
|
||
return result_gg if result_gg.startswith("mostly_") else "unknown"
|
||
|
||
except requests.exceptions.RequestException as e:
|
||
logging.error(f"Fehler bei der Genderize API-Anfrage für '{firstname_clean}': {e}")
|
||
# Fallback auf gender-guesser Ergebnis (falls mostly_)
|
||
return result_gg if result_gg.startswith("mostly_") else "unknown"
|
||
except Exception as e: # Z.B. JSONDecodeError
|
||
logging.error(f"Allgemeiner Fehler bei Genderize für '{firstname_clean}': {e}")
|
||
# Fallback auf gender-guesser Ergebnis (falls mostly_)
|
||
return result_gg if result_gg.startswith("mostly_") else "unknown"
|
||
else:
|
||
# Wenn gender-guesser sicher war (male, female), gib das Ergebnis direkt zurück
|
||
return result_gg
|
||
|
||
def get_email_address(firstname, lastname, website):
|
||
"""Generiert E-Mail: vorname.nachname@domain.tld."""
|
||
if not all([firstname, lastname, website]) or not all(isinstance(x, str) for x in [firstname, lastname, website]):
|
||
return ""
|
||
|
||
domain = simple_normalize_url(website)
|
||
if domain == "k.A." or not '.' in domain: # Einfache Domain-Validierung
|
||
return ""
|
||
|
||
# Domain von 'www.' befreien, falls simple_normalize_url es nicht schon getan hat
|
||
if domain.startswith("www."):
|
||
domain = domain[4:]
|
||
|
||
# Vor- und Nachname normalisieren (Umlaute etc.), Kleinbuchstaben, keine Sonderzeichen außer '.' und '-' erlauben
|
||
normalized_first = normalize_string(firstname.lower())
|
||
normalized_last = normalize_string(lastname.lower())
|
||
# Ersetze Leerzeichen und mehrere Bindestriche durch einen einzelnen Bindestrich
|
||
normalized_first = re.sub(r'\s+', '-', normalized_first)
|
||
normalized_last = re.sub(r'\s+', '-', normalized_last)
|
||
# Entferne alle Zeichen, die nicht alphanumerisch oder Bindestrich sind
|
||
normalized_first = re.sub(r'[^\w\-]+', '', normalized_first)
|
||
normalized_last = re.sub(r'[^\w\-]+', '', normalized_last)
|
||
|
||
if normalized_first and normalized_last and domain:
|
||
return f"{normalized_first}.{normalized_last}@{domain}"
|
||
else:
|
||
return ""
|
||
|
||
def fuzzy_similarity(str1, str2):
|
||
"""Berechnet Ähnlichkeit zwischen 0 und 1."""
|
||
if not str1 or not str2: return 0.0
|
||
return SequenceMatcher(None, str(str1).lower(), str(str2).lower()).ratio()
|
||
|
||
# ==================== BRANCH MAPPING & SCHEMA ====================
|
||
|
||
import re # Sicherstellen, dass re importiert ist
|
||
|
||
# Annahmen:
|
||
# - Die globalen Variablen ALLOWED_TARGET_BRANCHES und TARGET_SCHEMA_STRING werden
|
||
# durch load_target_schema() korrekt befüllt (enthalten nur Kurzformen).
|
||
# - Die Funktion call_openai_chat(prompt, temperature) existiert und funktioniert.
|
||
# - Die Funktion debug_print(message) existiert.
|
||
# - Die globale Variable Config.API_KEYS['openai'] ist verfügbar.
|
||
|
||
def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kategorien, website_summary):
|
||
"""
|
||
Ordnet das Unternehmen basierend auf den angegebenen Informationen exakt einer Branche
|
||
aus dem Ziel-Branchenschema (nur Kurzformen) zu. Validiert den ChatGPT-Vorschlag
|
||
strikt gegen die erlaubten Kurzformen und führt einen Fallback auf die (extrahierte)
|
||
CRM-Kurzform durch, falls der Vorschlag ungültig ist.
|
||
|
||
Args:
|
||
crm_branche (str): Branche laut CRM (kann noch Präfix enthalten).
|
||
beschreibung (str): Unternehmensbeschreibung (CRM).
|
||
wiki_branche (str): Branche aus Wikipedia (falls vorhanden).
|
||
wiki_kategorien (str): Wikipedia-Kategorien.
|
||
website_summary (str): Zusammenfassung des Website-Inhalts.
|
||
|
||
Returns:
|
||
dict: Enthält "branch" (die finale, gültige Kurzform oder Fehler),
|
||
"consistency" ('ok', 'X', 'fallback_crm_valid', 'fallback_invalid') und
|
||
"justification" (Begründung von ChatGPT oder Fallback-Info).
|
||
"""
|
||
global ALLOWED_TARGET_BRANCHES, TARGET_SCHEMA_STRING
|
||
|
||
# Grundlegende Prüfung: Ist das Schema überhaupt geladen?
|
||
if not ALLOWED_TARGET_BRANCHES:
|
||
# Kritischer Fehler, da Kernfunktion nicht möglich
|
||
logging.critical("FEHLER in evaluate_branche_chatgpt: Ziel-Branchenschema (ALLOWED_TARGET_BRANCHES) ist leer. Abbruch der Funktion.")
|
||
return {"branch": crm_branche, "consistency": "error_schema_missing", "justification": "Fehler: Ziel-Schema nicht geladen"}
|
||
|
||
# Erstelle Lookup für erlaubte Branches
|
||
allowed_branches_lookup = {b.lower(): b for b in ALLOWED_TARGET_BRANCHES}
|
||
|
||
# --- Prompt für ChatGPT erstellen ---
|
||
prompt_parts = [TARGET_SCHEMA_STRING]
|
||
prompt_parts.append("\nOrdne das Unternehmen anhand folgender Angaben exakt einer Branche des Ziel-Branchenschemas (Kurzformen) zu:")
|
||
|
||
# Sammle vorhandene Infos
|
||
info_count = 0
|
||
if crm_branche and crm_branche != "k.A.": prompt_parts.append(f"- CRM-Branche (Referenz): {crm_branche}"); info_count += 1
|
||
if beschreibung and beschreibung != "k.A.": prompt_parts.append(f"- Beschreibung: {beschreibung[:500]}..."); info_count += 1 # Kürzen
|
||
if wiki_branche and wiki_branche != "k.A.": prompt_parts.append(f"- Wikipedia-Branche: {wiki_branche[:300]}"); info_count += 1 # Kürzen
|
||
if wiki_kategorien and wiki_kategorien != "k.A.": prompt_parts.append(f"- Wikipedia-Kategorien: {wiki_kategorien[:500]}..."); info_count += 1 # Kürzen
|
||
if website_summary and website_summary != "k.A.": prompt_parts.append(f"- Website-Zusammenfassung: {website_summary[:500]}..."); info_count += 1 # Kürzen
|
||
|
||
# Fallback, wenn zu wenige Infos da sind
|
||
if info_count < 2: # Mindestens 2 Info-Punkte sollten vorhanden sein
|
||
logging.warning("Warnung in evaluate_branche_chatgpt: Zu wenige Informationen (<2) für Branchenevaluierung.")
|
||
# Gib ursprüngliche CRM-Branche zurück, aber markiere als Fehler
|
||
return {"branch": crm_branche, "consistency": "error_no_info", "justification": "Fehler: Zu wenige Informationen für eine Einschätzung"}
|
||
|
||
prompt_parts.append("\nWICHTIG: Antworte NUR mit dem exakten Kurznamen einer Branche aus der obigen Liste. Verwende KEINE Präfixe wie 'Hersteller / Produzenten >' oder 'Service provider (Dienstleister) >'.")
|
||
prompt_parts.append("\nAntworte ausschließlich im folgenden Format (keine Einleitung, kein Schlusssatz):")
|
||
prompt_parts.append("Branche: <Exakter Kurzname der Branche aus der Liste>")
|
||
prompt_parts.append("Übereinstimmung: <ok oder X (Vergleich deines Vorschlags mit der extrahierten Kurzform der CRM-Referenz)>")
|
||
prompt_parts.append("Begründung: <Sehr kurze Begründung für deinen Branchenvorschlag>")
|
||
|
||
prompt = "\n".join(prompt_parts)
|
||
logging.debug(f"Erstellter Prompt für Branchenevaluierung:\n---\n{prompt}\n---")
|
||
|
||
# --- ChatGPT aufrufen ---
|
||
chat_response = call_openai_chat(prompt, temperature=0.0) # Niedrige Temperatur
|
||
|
||
if not chat_response:
|
||
# Fehler loggen
|
||
logging.error("Fehler in evaluate_branche_chatgpt: Keine Antwort von OpenAI erhalten.")
|
||
return {"branch": crm_branche, "consistency": "error_api_no_response", "justification": "Fehler: Keine Antwort von API"}
|
||
|
||
logging.debug(f"OpenAI Antwort für Branchenevaluierung: {chat_response}")
|
||
|
||
# --- Antwort parsen ---
|
||
lines = chat_response.strip().split("\n")
|
||
result = {"branch": None, "consistency": None, "justification": ""}
|
||
suggested_branch = ""
|
||
parsed_branch = False
|
||
for line in lines:
|
||
line_lower = line.lower()
|
||
if line_lower.startswith("branche:"):
|
||
suggested_branch = line.split(":", 1)[1].strip().strip('"\'') # Trimme Leerzeichen und Anführungszeichen
|
||
parsed_branch = True
|
||
elif line_lower.startswith("begründung:"):
|
||
result["justification"] = line.split(":", 1)[1].strip()
|
||
# 'Übereinstimmung' wird ignoriert und später selbst berechnet
|
||
|
||
if not parsed_branch or not suggested_branch: # Prüfe, ob Branch geparst wurde UND nicht leer ist
|
||
logging.error(f"Fehler in evaluate_branche_chatgpt: Konnte 'Branche:' nicht oder nur leer aus Antwort parsen: {chat_response}")
|
||
return {"branch": crm_branche, "consistency": "error_parsing", "justification": f"Fehler: Parsing der API Antwort fehlgeschlagen. Antwort: {chat_response}"}
|
||
|
||
# --- Validierung des ChatGPT-Vorschlags ---
|
||
final_branch = None
|
||
suggested_branch_lower = suggested_branch.lower()
|
||
|
||
if suggested_branch_lower in allowed_branches_lookup:
|
||
final_branch = allowed_branches_lookup[suggested_branch_lower] # Nimm korrekte Schreibweise
|
||
logging.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist gültig ('{final_branch}').")
|
||
result["consistency"] = "pending_comparison" # Temporärer Status
|
||
else:
|
||
# --- Fallback-Logik ---
|
||
logging.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist NICHT im Ziel-Schema ({len(ALLOWED_TARGET_BRANCHES)} Einträge). Starte Fallback...")
|
||
|
||
# Versuche Kurzform aus CRM-Branche zu extrahieren
|
||
crm_short_branch = "k.A."
|
||
if crm_branche and ">" in crm_branche:
|
||
crm_short_branch = crm_branche.split(">", 1)[1].strip()
|
||
elif crm_branche and crm_branche != "k.A.":
|
||
crm_short_branch = crm_branche.strip()
|
||
|
||
logging.debug(f" Fallback: Prüfe extrahierte CRM-Kurzform: '{crm_short_branch}'")
|
||
crm_short_branch_lower = crm_short_branch.lower()
|
||
# Logge nur wenige Lookup-Keys zur Kontrolle
|
||
# lookup_keys_sample = list(allowed_branches_lookup.keys())[:5]
|
||
# logging.debug(f" -> Prüfe gegen Lookup-Keys (erste 5): {lookup_keys_sample}...")
|
||
|
||
if crm_short_branch != "k.A." and crm_short_branch_lower in allowed_branches_lookup:
|
||
final_branch = allowed_branches_lookup[crm_short_branch_lower]
|
||
result["consistency"] = "fallback_crm_valid"
|
||
fallback_reason = f"Fallback: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}'). Gültige CRM-Kurzform '{final_branch}' verwendet."
|
||
result["justification"] = f"{fallback_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})"
|
||
# Info statt Debug, da dies eine wichtige Entscheidung ist
|
||
logging.info(f"Fallback auf gültige CRM-Kurzform erfolgreich: '{final_branch}'")
|
||
else:
|
||
# Wenn auch CRM-Kurzform ungültig
|
||
final_branch = suggested_branch # Behalte ungültigen Vorschlag temporär
|
||
result["consistency"] = "fallback_invalid"
|
||
error_reason = f"Fehler: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}') und keine gültige CRM-Kurzform ('{crm_short_branch}') als Fallback verfügbar."
|
||
result["justification"] = f"{error_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})"
|
||
# Warnung, da keine gültige Branche gefunden wurde
|
||
logging.warning(f"Fallback fehlgeschlagen. Ungültiger Vorschlag: '{final_branch}', Ungültige CRM-Kurzform: '{crm_short_branch}'")
|
||
final_branch = "FEHLER - UNGÜLTIGE ZUWEISUNG" # Setze finalen Branch auf Fehler
|
||
|
||
# Setze den finalen Branch im Ergebnis-Dictionary
|
||
result["branch"] = final_branch
|
||
|
||
# --- Konsistenzprüfung (Finale Bewertung) ---
|
||
# Extrahiere CRM-Kurzform für den Vergleich erneut
|
||
crm_short_to_compare = "k.A."
|
||
if crm_branche and ">" in crm_branche:
|
||
crm_short_to_compare = crm_branche.split(">", 1)[1].strip()
|
||
elif crm_branche and crm_branche != "k.A.":
|
||
crm_short_to_compare = crm_branche.strip()
|
||
|
||
# Vergleiche finalen Branch (falls nicht FEHLER) mit CRM-Kurzform
|
||
if result["branch"] != "FEHLER - UNGÜLTIGE ZUWEISUNG":
|
||
if result["branch"].lower() == crm_short_to_compare.lower():
|
||
if result["consistency"] == "pending_comparison":
|
||
result["consistency"] = "ok"
|
||
# Wenn Fallback auf gültige CRM stattfand ('fallback_crm_valid'), bleibt dieser Status.
|
||
elif result["consistency"] == "pending_comparison":
|
||
# Wenn sie nicht übereinstimmen und kein Fallback stattfand, ist es 'X'.
|
||
result["consistency"] = "X"
|
||
# Wenn der Status bereits 'fallback_crm_valid' oder 'fallback_invalid' ist oder Branch ein Fehler ist, bleibt er.
|
||
|
||
# Entferne den temporären Status sicherheitshalber
|
||
if result["consistency"] == "pending_comparison":
|
||
logging.warning("Konsistenzprüfung blieb im Status 'pending_comparison', setze auf 'error_comparison_failed'.")
|
||
result["consistency"] = "error_comparison_failed"
|
||
elif result["consistency"] is None: # Sollte nicht passieren
|
||
logging.error("Konsistenz blieb unerwartet None, setze auf 'error_unknown_state'.")
|
||
result["consistency"] = "error_unknown_state"
|
||
|
||
# Debug-Ausgabe des finalen Ergebnisses
|
||
logging.debug(f"Finale Branch-Evaluation: {result}")
|
||
|
||
return result
|
||
|
||
def load_target_schema(csv_filepath=BRANCH_MAPPING_FILE):
|
||
"""Lädt Liste erlaubter Ziele (Kurzformen) aus Spalte A der CSV."""
|
||
global BRANCH_MAPPING, TARGET_SCHEMA_STRING, ALLOWED_TARGET_BRANCHES
|
||
# BRANCH_MAPPING wird nicht mehr benötigt, wenn wir nur die Ziele laden
|
||
BRANCH_MAPPING = {}
|
||
|
||
allowed_branches_set = set()
|
||
debug_print(f"Versuche, Ziel-Schema (Kurzformen) aus '{csv_filepath}' Spalte A zu laden...") # NEU
|
||
line_count = 0
|
||
try:
|
||
with open(csv_filepath, encoding="utf-8-sig") as f:
|
||
reader = csv.reader(f)
|
||
# Optional: Header überspringen
|
||
# next(reader, None)
|
||
for row in reader:
|
||
line_count += 1
|
||
if line_count <= 10 or line_count % 100 == 0:
|
||
debug_print(f"Schema-Laden: Lese Zeile {line_count}: {row}")
|
||
|
||
if len(row) >= 1: # Nur Spalte A (Index 0) wird benötigt
|
||
target = row[0].strip()
|
||
if target: # Nur nicht-leere Einträge hinzufügen
|
||
allowed_branches_set.add(target)
|
||
if line_count <= 10: # Logge die ersten 10 hinzugefügten
|
||
debug_print(f" -> '{target}' zum Set hinzugefügt.")
|
||
|
||
except FileNotFoundError:
|
||
debug_print(f"Fehler: Schema-Datei '{csv_filepath}' nicht gefunden.")
|
||
ALLOWED_TARGET_BRANCHES = []
|
||
except Exception as e:
|
||
debug_print(f"Fehler beim Laden des Ziel-Schemas aus '{csv_filepath}' (Zeile {line_count}): {e}")
|
||
ALLOWED_TARGET_BRANCHES = []
|
||
|
||
ALLOWED_TARGET_BRANCHES = sorted(list(allowed_branches_set), key=str.lower)
|
||
debug_print(f"Ziel-Schema geladen. {len(ALLOWED_TARGET_BRANCHES)} eindeutige Zielbranchen gefunden.") # NEU: Zählung der Branches
|
||
|
||
# Logge die ersten paar geladenen Branches zur Kontrolle
|
||
if ALLOWED_TARGET_BRANCHES:
|
||
debug_print(f"Erste 10 geladene Zielbranchen: {ALLOWED_TARGET_BRANCHES[:10]}")
|
||
schema_lines = ["Ziel-Branchenschema: Folgende Branchenbereiche sind gültig (Kurzformen):"] # Klarstellung
|
||
schema_lines.extend(f"- {branch}" for branch in ALLOWED_TARGET_BRANCHES)
|
||
schema_lines.append("Bitte ordne das Unternehmen ausschließlich in einen dieser Bereiche ein. Gib NUR den Kurznamen der Branche zurück (keine Präfixe wie 'Hersteller / Produzenten >').") # Strengere Anweisung
|
||
TARGET_SCHEMA_STRING = "\n".join(schema_lines)
|
||
else:
|
||
TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfügbar (Datei leer oder Fehler)."
|
||
ALLOWED_TARGET_BRANCHES = []
|
||
|
||
|
||
def map_external_branch(external_branch):
|
||
"""
|
||
Versucht, eine externe Branchenbezeichnung mithilfe des Mappings in das Ziel-Schema zu überführen.
|
||
Nutzt Normalisierung und Teilstring-Matching als Fallback.
|
||
"""
|
||
if not external_branch or not isinstance(external_branch, str) or not BRANCH_MAPPING:
|
||
return external_branch # Gib Original zurück, wenn kein Input oder kein Mapping
|
||
|
||
norm_external = normalize_string(external_branch).lower()
|
||
|
||
# 1. Exaktes Match (nach Normalisierung)
|
||
if norm_external in BRANCH_MAPPING:
|
||
return BRANCH_MAPPING[norm_external]
|
||
|
||
# 2. Teilstring-Match (prüfe, ob ein Mapping-Key im normalisierten Input enthalten ist)
|
||
# Sortiere Keys nach Länge (absteigend), um spezifischere Treffer zu bevorzugen
|
||
sorted_keys = sorted(BRANCH_MAPPING.keys(), key=len, reverse=True)
|
||
for key in sorted_keys:
|
||
if key in norm_external:
|
||
debug_print(f"Teilstring-Match für Branche: '{key}' in '{norm_external}' -> '{BRANCH_MAPPING[key]}'")
|
||
return BRANCH_MAPPING[key]
|
||
|
||
# 3. Kein Mapping gefunden
|
||
debug_print(f"Kein Mapping für externe Branche '{external_branch}' (normalisiert: '{norm_external}') gefunden.")
|
||
return external_branch # Gib Original zurück, wenn kein Mapping passt
|
||
|
||
|
||
# ==================== TOKEN COUNT FUNCTION ====================
|
||
@retry_on_failure
|
||
def token_count(text):
|
||
"""Zählt Tokens via tiktoken oder schätzt über Leerzeichen."""
|
||
if not text or not isinstance(text, str): return 0
|
||
if tiktoken:
|
||
try:
|
||
# Cache encoding object per model
|
||
if not hasattr(token_count, 'enc_cache'):
|
||
token_count.enc_cache = {}
|
||
if Config.TOKEN_MODEL not in token_count.enc_cache:
|
||
token_count.enc_cache[Config.TOKEN_MODEL] = tiktoken.encoding_for_model(Config.TOKEN_MODEL)
|
||
enc = token_count.enc_cache[Config.TOKEN_MODEL]
|
||
return len(enc.encode(text))
|
||
except Exception as e:
|
||
debug_print(f"Fehler beim Token-Counting mit tiktoken für Modell '{Config.TOKEN_MODEL}': {e}")
|
||
# Fallback zur Schätzung
|
||
return len(text.split())
|
||
else:
|
||
# Fallback Schätzung
|
||
return len(text.split())
|
||
|
||
# ==================== GOOGLE SHEET HANDLER ====================
|
||
# Annahmen:
|
||
# - Globale Variablen/Konstanten: retry_on_failure, Config, CREDENTIALS_FILE, Config.SHEET_URL, debug_print, COLUMN_MAP
|
||
# - COLUMN_MAP enthält den Schlüssel "Website Scrape Timestamp" mit dem korrekten Index (45)
|
||
|
||
class GoogleSheetHandler:
|
||
def __init__(self):
|
||
"""Initialisiert den Handler, verbindet und lädt initiale Daten."""
|
||
self.sheet = None
|
||
self.sheet_values = []
|
||
self.headers = [] # Speichert die erste Zeile als Header-Namen
|
||
try:
|
||
self._connect()
|
||
if self.sheet:
|
||
self.load_data() # Erste Datenladung bei Initialisierung
|
||
except Exception as e:
|
||
debug_print(f"FATAL: Fehler bei Initialisierung von GoogleSheetHandler: {e}")
|
||
# Wirft einen Fehler, damit das Hauptprogramm weiß, dass es nicht weitergehen kann
|
||
raise ConnectionError(f"Google Sheet Handler Init failed: {e}")
|
||
|
||
# retry_on_failure Decorator sollte hier angewendet werden
|
||
@retry_on_failure
|
||
def _connect(self):
|
||
"""Stellt Verbindung zum Google Sheet her."""
|
||
self.sheet = None # Sicherstellen, dass sheet vor try None ist
|
||
debug_print("Verbinde mit Google Sheets...")
|
||
try:
|
||
scope = ["https://www.googleapis.com/auth/spreadsheets"]
|
||
creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope)
|
||
gc = gspread.authorize(creds)
|
||
sh = gc.open_by_url(Config.SHEET_URL)
|
||
self.sheet = sh.sheet1 # Greift auf das erste Blatt zu
|
||
debug_print("Verbindung zu Google Sheets erfolgreich.")
|
||
except gspread.exceptions.APIError as e:
|
||
# Logge spezifische API-Fehler von Google
|
||
debug_print(f"FEHLER bei Google API Verbindung: Status {e.response.status_code} - {e.response.text[:200]}")
|
||
raise e # Fehler weitergeben, damit retry greift
|
||
except Exception as e:
|
||
# Logge andere Verbindungsfehler
|
||
debug_print(f"FEHLER bei der Google Sheets Verbindung: {type(e).__name__} - {e}")
|
||
raise e # Fehler weitergeben
|
||
|
||
# retry_on_failure Decorator sollte hier angewendet werden
|
||
@retry_on_failure
|
||
def load_data(self):
|
||
"""Lädt alle Daten aus dem Sheet und aktualisiert self.sheet_values und self.headers."""
|
||
if not self.sheet:
|
||
debug_print("Fehler: Keine Sheet-Verbindung zum Laden der Daten.")
|
||
self.sheet_values = []
|
||
self.headers = []
|
||
return False # Signalisiert Fehler
|
||
debug_print("Lade Daten aus Google Sheet...")
|
||
try:
|
||
self.sheet_values = self.sheet.get_all_values() # Daten neu holen
|
||
if not self.sheet_values:
|
||
debug_print("Warnung: Google Sheet scheint leer zu sein oder keine Daten zurückgegeben.")
|
||
self.headers = []
|
||
return True # Kein Fehler beim Laden, aber keine Daten
|
||
if len(self.sheet_values) >= 1:
|
||
self.headers = self.sheet_values[0] # Speichere die erste Zeile als Header
|
||
else:
|
||
self.headers = [] # Sollte nicht passieren, wenn sheet_values nicht leer war
|
||
|
||
debug_print(f"Daten neu geladen: {len(self.sheet_values)} Zeilen insgesamt.")
|
||
return True # Signalisiert Erfolg
|
||
except gspread.exceptions.APIError as e:
|
||
debug_print(f"Google API Fehler beim Laden der Sheet Daten: Status {e.response.status_code} - {e.response.text[:200]}")
|
||
raise e # Damit retry greift
|
||
except Exception as e:
|
||
debug_print(f"Allgemeiner Fehler beim Laden der Google Sheet Daten: {e}")
|
||
raise e # Damit retry greift
|
||
|
||
def get_data(self):
|
||
"""Gibt die aktuell im Handler gespeicherten Daten zurück (ohne die ersten 5 Header-Zeilen)."""
|
||
header_rows = 5 # Definiert die Anzahl der zu überspringenden Header-Zeilen
|
||
if not self.sheet_values or len(self.sheet_values) <= header_rows:
|
||
if self.sheet_values: # Logge nur, wenn Daten da, aber zu wenige
|
||
debug_print(f"Warnung in get_data: Nur {len(self.sheet_values)} Zeilen vorhanden, weniger als {header_rows} Header-Zeilen erwartet.")
|
||
return []
|
||
# Gibt eine Slice der Liste zurück, die die Datenzeilen enthält
|
||
return self.sheet_values[header_rows:]
|
||
|
||
def get_all_data_with_headers(self):
|
||
"""Gibt alle aktuell im Handler gespeicherten Daten inklusive Header zurück."""
|
||
if not self.sheet_values:
|
||
debug_print("Warnung in get_all_data_with_headers: Keine Daten im Handler gespeichert.")
|
||
return []
|
||
return self.sheet_values
|
||
|
||
def _get_col_letter(self, col_idx_1_based):
|
||
""" Konvertiert 1-basierten Spaltenindex in Buchstaben (A, B, ..., Z, AA, ...). """
|
||
string = ""
|
||
n = col_idx_1_based
|
||
if n < 1: return None # Ungültiger Index
|
||
while n > 0:
|
||
n, remainder = divmod(n - 1, 26)
|
||
string = chr(65 + remainder) + string
|
||
return string
|
||
|
||
# Angepasst: Sucht nur noch nach EXAKT LEER ("")
|
||
def get_start_row_index(self, check_column_key, min_sheet_row=7):
|
||
"""
|
||
Findet den Index der ersten Zeile (0-basiert für Daten nach Header),
|
||
ab einer Mindestzeilennummer im Sheet, in der der Wert in der
|
||
Spalte (definiert durch check_column_key) EXAKT LEER ("") ist.
|
||
Lädt die Daten vor der Prüfung neu.
|
||
|
||
Args:
|
||
check_column_key (str): Der Schlüssel in COLUMN_MAP für die zu prüfende Spalte.
|
||
min_sheet_row (int): Die 1-basierte Zeilennummer im Sheet, ab der gesucht werden soll.
|
||
|
||
Returns:
|
||
int: Der 0-basierte Index in der Datenliste (ohne Header),
|
||
oder -1 bei Fehler (z.B. Schlüssel nicht gefunden),
|
||
oder der Index nach der letzten Datenzeile, wenn alle gefüllt sind.
|
||
"""
|
||
if not self.load_data(): return -1 # Fehlerindikator
|
||
header_rows = 5
|
||
data_rows = self.get_data()
|
||
if not data_rows: return 0
|
||
|
||
check_column_index = COLUMN_MAP.get(check_column_key)
|
||
if check_column_index is None:
|
||
debug_print(f"FEHLER: Schlüssel '{check_column_key}' nicht in COLUMN_MAP gefunden!")
|
||
return -1
|
||
|
||
actual_col_letter = self._get_col_letter(check_column_index + 1)
|
||
search_start_index_in_data = max(0, min_sheet_row - header_rows - 1)
|
||
|
||
debug_print(f"get_start_row_index: Suche ab Daten-Index {search_start_index_in_data} nach EXAKT LEEREM Wert (=='') in Spalte '{check_column_key}' ({actual_col_letter})...")
|
||
|
||
if search_start_index_in_data >= len(data_rows):
|
||
debug_print(f"Start-Suchindex ({search_start_index_in_data}) >= Datenlänge ({len(data_rows)}).")
|
||
return len(data_rows)
|
||
|
||
for i in range(search_start_index_in_data, len(data_rows)):
|
||
row = data_rows[i]
|
||
current_sheet_row = i + header_rows + 1
|
||
cell_value = ""; is_exactly_empty = True
|
||
if len(row) > check_column_index:
|
||
cell_value = row[check_column_index]
|
||
if cell_value != "": is_exactly_empty = False
|
||
log_debug = (i == search_start_index_in_data or i % 1000 == 0 or is_exactly_empty or i in range(10110, 10116)) # Angepasste Log-Punkte
|
||
if log_debug: debug_print(f" -> Prüfe Daten-Index {i} (Sheet {current_sheet_row}): Wert in {actual_col_letter}='{cell_value}' (Typ: {type(cell_value)}). Ist exakt leer ('')? {is_exactly_empty}")
|
||
if is_exactly_empty:
|
||
debug_print(f"Erste Zeile ab {min_sheet_row} mit EXAKT LEEREM Wert in Spalte {actual_col_letter} gefunden: Zeile {current_sheet_row} (Daten-Index {i})")
|
||
return i
|
||
|
||
last_index = len(data_rows)
|
||
debug_print(f"Alle Zeilen ab Daten-Index {search_start_index_in_data} haben einen nicht-leeren Wert in Spalte {actual_col_letter}. Nächster Daten-Index wäre {last_index}.")
|
||
return last_index
|
||
|
||
# --- ÜBERARBEITETE METHODE mit besserem Error Handling ---
|
||
@retry_on_failure
|
||
def batch_update_cells(self, update_data):
|
||
"""
|
||
Führt ein Batch-Update im Google Sheet durch. Beinhaltet robustere
|
||
Fehlerbehandlung und gibt nur True bei echtem Erfolg zurück.
|
||
|
||
Args:
|
||
update_data (list): Eine Liste von Dictionaries, jedes mit 'range' und 'values'.
|
||
z.B. [{'range': 'A1', 'values': [['Wert']]}, ...]
|
||
|
||
Returns:
|
||
bool: True bei Erfolg, False bei Fehler nach Retries.
|
||
"""
|
||
if not self.sheet:
|
||
debug_print("FEHLER: Keine Sheet-Verbindung für Batch-Update.")
|
||
return False
|
||
if not update_data:
|
||
# debug_print("Keine Daten für Batch-Update vorhanden.") # Weniger Lärm
|
||
return True # Nichts zu tun ist technisch ein Erfolg
|
||
|
||
success = False # Standard: Nicht erfolgreich
|
||
try:
|
||
debug_print(f" -> Versuche sheet.batch_update mit {len(update_data)} Operationen...")
|
||
self.sheet.batch_update(update_data, value_input_option='USER_ENTERED')
|
||
# Wenn keine Exception aufgetreten ist, war es erfolgreich
|
||
success = True
|
||
# Logge Erfolg nicht mehr hier, sondern in der aufrufenden Funktion
|
||
# debug_print(f" -> sheet.batch_update erfolgreich abgeschlossen.")
|
||
|
||
except gspread.exceptions.APIError as e:
|
||
# Spezifische Fehler loggen
|
||
debug_print(f" -> FEHLER (Google API Error) beim Batch-Update: Status {e.response.status_code}")
|
||
# Logge die ersten 500 Zeichen der Fehlermeldung von Google
|
||
try:
|
||
error_details = e.response.json() # Versuche JSON zu parsen
|
||
debug_print(f" -> Details: {str(error_details)[:500]}")
|
||
except: # Falls die Antwort kein JSON ist
|
||
debug_print(f" -> Raw Response Text: {e.response.text[:500]}")
|
||
# WICHTIG: Fehler weitergeben, damit retry_on_failure greifen kann
|
||
raise e
|
||
|
||
except Exception as e:
|
||
# Andere Fehler loggen
|
||
debug_print(f" -> FEHLER (Allgemein) beim Batch-Update: {type(e).__name__} - {e}")
|
||
import traceback
|
||
debug_print(traceback.format_exc()) # Gib den vollen Traceback aus
|
||
# Fehler weitergeben, damit retry_on_failure greifen kann
|
||
raise e # Oder return False, wenn Retries nicht helfen sollen? Besser weitergeben.
|
||
|
||
# Gib den Erfolgsstatus zurück
|
||
return success
|
||
|
||
|
||
# --- Ende GoogleSheetHandler Klasse ---
|
||
|
||
|
||
|
||
# ==================== WIKIPEDIA SCRAPER ====================
|
||
class WikipediaScraper:
|
||
"""
|
||
Handles searching Wikipedia articles and extracting relevant company data.
|
||
Version: 1.6.6 logic - Improved infobox parsing, disambiguation handling,
|
||
dynamic article validation, and standard logging.
|
||
"""
|
||
def __init__(self, user_agent=None):
|
||
"""
|
||
Initialisiert den Scraper mit einer Requests-Session und Logger.
|
||
"""
|
||
self.logger = logging.getLogger(__name__)
|
||
self.logger.debug(f"Logger für WikipediaScraper ('{__name__}') initialisiert.")
|
||
|
||
self.user_agent = user_agent or getattr(Config, 'USER_AGENT', 'Mozilla/5.0 (compatible; Datenanreicherungsskript/1.0)')
|
||
self.session = requests.Session()
|
||
self.session.headers.update({'User-Agent': self.user_agent})
|
||
self.logger.debug(f"Requests Session mit User-Agent '{self.user_agent}' initialisiert.")
|
||
|
||
self.keywords_map = {
|
||
'branche': ['branche', 'wirtschaftszweig', 'industry', 'tätigkeit', 'sektor', 'produkte', 'leistungen'],
|
||
'umsatz': ['umsatz', 'erlös', 'revenue', 'jahresumsatz', 'konzernumsatz', 'ergebnis'],
|
||
'mitarbeiter': ['mitarbeiter', 'mitarbeiterzahl', 'beschäftigte', 'employees', 'number of employees', 'personal', 'belegschaft']
|
||
}
|
||
try:
|
||
wiki_lang = getattr(Config, 'LANG', 'de')
|
||
wikipedia.set_lang(wiki_lang)
|
||
wikipedia.set_rate_limiting(True)
|
||
self.logger.info(f"Wikipedia library language set to '{wiki_lang}'. Rate limiting enabled.")
|
||
except Exception as e:
|
||
self.logger.warning(f"Fehler beim Setzen der Wikipedia-Sprache oder Rate Limiting: {e}")
|
||
|
||
def _get_full_domain(self, website):
|
||
"""Extrahiert die normalisierte Domain (ohne www, ohne Pfad) aus einer URL."""
|
||
# ... (Implementierung bleibt wie zuvor) ...
|
||
if not website or not isinstance(website, str): return ""
|
||
website_lower = website.lower().strip()
|
||
if not website_lower or website_lower == 'k.a.': return ""
|
||
website_lower = re.sub(r'^https?:\/\/', '', website_lower)
|
||
if '@' in website_lower: website_lower = website_lower.split('@', 1)[1]
|
||
if website_lower.startswith('www.'): website_lower = website_lower[4:]
|
||
domain = website_lower.split('/')[0].split(':')[0]
|
||
return domain
|
||
|
||
def _generate_search_terms(self, company_name, website):
|
||
"""Generiert eine Liste von Suchbegriffen für die Wikipedia-Suche."""
|
||
# ... (Implementierung bleibt wie zuvor) ...
|
||
if not company_name: return []
|
||
terms = set()
|
||
full_domain = self._get_full_domain(website)
|
||
if full_domain: terms.add(full_domain)
|
||
# Annahme: normalize_company_name existiert
|
||
normalized_name = normalize_company_name(company_name)
|
||
if normalized_name:
|
||
name_parts = normalized_name.split()
|
||
if len(name_parts) > 0: terms.add(name_parts[0])
|
||
if len(name_parts) > 1: terms.add(" ".join(name_parts[:2]))
|
||
terms.add(normalized_name)
|
||
company_name_lower = company_name.lower()
|
||
if company_name_lower != normalized_name and company_name_lower not in terms:
|
||
terms.add(company_name_lower)
|
||
final_terms = [term for term in list(terms) if term][:5]
|
||
self.logger.debug(f"Generierte Suchbegriffe für '{company_name}': {final_terms}")
|
||
return final_terms
|
||
|
||
@retry_on_failure # Annahme: Decorator existiert
|
||
def _get_page_soup(self, url):
|
||
"""Holt HTML von einer URL und gibt ein BeautifulSoup-Objekt zurück."""
|
||
# ... (Implementierung bleibt wie zuvor) ...
|
||
if not url or not isinstance(url, str) or not url.startswith("http"):
|
||
self.logger.warning(f"_get_page_soup: Ungültige URL '{url}'.")
|
||
return None
|
||
try:
|
||
self.logger.debug(f"_get_page_soup: Rufe URL ab: {url}")
|
||
response = self.session.get(url, timeout=20)
|
||
response.raise_for_status()
|
||
response.encoding = 'utf-8'
|
||
# Annahme: Config ist verfügbar
|
||
soup = BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser'))
|
||
self.logger.debug(f"_get_page_soup: Parsen von {url} erfolgreich.")
|
||
return soup
|
||
except requests.exceptions.Timeout:
|
||
self.logger.error(f"_get_page_soup: Timeout beim Abrufen von {url}")
|
||
raise
|
||
except requests.exceptions.RequestException as e:
|
||
self.logger.error(f"_get_page_soup: Netzwerkfehler beim Abrufen von HTML von {url}: {e}")
|
||
raise e
|
||
except Exception as e:
|
||
self.logger.error(f"_get_page_soup: Fehler beim Parsen von HTML von {url}: {e}")
|
||
raise e
|
||
|
||
# --- ÜBERARBEITETE VALIDIERUNGSMETHODE ---
|
||
def _validate_article(self, page, company_name, website):
|
||
"""
|
||
Validiert, ob ein Wikipedia-Artikel zum Unternehmen passt.
|
||
Prüft Titelähnlichkeit (gewichtet Anfangsworte höher), Domain-Match
|
||
und passt Schwellenwerte dynamisch an.
|
||
"""
|
||
if not page or not company_name: return False
|
||
self.logger.debug(f"Validiere Artikel '{page.title}' für Firma '{company_name}' (Website: {website})...")
|
||
|
||
full_domain = self._get_full_domain(website)
|
||
# Annahme: normalize_company_name existiert
|
||
normalized_company = normalize_company_name(company_name)
|
||
normalized_title = normalize_company_name(page.title)
|
||
if not normalized_company or not normalized_title:
|
||
self.logger.warning("Validierung nicht möglich, da Normalisierung eines Namens fehlschlug.")
|
||
return False
|
||
|
||
# 1. Titelähnlichkeit (Gesamt)
|
||
similarity = SequenceMatcher(None, normalized_title, normalized_company).ratio()
|
||
self.logger.debug(f" -> Gesamt-Ähnlichkeit: {similarity:.2f} ('{normalized_title}' vs '{normalized_company}')")
|
||
|
||
# 2. Ähnlichkeit der ersten Worte
|
||
company_tokens = normalized_company.split()
|
||
title_tokens = normalized_title.split()
|
||
first_word_match = False
|
||
first_two_words_match = False
|
||
if len(company_tokens) > 0 and len(title_tokens) > 0:
|
||
if company_tokens[0] == title_tokens[0]:
|
||
first_word_match = True
|
||
self.logger.debug(" -> Erstes Wort stimmt überein.")
|
||
if len(company_tokens) > 1 and len(title_tokens) > 1:
|
||
if company_tokens[1] == title_tokens[1]:
|
||
first_two_words_match = True
|
||
self.logger.debug(" -> Erste zwei Worte stimmen überein.")
|
||
|
||
# 3. Link-Prüfung (Domain-Match)
|
||
domain_found = False
|
||
if full_domain:
|
||
self.logger.debug(f" -> Suche nach Domain '{full_domain}' in Links von {page.url}...")
|
||
soup = self._get_page_soup(page.url) # Erneuter Abruf für Link-Check
|
||
if soup:
|
||
infobox = soup.select_one('table[class*="infobox"]')
|
||
if infobox:
|
||
website_links = infobox.find_all('a', href=True)
|
||
for link in website_links:
|
||
href = link.get('href', '')
|
||
if href.startswith('http') and full_domain in self._get_full_domain(href):
|
||
link_text = link.get_text(strip=True).lower()
|
||
th = link.find_previous(['th', 'td']) # Prüfe vorheriges TH oder TD
|
||
th_text = th.get_text(strip=True).lower() if th else ""
|
||
if any(kw in link_text for kw in ['website', 'webseite', 'offizielle']) or \
|
||
any(kw in th_text for kw in ['website', 'webseite', 'webauftritt']):
|
||
self.logger.debug(f" -> Domain '{full_domain}' in Infobox-Link gefunden (Header/Text: '{th_text}/{link_text}', URL: {href})")
|
||
domain_found = True
|
||
break
|
||
else:
|
||
self.logger.debug(f" -> Domain '{full_domain}' in Infobox-Link gefunden (URL: {href}, kein Keyword-Match im Text/Header)")
|
||
domain_found = True
|
||
break
|
||
if not domain_found:
|
||
self.logger.debug(" -> Domain nicht in Infobox-Links gefunden, suche in allen externen Links...")
|
||
all_links = soup.find_all('a', href=True, class_=re.compile(r'.*\bexternal\b.*'))
|
||
if not all_links: all_links = soup.find_all('a', href=True)
|
||
for link in all_links:
|
||
href = link.get('href', '')
|
||
if href.startswith('http') and full_domain in self._get_full_domain(href):
|
||
if not any(site in href for site in ['wikipedia.org', 'wikimedia.org', 'wikidata.org', 'archive.org', 'webcitation.org']):
|
||
self.logger.debug(f" -> Domain '{full_domain}' in externem Link gefunden (URL: {href})")
|
||
domain_found = True
|
||
break
|
||
else:
|
||
self.logger.warning(f" -> Konnte HTML für Link-Prüfung von {page.url} nicht laden.")
|
||
|
||
if domain_found:
|
||
self.logger.debug(f" -> Domain-Check Ergebnis: Gefunden.")
|
||
else:
|
||
self.logger.debug(f" -> Domain-Check Ergebnis: NICHT gefunden.")
|
||
else:
|
||
self.logger.debug(" -> Keine Website-Domain für Link-Prüfung vorhanden.")
|
||
|
||
|
||
# 4. Dynamische Schwellenwert-Entscheidung
|
||
# Annahme: Config ist verfügbar
|
||
standard_threshold = getattr(Config, 'SIMILARITY_THRESHOLD', 0.65)
|
||
is_valid = False
|
||
reason = "Keine Validierungsregel traf zu" # Default Grund
|
||
|
||
# Regeln der Reihe nach prüfen
|
||
if similarity >= standard_threshold:
|
||
is_valid = True
|
||
reason = f"Gesamt-Ähnlichkeit >= {standard_threshold:.2f}"
|
||
elif domain_found and first_two_words_match and similarity >= 0.30: # Stärkste Kombination
|
||
is_valid = True
|
||
reason = f"Domain gefunden UND erste 2 Worte stimmen überein UND Ähnlichkeit >= 0.30"
|
||
elif domain_found and first_word_match and similarity >= 0.35: # Zweitstärkste
|
||
is_valid = True
|
||
reason = f"Domain gefunden UND erstes Wort stimmt überein UND Ähnlichkeit >= 0.35"
|
||
elif first_two_words_match and similarity >= 0.40: # Wenn nur erste zwei Worte passen
|
||
is_valid = True
|
||
reason = f"Erste zwei Worte stimmen überein UND Ähnlichkeit >= 0.40"
|
||
elif domain_found and similarity >= 0.45: # Wenn nur Domain passt (etwas höhere Anforderung als bei Wort-Match)
|
||
is_valid = True
|
||
reason = f"Domain gefunden UND Ähnlichkeit >= 0.45"
|
||
elif first_word_match and similarity >= 0.50: # Wenn nur erstes Wort passt (auch etwas höhere Anforderung)
|
||
is_valid = True
|
||
reason = f"Erstes Wort stimmt überein UND Ähnlichkeit >= 0.50"
|
||
|
||
|
||
log_level = logging.INFO if is_valid else logging.DEBUG
|
||
self.logger.log(log_level, f" => Artikel '{page.title}' {'VALIDIERT' if is_valid else 'NICHT validiert'} (Grund: {reason}. Details: Sim={similarity:.2f}, Domain? {domain_found}, 1stWord? {first_word_match}, 2ndWord? {first_two_words_match})")
|
||
return is_valid
|
||
# --- ENDE ÜBERARBEITETE VALIDIERUNG ---
|
||
|
||
def extract_categories(self, soup):
|
||
"""Extrahiert Wikipedia-Kategorien aus dem Soup-Objekt."""
|
||
# ... (Implementierung bleibt wie zuvor) ...
|
||
if not soup: return "k.A."
|
||
cats_filtered = []
|
||
try:
|
||
cat_div = soup.find('div', id="mw-normal-catlinks")
|
||
if cat_div:
|
||
ul = cat_div.find('ul')
|
||
if ul:
|
||
cats = [clean_text(li.get_text()) for li in ul.find_all('li')]
|
||
cats_filtered = [c for c in cats if c and "kategorien:" not in c.lower()]
|
||
self.logger.debug(f"Kategorien gefunden: {cats_filtered}")
|
||
else: self.logger.debug("Kein 'ul' in 'mw-normal-catlinks' gefunden.")
|
||
else: self.logger.debug("Kein 'div#mw-normal-catlinks' gefunden.")
|
||
except Exception as e:
|
||
self.logger.error(f"Fehler beim Extrahieren der Kategorien: {e}")
|
||
return ", ".join(cats_filtered) if cats_filtered else "k.A."
|
||
|
||
def _extract_first_paragraph_from_soup(self, soup):
|
||
"""Extrahiert den ersten aussagekräftigen Absatz aus dem Soup-Objekt."""
|
||
# ... (Implementierung bleibt wie zuvor) ...
|
||
if not soup: return "k.A."
|
||
paragraph_text = "k.A."
|
||
try:
|
||
content_div = soup.find('div', class_='mw-parser-output')
|
||
search_area = content_div if content_div else soup
|
||
paragraphs = search_area.find_all('p', recursive=False)
|
||
if not paragraphs: paragraphs = search_area.find_all('p', recursive=True)
|
||
self.logger.debug(f"Suche ersten Absatz in {len(paragraphs)} gefundenen <p>-Tags...")
|
||
for idx, p in enumerate(paragraphs):
|
||
if not p.get_text(strip=True):
|
||
self.logger.debug(f" -> Überspringe leeres <p> Tag (Index {idx})")
|
||
continue
|
||
if p.find(['img', 'table', 'figure', 'div'], recursive=False):
|
||
self.logger.debug(f" -> Überspringe <p> Tag (Index {idx}), da er Blockelemente enthält.")
|
||
continue
|
||
for sup in p.find_all('sup', class_='reference'): sup.decompose()
|
||
for span in p.find_all('span', id='coordinates'): span.decompose()
|
||
# Annahme: clean_text existiert
|
||
text = clean_text(p.get_text(separator=' ', strip=True))
|
||
if text and len(text) > 40:
|
||
self.logger.debug(f" -> Ersten gültigen Absatz (Index {idx}) gefunden: {text[:100]}...")
|
||
paragraph_text = text[:1000]
|
||
break
|
||
else:
|
||
self.logger.debug(f" -> Überspringe <p> Tag (Index {idx}), Text zu kurz oder leer nach clean_text: '{text[:50]}...'")
|
||
except Exception as e:
|
||
self.logger.error(f"Fehler beim Extrahieren des ersten Absatzes: {e}")
|
||
if paragraph_text == "k.A.":
|
||
self.logger.warning("Kein passender erster Absatz gefunden.")
|
||
return paragraph_text
|
||
|
||
def _extract_infobox_value(self, soup, target):
|
||
"""
|
||
Extrahiert gezielt Branche, Umsatz oder Mitarbeiter aus der Infobox.
|
||
Berücksichtigt Header in <th> oder fett formatierten <td>.
|
||
"""
|
||
# ... (Implementierung bleibt wie zuletzt bereitgestellt) ...
|
||
self.logger.debug(f"--- Entering _extract_infobox_value for target '{target}' ---")
|
||
if not soup or target not in self.keywords_map:
|
||
self.logger.debug(f"_extract_infobox_value: Ungültiger Input (Soup: {soup is not None}, Target: {target})")
|
||
return "k.A."
|
||
keywords = self.keywords_map[target]
|
||
self.logger.debug(f"_extract_infobox_value: Suche nach '{target}' mit Keywords: {keywords}")
|
||
infobox = soup.select_one('table[class*="infobox"]')
|
||
if not infobox:
|
||
self.logger.debug(" -> KEINE Infobox via select_one 'table[class*=\"infobox\"]' gefunden.")
|
||
return "k.A."
|
||
self.logger.debug(f" -> Infobox gefunden (via select_one 'table[class*=\"infobox\"]')")
|
||
value_found = "k.A."
|
||
try:
|
||
rows = infobox.find_all('tr')
|
||
self.logger.debug(f" -> Analysiere {len(rows)} Zeilen in der Infobox.")
|
||
for idx, row in enumerate(rows):
|
||
self.logger.debug(f" --- Prüfe Roh-HTML Zeile {idx}: {str(row)[:150]}...")
|
||
cells = row.find_all(['th', 'td'], recursive=False)
|
||
header_text = None
|
||
value_cell = None
|
||
if len(cells) == 2 and cells[0].name == 'th' and cells[1].name == 'td':
|
||
header_text = cells[0].get_text(strip=True)
|
||
value_cell = cells[1]
|
||
self.logger.debug(f" -> Zeile {idx}: Struktur TH + TD erkannt.")
|
||
elif len(cells) == 2 and cells[0].name == 'td' and cells[1].name == 'td':
|
||
first_cell_is_header_like = False
|
||
style = cells[0].get('style', '').lower()
|
||
if 'font-weight' in style and ('bold' in style or '700' in style or '800' in style or '900' in style):
|
||
first_cell_is_header_like = True
|
||
elif cells[0].find(['b', 'strong'], recursive=False):
|
||
first_cell_is_header_like = True
|
||
if first_cell_is_header_like:
|
||
header_text = cells[0].get_text(strip=True)
|
||
value_cell = cells[1]
|
||
self.logger.debug(f" -> Zeile {idx}: Struktur TD(Header-like) + TD erkannt.")
|
||
else:
|
||
self.logger.debug(f" -> Zeile {idx}: Struktur TD + TD, aber erstes TD nicht als Header erkannt.")
|
||
else:
|
||
self.logger.debug(f" -> Zeile {idx}: Übersprungen (Struktur passt nicht, Zellen: {len(cells)}, Typen: {[c.name for c in cells]})")
|
||
|
||
if header_text is not None and value_cell is not None:
|
||
self.logger.debug(f" -> Verarbeite Zeile {idx} mit Header='{header_text}'")
|
||
header_text_lower = header_text.lower()
|
||
matched_keyword = None
|
||
for kw in keywords:
|
||
if kw in header_text_lower:
|
||
matched_keyword = kw
|
||
break
|
||
if matched_keyword:
|
||
self.logger.debug(f" --> Keyword '{matched_keyword}' gefunden in Header '{header_text}'!")
|
||
for sup in value_cell.find_all(['sup', 'span']):
|
||
if (sup.name == 'sup' and sup.has_attr('class') and 'reference' in sup['class']) or \
|
||
(sup.name == 'span' and sup.get('style') and 'display:none' in sup['style']):
|
||
self.logger.debug(f" -> Entferne störendes Element: {sup.get_text(strip=True)}")
|
||
sup.decompose()
|
||
raw_value_text = value_cell.get_text(separator=' ', strip=True)
|
||
self.logger.debug(f" -> Roher TD/Value-Text nach Decompose: '{raw_value_text}'")
|
||
# Annahme: clean_text existiert
|
||
cleaned_raw_value = clean_text(raw_value_text)
|
||
if target == 'branche':
|
||
clean_val = re.sub(r'\s*\([^)]*\)', '', cleaned_raw_value).strip()
|
||
clean_val = clean_val.split('\n')[0].strip()
|
||
value_found = clean_val if clean_val else "k.A."
|
||
self.logger.info(f" --> Branche extrahiert: '{value_found}'")
|
||
elif target == 'umsatz':
|
||
# Annahme: extract_numeric_value existiert
|
||
numeric_val = extract_numeric_value(cleaned_raw_value, is_umsatz=True)
|
||
value_found = numeric_val
|
||
self.logger.info(f" --> Umsatz extrahiert (aus '{cleaned_raw_value}'): '{value_found}'")
|
||
elif target == 'mitarbeiter':
|
||
# Annahme: extract_numeric_value existiert
|
||
numeric_val = extract_numeric_value(cleaned_raw_value, is_umsatz=False)
|
||
value_found = numeric_val
|
||
self.logger.info(f" --> Mitarbeiter extrahiert (aus '{cleaned_raw_value}'): '{value_found}'")
|
||
break
|
||
if value_found != "k.A.":
|
||
self.logger.debug(f" -> Finaler Wert für '{target}' gefunden: '{value_found}'")
|
||
else:
|
||
self.logger.debug(f" -> Kein passender Eintrag für '{target}' in der gesamten Infobox gefunden.")
|
||
except Exception as e:
|
||
self.logger.exception(f"Fehler beim Durchlaufen der Infobox-Zeilen für '{target}': {e}")
|
||
return "k.A."
|
||
return value_found
|
||
|
||
def extract_company_data(self, page_url):
|
||
"""
|
||
Extrahiert Firmendaten von einer Wikipedia-URL.
|
||
"""
|
||
# ... (Implementierung bleibt wie zuvor, ruft Helfer auf) ...
|
||
default_result = {'url': page_url if page_url else 'k.A.', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}
|
||
if not page_url or not isinstance(page_url, str) or "wikipedia.org" not in page_url.lower():
|
||
self.logger.warning(f"extract_company_data: Ungültige URL '{page_url}'.")
|
||
return default_result
|
||
self.logger.info(f"Extrahiere Daten für Wiki-URL: {page_url}")
|
||
soup = self._get_page_soup(page_url)
|
||
if not soup:
|
||
self.logger.error(f" -> Fehler: Konnte Seite {page_url} nicht laden oder parsen.")
|
||
default_result['url'] = page_url
|
||
return default_result
|
||
self.logger.debug(" -> Extrahiere ersten Absatz...")
|
||
first_paragraph = self._extract_first_paragraph_from_soup(soup)
|
||
self.logger.debug(" -> Extrahiere Kategorien...")
|
||
categories_val = self.extract_categories(soup)
|
||
self.logger.debug(" -> Extrahiere Branche aus Infobox...")
|
||
branche_val = self._extract_infobox_value(soup, 'branche')
|
||
self.logger.debug(" -> Extrahiere Umsatz aus Infobox...")
|
||
umsatz_val = self._extract_infobox_value(soup, 'umsatz')
|
||
self.logger.debug(" -> Extrahiere Mitarbeiter aus Infobox...")
|
||
mitarbeiter_val = self._extract_infobox_value(soup, 'mitarbeiter')
|
||
result = {
|
||
'url': page_url, 'first_paragraph': first_paragraph, 'branche': branche_val,
|
||
'umsatz': umsatz_val, 'mitarbeiter': mitarbeiter_val, 'categories': categories_val
|
||
}
|
||
self.logger.info(f" -> Extrahierte Daten: P={first_paragraph[:30]}..., B='{branche_val}', U='{umsatz_val}', M='{mitarbeiter_val}', C={categories_val[:30]}...")
|
||
return result
|
||
|
||
@retry_on_failure # Annahme: Decorator existiert
|
||
def search_company_article(self, company_name, website=None):
|
||
"""
|
||
Sucht einen passenden Wikipedia-Artikel und gibt das page-Objekt zurück.
|
||
Behandelt explizit Begriffsklärungsseiten.
|
||
"""
|
||
# ... (Implementierung bleibt wie zuletzt bereitgestellt, inkl. check_page Helfer) ...
|
||
if not company_name:
|
||
self.logger.warning("Wikipedia search skipped: No company name provided.")
|
||
return None
|
||
search_terms = self._generate_search_terms(company_name, website)
|
||
if not search_terms:
|
||
self.logger.warning(f"Keine Suchbegriffe für '{company_name}' generiert.")
|
||
return None
|
||
self.logger.info(f"Starte Wikipedia-Suche für '{company_name}' (Website: {website}) mit Begriffen: {search_terms}")
|
||
processed_titles = set()
|
||
|
||
def check_page(title_to_check):
|
||
if title_to_check in processed_titles:
|
||
self.logger.debug(f" -> Titel '{title_to_check}' bereits geprüft, überspringe.")
|
||
return None
|
||
processed_titles.add(title_to_check)
|
||
try:
|
||
self.logger.debug(f" -> Prüfe potenziellen Artikel: '{title_to_check}'")
|
||
page = wikipedia.page(title_to_check, auto_suggest=False, preload=True)
|
||
# HIER wird die neue _validate_article aufgerufen
|
||
if self._validate_article(page, company_name, website):
|
||
return page
|
||
else:
|
||
self.logger.debug(f" -> Titel '{title_to_check}' nicht validiert.")
|
||
return None
|
||
except wikipedia.exceptions.PageError:
|
||
self.logger.debug(f" -> Seite '{title_to_check}' nicht gefunden (PageError).")
|
||
return None
|
||
except wikipedia.exceptions.DisambiguationError as e_inner:
|
||
self.logger.info(f" -> Begriffsklärung '{title_to_check}' gefunden. Prüfe Optionen...")
|
||
self.logger.debug(f" Optionen: {e_inner.options}")
|
||
best_option_page = None
|
||
for option in e_inner.options:
|
||
option_lower = option.lower()
|
||
is_company_candidate = False
|
||
if "(unternehmen)" in option_lower:
|
||
is_company_candidate = True
|
||
self.logger.debug(f" -> Option mit '(Unternehmen)' gefunden: '{option}'")
|
||
elif any(form in option_lower for form in [' gmbh', ' ag', ' kg', ' ltd', ' inc', ' corp', ' s.a.', ' se', ' group']):
|
||
is_company_candidate = True
|
||
self.logger.debug(f" -> Option mit Firmen-Keyword gefunden: '{option}'")
|
||
elif SequenceMatcher(None, normalize_company_name(option), normalize_company_name(company_name)).ratio() > 0.7:
|
||
is_company_candidate = True
|
||
self.logger.debug(f" -> Option mit hoher Namensähnlichkeit gefunden: '{option}'")
|
||
|
||
if is_company_candidate:
|
||
validated_option_page = check_page(option)
|
||
if validated_option_page:
|
||
self.logger.info(f" -> Option '{option}' erfolgreich validiert!")
|
||
if best_option_page is None:
|
||
best_option_page = validated_option_page
|
||
if best_option_page:
|
||
return best_option_page
|
||
else:
|
||
self.logger.warning(f" -> Keine passende/validierte Unternehmens-Option in Begriffsklärung '{title_to_check}' gefunden.")
|
||
return None
|
||
except requests.exceptions.RequestException as e_req:
|
||
self.logger.warning(f" -> Netzwerkfehler beim Laden/Validieren von '{title_to_check}': {e_req}. Überspringe Titel.")
|
||
time.sleep(1)
|
||
# Fehler hier nicht weitergeben, um Suche nicht abzubrechen
|
||
return None
|
||
except Exception as e_page:
|
||
self.logger.error(f" -> Fehler bei Verarbeitung von Titel '{title_to_check}': {type(e_page).__name__} - {e_page}")
|
||
return None
|
||
|
||
# --- Haupt-Suchlogik ---
|
||
self.logger.debug(f" -> Versuche direkten Match für '{company_name}'...")
|
||
validated_page = check_page(company_name)
|
||
if validated_page: return validated_page
|
||
|
||
self.logger.debug(f" -> Kein direkter Treffer/validiert. Starte Suche mit generierten Begriffen: {search_terms}")
|
||
for term in search_terms:
|
||
try:
|
||
self.logger.debug(f" -> Suche mit Begriff: '{term}'...")
|
||
# Annahme: Config ist verfügbar
|
||
search_results = wikipedia.search(term, results=getattr(Config, 'WIKIPEDIA_SEARCH_RESULTS', 5))
|
||
self.logger.debug(f" -> Suchergebnisse für '{term}': {search_results}")
|
||
if not search_results: continue
|
||
for title in search_results:
|
||
validated_page = check_page(title)
|
||
if validated_page: return validated_page
|
||
time.sleep(0.1) # Kleines Delay
|
||
except requests.exceptions.RequestException as e_search_req:
|
||
self.logger.error(f"Netzwerkfehler während Wikipedia-Suche für '{term}': {e_search_req}")
|
||
time.sleep(2)
|
||
raise e_search_req # Fehler weitergeben für Retry
|
||
except Exception as e_search:
|
||
self.logger.error(f"Allgemeiner Fehler während Wikipedia-Suche für '{term}': {e_search}")
|
||
continue # Nächsten Begriff versuchen
|
||
|
||
self.logger.warning(f"Kein passender & validierter Wikipedia-Artikel für '{company_name}' gefunden nach Prüfung aller Begriffe und Optionen.")
|
||
return None
|
||
|
||
|
||
# ==================== WEBSITE SCRAPING ====================
|
||
|
||
@retry_on_failure
|
||
def get_website_raw(url, max_length=1000, verify_cert=False):
|
||
"""Holt Textinhalt von einer Website, versucht Cookie-Banner zu umgehen."""
|
||
if not url or not isinstance(url, str) or url.strip().lower() == 'k.a.':
|
||
return "k.A."
|
||
|
||
if not url.lower().startswith("http"):
|
||
url = "https://" + url
|
||
|
||
headers = {
|
||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36"
|
||
}
|
||
|
||
try:
|
||
response = requests.get(url, timeout=15, headers=headers, verify=verify_cert) # Etwas längerer Timeout
|
||
response.raise_for_status()
|
||
response.encoding = response.apparent_encoding
|
||
soup = BeautifulSoup(response.text, Config.HTML_PARSER)
|
||
|
||
# --- Versuch 1: Hauptinhalt-Tags finden ---
|
||
content_area = (
|
||
soup.find('main') or # Suche <main>
|
||
soup.find('article') or # Oder <article>
|
||
soup.find(id='content') or # Oder <div id="content">
|
||
soup.find(id='main-content') or # Oder <div id="main-content">
|
||
soup.find(class_='main-content') or # Oder <div class="main-content">
|
||
soup.find(class_='content') # Oder <div class="content">
|
||
# Füge ggf. weitere spezifische Selektoren hinzu, die du oft siehst
|
||
)
|
||
|
||
if content_area:
|
||
debug_print(f"Gezielten Inhaltsbereich gefunden ({content_area.name}#{content_area.get('id')} oder .{content_area.get('class')}) für {url}")
|
||
else:
|
||
# --- Fallback: Body nehmen, ABER Banner versuchen zu entfernen ---
|
||
debug_print(f"Kein spezifischer Inhaltsbereich gefunden für {url}. Nutze Body und versuche Banner zu entfernen.")
|
||
content_area = soup.find('body')
|
||
if content_area:
|
||
# Versuche, häufige Cookie-Banner Strukturen zu entfernen
|
||
banner_selectors = [
|
||
'[id*="cookie"]', # IDs die "cookie" enthalten
|
||
'[class*="cookie"]', # Klassen die "cookie" enthalten
|
||
'[id*="consent"]', # IDs die "consent" enthalten
|
||
'[class*="consent"]', # Klassen die "consent" enthalten
|
||
'[id*="banner"]', # IDs die "banner" enthalten (vorsichtig!)
|
||
'[class*="banner"]', # Klassen die "banner" enthalten (vorsichtig!)
|
||
'[role="dialog"]' # Oft für Popups/Banner genutzt (vorsichtig!)
|
||
]
|
||
banners_removed_count = 0
|
||
for selector in banner_selectors:
|
||
try:
|
||
potential_banners = content_area.select(selector)
|
||
for banner in potential_banners:
|
||
# Zusätzliche Prüfung: Enthält das Element typischen Banner-Text?
|
||
banner_text = banner.get_text(" ", strip=True).lower()
|
||
keywords = ["cookie", "zustimm", "ablehnen", "einverstanden", "datenschutz", "privacy", "akzeptier"]
|
||
if any(keyword in banner_text for keyword in keywords):
|
||
debug_print(f"Entferne potenzielles Banner ({selector}) mit Text: {banner_text[:100]}...")
|
||
banner.decompose() # Entferne das Element aus dem Baum
|
||
banners_removed_count += 1
|
||
except Exception as e_select:
|
||
debug_print(f"Fehler beim Versuch Banner mit Selektor '{selector}' zu entfernen: {e_select}")
|
||
if banners_removed_count > 0:
|
||
debug_print(f"{banners_removed_count} potenzielle Banner-Elemente entfernt.")
|
||
|
||
# --- Text extrahieren aus gefundenem Bereich (oder Body) ---
|
||
if content_area:
|
||
for script_or_style in content_area(["script", "style"]): # Skripte/Styles entfernen
|
||
script_or_style.decompose()
|
||
|
||
text = content_area.get_text(separator=' ', strip=True)
|
||
text = re.sub(r'\s+', ' ', text) # Normalisiere Whitespace
|
||
|
||
# --- Zusätzliche Prüfung: Ist der extrahierte Text *nur* Banner-Text? ---
|
||
banner_keywords_strict = ["cookie", "zustimmen", "ablehnen", "einverstanden", "datenschutz", "privacy", "akzeptier", "einstellung", "partner", "analyse", "marketing"]
|
||
text_lower = text.lower()
|
||
keyword_hits = sum(1 for keyword in banner_keywords_strict if keyword in text_lower)
|
||
|
||
# Heuristik: Wenn der Text kurz ist UND viele Banner-Keywords enthält -> Verwerfen
|
||
if len(text) < 500 and keyword_hits >= 3:
|
||
debug_print(f"WARNUNG: Extrahierter Text für {url} scheint nur Cookie-Banner zu sein (Länge {len(text)}, {keyword_hits} Keywords). Verwerfe Text.")
|
||
return "k.A. (Nur Cookie-Banner erkannt)"
|
||
|
||
result = text[:max_length]
|
||
debug_print(f"Website {url} erfolgreich gescrapt. Extrahierter Text (Länge {len(result)}): {result[:100]}...")
|
||
return result
|
||
else:
|
||
debug_print(f"Kein <body> oder spezifischer Inhaltsbereich gefunden in {url}")
|
||
return "k.A."
|
||
except requests.exceptions.SSLError as e:
|
||
debug_print(f"SSL-Fehler beim Abrufen der Website {url}: {e}. Versuche ohne Zertifikatsprüfung...")
|
||
if verify_cert:
|
||
return get_website_raw(url, max_length, verify_cert=False)
|
||
else:
|
||
return "k.A."
|
||
except requests.exceptions.RequestException as e:
|
||
debug_print(f"Netzwerk-/HTTP-Fehler beim Abrufen der Website {url}: {e}")
|
||
return "k.A."
|
||
except Exception as e:
|
||
debug_print(f"Allgemeiner Fehler beim Scraping von {url}: {e}")
|
||
return "k.A."
|
||
|
||
# Die Hilfsfunktion summarize_batch_openai wird weiterhin benötigt
|
||
# (Code dafür bleibt wie in der Antwort von 16:24 Uhr)
|
||
@retry_on_failure
|
||
def summarize_batch_openai(tasks_data):
|
||
"""
|
||
Fasst eine Liste von Rohtexten in einem einzigen OpenAI API Call zusammen.
|
||
Die Prüfung auf das Token-Limit wird jetzt primär der API überlassen.
|
||
|
||
Args:
|
||
tasks_data (list): Eine Liste von Dictionaries, jedes enthält:
|
||
{'row_num': int, 'raw_text': str}
|
||
|
||
Returns:
|
||
dict: Ein Dictionary, das Zeilennummern auf ihre Zusammenfassungen mappt.
|
||
z.B. {2122: "Zusammenfassung A", 2123: "Zusammenfassung B"}
|
||
Bei Fehlern oder fehlenden Zusammenfassungen wird "k.A." verwendet.
|
||
"""
|
||
if not tasks_data: return {}
|
||
|
||
# Filtere Tasks, die gültigen Text haben
|
||
valid_tasks = [t for t in tasks_data if t.get("raw_text") and t["raw_text"] not in ["k.A.", "k.A. (Nur Cookie-Banner erkannt)", "k.A. (Fehler)"] and str(t.get("raw_text")).strip()]
|
||
if not valid_tasks:
|
||
debug_print("Keine gültigen Rohtexte für Batch-Zusammenfassung gefunden.")
|
||
return {t['row_num']: "k.A. (Kein gültiger Rohtext)" for t in tasks_data}
|
||
|
||
debug_print(f"Starte Batch-Zusammenfassung für {len(valid_tasks)} gültige Texte (Zeilen: {[t['row_num'] for t in valid_tasks]})...")
|
||
|
||
# --- Aggregierten Prompt erstellen ---
|
||
prompt_parts = [
|
||
"Du bist ein KI-Assistent...", # (Rest des Prompts wie gehabt)
|
||
"RESULTAT <Zeilennummer>: <Zusammenfassung für diese Zeilennummer>",
|
||
"\n--- Texte zur Zusammenfassung ---"
|
||
]
|
||
text_block = ""
|
||
row_numbers_in_batch = [] # Zeilen, die tatsächlich im Prompt landen
|
||
|
||
# Baue den Textblock ohne interne Längenprüfung zusammen
|
||
for task in valid_tasks:
|
||
row_num = task['row_num']
|
||
raw_text = task['raw_text']
|
||
# Kürzen sollte in get_website_raw passieren, aber zur Sicherheit:
|
||
raw_text = raw_text[:1500] # Limitiere jeden Text auf max 1500 Zeichen im Prompt
|
||
|
||
entry_text = f"\n--- TEXT Zeile {row_num} ---\n{raw_text}\n--- ENDE TEXT Zeile {row_num} ---\n"
|
||
text_block += entry_text
|
||
row_numbers_in_batch.append(row_num) # Füge die Zeilennummer hinzu
|
||
|
||
# --- Interne Längenprüfung ENTFERNT ---
|
||
# max_chars_per_batch = 15000 # Nicht mehr relevant für die Logik hier
|
||
# if total_chars + len(entry_text) > max_chars_per_batch: # ENTFERNT
|
||
# debug_print(f"WARNUNG: ...") # ENTFERNT
|
||
# continue # ENTFERNT
|
||
|
||
if not row_numbers_in_batch:
|
||
# Sollte nur passieren, wenn valid_tasks leer war
|
||
debug_print("Keine Texte im Batch für OpenAI.")
|
||
return {t['row_num']: "k.A. (Validierungsfehler?)" for t in tasks_data}
|
||
|
||
prompt_parts.append(text_block)
|
||
prompt_parts.append("--- Ende der Texte ---")
|
||
prompt_parts.append("Bitte gib NUR die 'RESULTAT <Zeilennummer>: ...' Zeilen zurück.")
|
||
final_prompt = "\n".join(prompt_parts)
|
||
|
||
# Optional: Token zählen zur Info, aber nicht zur Blockade
|
||
try:
|
||
prompt_tokens = token_count(final_prompt)
|
||
debug_print(f"Geschätzte Prompt-Tokens für Batch: {prompt_tokens} (Limit ca. 4096 für gpt-3.5-turbo)")
|
||
if prompt_tokens > 3500: # Nur eine Warnung
|
||
debug_print("WARNUNG: Geschätzte Prompt-Tokens hoch, API könnte Fehler werfen.")
|
||
except Exception as e_tc:
|
||
debug_print(f"Fehler beim Token-Zählen: {e_tc}")
|
||
|
||
|
||
# --- OpenAI API Call (Die API wirft Fehler bei Token-Limit) ---
|
||
chat_response = call_openai_chat(final_prompt, temperature=0.2)
|
||
|
||
# --- Antwort parsen (wie gehabt) ---
|
||
summaries = {row_num: "k.A. (Keine Antwort geparst)" for row_num in row_numbers_in_batch}
|
||
if chat_response:
|
||
# ... (Parsing-Logik bleibt gleich) ...
|
||
lines = chat_response.strip().split('\n'); parsed_count = 0
|
||
for line in lines:
|
||
match = re.match(r"RESULTAT (\d+): (.*)", line.strip())
|
||
if match:
|
||
row_num = int(match.group(1)); summary_text = match.group(2).strip()
|
||
if row_num in summaries: summaries[row_num] = summary_text; parsed_count += 1
|
||
debug_print(f"Batch-Zusammenfassung: {parsed_count} von {len(row_numbers_in_batch)} erfolgreich geparst.")
|
||
if parsed_count < len(row_numbers_in_batch): debug_print(f"WARNUNG: Nicht alle Zusammenfassungen geparst. Antwort: {chat_response[:500]}...")
|
||
else:
|
||
debug_print("Fehler: Keine gültige Antwort von OpenAI für Batch-Zusammenfassung erhalten.")
|
||
# Wenn der API Call fehlschlägt (z.B. Token Limit), ist chat_response None,
|
||
# alle summaries bleiben "k.A."
|
||
|
||
# Füge k.A. für Tasks hinzu, die ungültigen Rohtext hatten (aus valid_tasks gefiltert)
|
||
for task in tasks_data:
|
||
if task['row_num'] not in summaries:
|
||
summaries[task['row_num']] = "k.A. (Ungültiger Rohtext o.ä.)"
|
||
|
||
return summaries
|
||
|
||
|
||
# ==================== OPENAI / CHATGPT FUNCTIONS ====================
|
||
|
||
@retry_on_failure
|
||
def call_openai_chat(prompt, temperature=0.3, model=None):
|
||
"""Zentrale Funktion für OpenAI Chat API Aufrufe."""
|
||
if not Config.API_KEYS.get('openai'):
|
||
debug_print("Fehler: OpenAI API Key nicht konfiguriert.")
|
||
return None
|
||
if not prompt:
|
||
debug_print("Fehler: Leerer Prompt für OpenAI.")
|
||
return None
|
||
|
||
current_model = model if model else Config.TOKEN_MODEL
|
||
|
||
try:
|
||
# Token zählen vor dem Senden (optional, aber gut für Debugging)
|
||
# prompt_tokens = token_count(prompt)
|
||
# debug_print(f"Sende Prompt an OpenAI ({current_model}, {prompt_tokens} Tokens)...")
|
||
|
||
response = openai.ChatCompletion.create(
|
||
model=current_model,
|
||
messages=[{"role": "user", "content": prompt}],
|
||
temperature=temperature
|
||
)
|
||
result = response.choices[0].message.content.strip()
|
||
|
||
# Token zählen für die Antwort
|
||
# completion_tokens = token_count(result)
|
||
# total_tokens = response.usage.total_tokens
|
||
# debug_print(f"OpenAI Antwort erhalten ({completion_tokens} Completion Tokens, {total_tokens} Gesamt).")
|
||
|
||
return result
|
||
except openai.error.InvalidRequestError as e:
|
||
debug_print(f"OpenAI Invalid Request Error: {e}")
|
||
# Hier könnte man prüfen, ob es am Token Limit liegt
|
||
if "maximum context length" in str(e):
|
||
debug_print("Fehler scheint Token Limit zu sein. Prompt evtl. zu lang.")
|
||
# TODO: Strategie für zu lange Prompts (kürzen, splitten?)
|
||
return None
|
||
except openai.error.OpenAIError as e: # Fängt RateLimitError, APIError etc. ab
|
||
debug_print(f"OpenAI API Fehler: {e}")
|
||
raise # Fehler weitergeben, damit retry_on_failure greifen kann
|
||
except Exception as e:
|
||
debug_print(f"Allgemeiner Fehler bei OpenAI-Aufruf: {e}")
|
||
raise # Fehler weitergeben
|
||
|
||
def summarize_website_content(raw_text):
|
||
"""Erstellt Zusammenfassung von Website-Rohtext via OpenAI."""
|
||
if not raw_text or raw_text == "k.A." or raw_text.strip() == "":
|
||
return "k.A."
|
||
|
||
# Kürze den Rohtext, falls er sehr lang ist, um Token zu sparen/Limits zu vermeiden
|
||
max_raw_length = 3000 # Zeichenlimit für den Input der Zusammenfassung
|
||
if len(raw_text) > max_raw_length:
|
||
debug_print(f"Kürze Rohtext für Zusammenfassung von {len(raw_text)} auf {max_raw_length} Zeichen.")
|
||
raw_text = raw_text[:max_raw_length]
|
||
|
||
prompt = (
|
||
"Du bist ein KI-Assistent, der Webinhalte analysiert.\n"
|
||
"Fasse den folgenden Text einer Unternehmenswebsite prägnant zusammen. "
|
||
"Konzentriere dich auf:\n"
|
||
"- Haupttätigkeitsfeld des Unternehmens\n"
|
||
"- Wichtigste Produkte und/oder Dienstleistungen\n"
|
||
"- Zielgruppe (falls erkennbar)\n\n"
|
||
f"Website-Text:\n```\n{raw_text}\n```\n\n"
|
||
"Zusammenfassung (max. 100 Wörter):"
|
||
)
|
||
summary = call_openai_chat(prompt, temperature=0.2)
|
||
return summary if summary else "k.A."
|
||
|
||
|
||
def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kategorien, website_summary):
|
||
"""
|
||
Ordnet das Unternehmen basierend auf den angegebenen Informationen exakt einer Branche
|
||
aus dem Ziel-Branchenschema (nur Kurzformen) zu. Validiert den ChatGPT-Vorschlag
|
||
strikt gegen die erlaubten Kurzformen und führt einen Fallback auf die (extrahierte)
|
||
CRM-Kurzform durch, falls der Vorschlag ungültig ist.
|
||
|
||
Args:
|
||
crm_branche (str): Branche laut CRM (kann noch Präfix enthalten).
|
||
beschreibung (str): Unternehmensbeschreibung (CRM).
|
||
wiki_branche (str): Branche aus Wikipedia (falls vorhanden).
|
||
wiki_kategorien (str): Wikipedia-Kategorien.
|
||
website_summary (str): Zusammenfassung des Website-Inhalts.
|
||
|
||
Returns:
|
||
dict: Enthält "branch" (die finale, gültige Kurzform oder Fehler),
|
||
"consistency" ('ok', 'X', 'fallback_crm_valid', 'fallback_invalid') und
|
||
"justification" (Begründung von ChatGPT oder Fallback-Info).
|
||
"""
|
||
# Globale Variablen für Schema und erlaubte Branches verwenden
|
||
global ALLOWED_TARGET_BRANCHES, TARGET_SCHEMA_STRING
|
||
|
||
# Grundlegende Prüfung: Ist das Schema überhaupt geladen?
|
||
if not ALLOWED_TARGET_BRANCHES:
|
||
debug_print("FEHLER in evaluate_branche_chatgpt: Ziel-Branchenschema (ALLOWED_TARGET_BRANCHES) ist leer. Abbruch.")
|
||
# Gib den CRM-Wert zurück, aber markiere als Fehler
|
||
return {"branch": crm_branche, "consistency": "error_schema_missing", "justification": "Fehler: Ziel-Schema nicht geladen"}
|
||
|
||
# Erstelle ein Set/Dict der erlaubten Branches in Kleinbuchstaben für effizientes Nachschlagen
|
||
# Speichert die Originalschreibweise als Wert.
|
||
allowed_branches_lookup = {b.lower(): b for b in ALLOWED_TARGET_BRANCHES}
|
||
|
||
# --- Prompt für ChatGPT erstellen ---
|
||
# Beginne mit den Regeln und der Liste der gültigen Kurzformen
|
||
prompt_parts = [TARGET_SCHEMA_STRING] # TARGET_SCHEMA_STRING sollte bereits die klare Anweisung enthalten
|
||
prompt_parts.append("\nOrdne das Unternehmen anhand folgender Angaben exakt einer Branche des Ziel-Branchenschemas (Kurzformen) zu:")
|
||
|
||
# Füge nur vorhandene Informationen hinzu und kürze sie ggf.
|
||
if crm_branche and crm_branche != "k.A.": prompt_parts.append(f"- CRM-Branche (Referenz): {crm_branche}")
|
||
if beschreibung and beschreibung != "k.A.": prompt_parts.append(f"- Beschreibung: {beschreibung[:500]}") # Kürzen
|
||
if wiki_branche and wiki_branche != "k.A.": prompt_parts.append(f"- Wikipedia-Branche: {wiki_branche}")
|
||
if wiki_kategorien and wiki_kategorien != "k.A.": prompt_parts.append(f"- Wikipedia-Kategorien: {wiki_kategorien[:500]}") # Kürzen
|
||
if website_summary and website_summary != "k.A.": prompt_parts.append(f"- Website-Zusammenfassung: {website_summary[:500]}") # Kürzen
|
||
|
||
# Fallback, wenn gar keine spezifischen Infos da sind
|
||
if len(prompt_parts) <= 2:
|
||
debug_print("Warnung in evaluate_branche_chatgpt: Zu wenige Informationen für Branchenevaluierung.")
|
||
return {"branch": crm_branche, "consistency": "error_no_info", "justification": "Fehler: Zu wenige Informationen für eine Einschätzung"}
|
||
|
||
# Füge die strengen Anweisungen für das Antwortformat hinzu
|
||
prompt_parts.append("\nWICHTIG: Antworte NUR mit dem exakten Kurznamen einer Branche aus der obigen Liste. Verwende KEINE Präfixe wie 'Hersteller / Produzenten >' oder 'Service provider (Dienstleister) >'.")
|
||
prompt_parts.append("\nAntworte ausschließlich im folgenden Format (keine Einleitung, kein Schlusssatz):")
|
||
prompt_parts.append("Branche: <Exakter Kurzname der Branche aus der Liste>")
|
||
prompt_parts.append("Übereinstimmung: <ok oder X (Vergleich deines Vorschlags mit der extrahierten Kurzform der CRM-Referenz)>")
|
||
prompt_parts.append("Begründung: <Sehr kurze Begründung für deinen Branchenvorschlag>")
|
||
|
||
prompt = "\n".join(prompt_parts)
|
||
|
||
# --- ChatGPT aufrufen ---
|
||
chat_response = call_openai_chat(prompt, temperature=0.0) # Niedrige Temperatur für konsistente Zuordnung
|
||
|
||
if not chat_response:
|
||
debug_print("Fehler in evaluate_branche_chatgpt: Keine Antwort von OpenAI erhalten.")
|
||
return {"branch": crm_branche, "consistency": "error_api_no_response", "justification": "Fehler: Keine Antwort von API"}
|
||
|
||
# --- Antwort parsen ---
|
||
lines = chat_response.strip().split("\n")
|
||
result = {"branch": None, "consistency": None, "justification": ""} # Initialisiere mit None
|
||
suggested_branch = ""
|
||
for line in lines:
|
||
line_lower = line.lower()
|
||
if line_lower.startswith("branche:"):
|
||
suggested_branch = line.split(":", 1)[1].strip()
|
||
# Entferne mögliche Anführungszeichen
|
||
suggested_branch = suggested_branch.strip('"\'')
|
||
elif line_lower.startswith("übereinstimmung:"):
|
||
# Wir überschreiben die Konsistenz später basierend auf unserer Logik
|
||
pass
|
||
elif line_lower.startswith("begründung:"):
|
||
result["justification"] = line.split(":", 1)[1].strip()
|
||
|
||
if not suggested_branch:
|
||
debug_print(f"Fehler in evaluate_branche_chatgpt: Konnte 'Branche:' nicht aus Antwort parsen: {chat_response}")
|
||
# Optional: Versuche Begründung als Branche zu nehmen? Eher nicht.
|
||
return {"branch": crm_branche, "consistency": "error_parsing", "justification": f"Fehler: Parsing der API Antwort fehlgeschlagen. Antwort: {chat_response}"}
|
||
|
||
# --- Validierung des ChatGPT-Vorschlags ---
|
||
final_branch = None
|
||
suggested_branch_lower = suggested_branch.lower()
|
||
|
||
if suggested_branch_lower in allowed_branches_lookup:
|
||
final_branch = allowed_branches_lookup[suggested_branch_lower] # Nimm korrekte Schreibweise
|
||
debug_print(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist gültig ('{final_branch}').")
|
||
# Konsistenz wird später gesetzt
|
||
result["consistency"] = "pending_comparison" # Temporärer Status
|
||
else:
|
||
# --- Fallback-Logik ---
|
||
debug_print(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist NICHT im Ziel-Schema ({len(ALLOWED_TARGET_BRANCHES)} Einträge) enthalten. Starte Fallback...")
|
||
|
||
# Versuche Kurzform aus CRM-Branche zu extrahieren
|
||
crm_short_branch = "k.A."
|
||
if crm_branche and ">" in crm_branche:
|
||
crm_short_branch = crm_branche.split(">", 1)[1].strip()
|
||
elif crm_branche and crm_branche != "k.A.": # Wenn CRM schon Kurzform sein könnte
|
||
crm_short_branch = crm_branche.strip()
|
||
|
||
# Prüfe, ob die extrahierte CRM-Kurzform gültig ist
|
||
if crm_short_branch != "k.A." and crm_short_branch.lower() in allowed_branches_lookup:
|
||
final_branch = allowed_branches_lookup[crm_short_branch.lower()] # Nimm korrekte Schreibweise
|
||
result["consistency"] = "fallback_crm_valid" # Setze Fallback-Status
|
||
# Kombiniere ChatGPT Begründung (falls vorhanden) mit Fallback-Info
|
||
fallback_reason = f"Fallback: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}'). Gültige CRM-Kurzform '{final_branch}' verwendet."
|
||
result["justification"] = f"{fallback_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})"
|
||
debug_print(f"Fallback auf gültige CRM-Kurzform erfolgreich: '{final_branch}'")
|
||
else:
|
||
# Wenn auch CRM-Kurzform ungültig oder nicht extrahierbar
|
||
final_branch = suggested_branch # Behalte ungültigen Vorschlag
|
||
result["consistency"] = "fallback_invalid" # Setze Fehler-Fallback-Status
|
||
error_reason = f"Fehler: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}') und keine gültige CRM-Kurzform ('{crm_short_branch}') als Fallback verfügbar."
|
||
result["justification"] = f"{error_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})"
|
||
debug_print(f"Fallback fehlgeschlagen. Ungültiger Vorschlag: '{final_branch}', Ungültige CRM-Kurzform: '{crm_short_branch}'")
|
||
# Alternativ: Gib einen speziellen Fehlerwert zurück
|
||
# final_branch = "FEHLER - UNGÜLTIGE ZUWEISUNG"
|
||
|
||
# Setze den finalen Branch im Ergebnis-Dictionary
|
||
result["branch"] = final_branch if final_branch else "FEHLER"
|
||
|
||
# --- Konsistenzprüfung (Finale Bewertung) ---
|
||
# Extrahiere CRM-Kurzform für den Vergleich (erneut oder Variable von oben)
|
||
crm_short_to_compare = "k.A."
|
||
if crm_branche and ">" in crm_branche:
|
||
crm_short_to_compare = crm_branche.split(">", 1)[1].strip()
|
||
elif crm_branche and crm_branche != "k.A.":
|
||
crm_short_to_compare = crm_branche.strip()
|
||
|
||
# Vergleiche finalen Branch (falls nicht FEHLER) mit CRM-Kurzform (case-insensitive)
|
||
if result["branch"] != "FEHLER" and result["branch"].lower() == crm_short_to_compare.lower():
|
||
# Wenn sie übereinstimmen UND *kein* Fallback stattgefunden hat, ist es 'ok'.
|
||
if result["consistency"] == "pending_comparison":
|
||
result["consistency"] = "ok"
|
||
# Wenn Fallback auf gültige CRM stattfand (Status 'fallback_crm_valid'), bleibt dieser Status.
|
||
elif result["consistency"] == "pending_comparison":
|
||
# Wenn sie nicht übereinstimmen und kein Fallback stattfand, ist es 'X'.
|
||
result["consistency"] = "X"
|
||
# Wenn der Status bereits 'fallback_crm_valid' oder 'fallback_invalid' ist, bleibt er unverändert.
|
||
elif result["consistency"] is None: # Sollte nicht passieren, aber zur Sicherheit
|
||
result["consistency"] = "error_unknown_state"
|
||
|
||
|
||
# Entferne den temporären Status, falls er noch da ist
|
||
if result["consistency"] == "pending_comparison":
|
||
result["consistency"] = "error_comparison_failed"
|
||
|
||
# Debug-Ausgabe des finalen Ergebnisses vor Rückgabe
|
||
debug_print(f"Finale Branch-Evaluation: {result}")
|
||
|
||
return result
|
||
|
||
# TODO: Weitere ChatGPT-Funktionen (evaluate_fsm_suitability, etc.) analog überarbeiten:
|
||
# - Prompts verbessern (klarere Anweisungen, Kontext nur bei Bedarf)
|
||
# - call_openai_chat verwenden
|
||
# - Parsing der Antworten robuster machen
|
||
|
||
def process_wiki_verification(crm_data, wiki_data_str):
|
||
# Platzhalter - Implementierung anpassen oder entfernen, falls durch _process_batch abgedeckt
|
||
debug_print(f"TODO: process_wiki_verification aufrufen/implementieren für {crm_data}")
|
||
return "k.A. (Not Implemented)"
|
||
|
||
def evaluate_fsm_suitability(company_name, company_data):
|
||
# Platzhalter - Implementierung anpassen
|
||
debug_print(f"TODO: evaluate_fsm_suitability aufrufen/implementieren für {company_name}")
|
||
return {"suitability": "k.A.", "justification": "Not Implemented"}
|
||
|
||
def evaluate_servicetechnicians_estimate(company_name, company_data):
|
||
# Platzhalter - Implementierung anpassen
|
||
debug_print(f"TODO: evaluate_servicetechnicians_estimate aufrufen/implementieren für {company_name}")
|
||
return "k.A. (Not Implemented)"
|
||
|
||
def map_internal_technicians(value):
|
||
# Platzhalter - Implementierung anpassen
|
||
debug_print(f"TODO: map_internal_technicians aufrufen/implementieren für {value}")
|
||
return "k.A. (Not Implemented)"
|
||
|
||
def evaluate_servicetechnicians_explanation(company_name, st_estimate, company_data):
|
||
# Platzhalter - Implementierung anpassen
|
||
debug_print(f"TODO: evaluate_servicetechnicians_explanation aufrufen/implementieren für {company_name}")
|
||
return "k.A. (Not Implemented)"
|
||
|
||
def process_employee_estimation(company_name, wiki_paragraph, crm_employee):
|
||
# Platzhalter - Implementierung anpassen
|
||
debug_print(f"TODO: process_employee_estimation aufrufen/implementieren für {company_name}")
|
||
return "k.A. (Not Implemented)"
|
||
|
||
def process_employee_consistency(crm_employee, wiki_employee, emp_estimate):
|
||
# Platzhalter - Implementierung anpassen
|
||
debug_print(f"TODO: process_employee_consistency aufrufen/implementieren für {crm_employee} vs {wiki_employee} vs {emp_estimate}")
|
||
return "k.A. (Not Implemented)"
|
||
|
||
def evaluate_umsatz_chatgpt(company_name, wiki_umsatz):
|
||
# Platzhalter - Implementierung anpassen
|
||
debug_print(f"TODO: evaluate_umsatz_chatgpt aufrufen/implementieren für {company_name}")
|
||
return "k.A. (Not Implemented)"
|
||
|
||
|
||
# ==================== BATCH PROCESSING FUNCTIONS ====================
|
||
|
||
def _process_batch(sheet, batches, row_numbers):
|
||
"""
|
||
Hilfsfunktion für process_verification_only: Verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen.
|
||
Aktualisiert NUR die Spalten S bis Y. Zeitstempel (AN, AO, AP) werden von
|
||
der aufrufenden Funktion oder anderen Prozessen gesetzt.
|
||
|
||
Args:
|
||
sheet (gspread.Worksheet): Das Worksheet-Objekt zum Schreiben.
|
||
batches (list): Liste der Prompt-Teile für den Batch.
|
||
row_numbers (list): Liste der zugehörigen Zeilennummern.
|
||
"""
|
||
if not batches:
|
||
return
|
||
|
||
# --- Prompt Erstellung (wie gehabt) ---
|
||
aggregated_prompt = (
|
||
"Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln für Unternehmen. "
|
||
"Für jeden der folgenden Einträge prüfe, ob der vorhandene Wikipedia-Artikel (URL, Absatz, Kategorien) plausibel zum Firmennamen und zur Beschreibung passt. "
|
||
"Gib das Ergebnis für jeden Eintrag ausschließlich im folgenden Format auf einer neuen Zeile aus:\n"
|
||
"Eintrag <Zeilennummer>: <Antwort>\n\n"
|
||
"Mögliche Antworten:\n"
|
||
"- 'OK' (wenn der Artikel gut passt)\n"
|
||
"- 'X | Alternativer Artikel: <URL> | Begründung: <Kurze Begründung>' (wenn der Artikel nicht passt, aber ein besserer gefunden wurde)\n"
|
||
"- 'X | Kein passender Artikel gefunden | Begründung: <Kurze Begründung>' (wenn der Artikel nicht passt und kein besserer gefunden wurde)\n"
|
||
"- 'Kein Wikipedia-Eintrag vorhanden.' (wenn initial keine URL angegeben wurde und keine Suche erfolgreich war)\n\n"
|
||
"Einträge:\n"
|
||
"----------\n"
|
||
)
|
||
aggregated_prompt += "".join(batches) # Join ohne zusätzliches \n
|
||
aggregated_prompt += "----------\nBitte nur die 'Eintrag X: Antwort'-Zeilen ausgeben."
|
||
|
||
debug_print(f"Verarbeite Verifizierungs-Batch für Zeilen {row_numbers[0]} bis {row_numbers[-1]} (nur S-Y)...") # Hinweis angepasst
|
||
|
||
# Token Count für den Prompt
|
||
prompt_tokens = token_count(aggregated_prompt)
|
||
debug_print(f"Token-Zahl für Verifizierungs-Batch: {prompt_tokens}")
|
||
|
||
# --- ChatGPT Aufruf (wie gehabt) ---
|
||
chat_response = call_openai_chat(aggregated_prompt, temperature=0.0)
|
||
|
||
if not chat_response:
|
||
debug_print(f"Fehler: Keine Antwort von OpenAI für Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]}.")
|
||
return
|
||
|
||
# --- Antwort parsen (wie gehabt) ---
|
||
answers = {}
|
||
lines = chat_response.strip().split('\n')
|
||
for line in lines:
|
||
match = re.match(r"Eintrag (\d+): (.*)", line.strip())
|
||
if match:
|
||
row_num = int(match.group(1))
|
||
answer_text = match.group(2).strip()
|
||
if row_num in row_numbers:
|
||
answers[row_num] = answer_text
|
||
# else: # Weniger Lärm im Log
|
||
# debug_print(f"Warnung: Antwort für unerwartete Zeilennummer {row_num} im Batch erhalten: {answer_text}")
|
||
|
||
# --- Batch-Update vorbereiten (NUR S bis Y) ---
|
||
updates = []
|
||
# Timestamps und Version werden HIER NICHT mehr hinzugefügt
|
||
|
||
for row_num in row_numbers:
|
||
answer = answers.get(row_num, "k.A. (Keine Antwort im Batch)") # Fallback
|
||
|
||
# Variablen für die Spalten S-Y initialisieren
|
||
wiki_confirm = "" # Spalte S
|
||
alt_article = "" # Spalte T
|
||
wiki_explanation = "" # Spalte U
|
||
v_val, w_val, x_val, y_val = "", "", "", "" # Spalten V-Y
|
||
|
||
# Logik zur Bestimmung der Werte für S, T, U basierend auf 'answer' (wie gehabt)
|
||
if answer.upper() == "OK":
|
||
wiki_confirm = "OK"
|
||
elif answer.upper() == "KEIN WIKIPEDIA-EINTRAG VORHANDEN.":
|
||
wiki_confirm = "X"
|
||
alt_article = "Kein Wikipedia-Eintrag vorhanden."
|
||
wiki_explanation = "Ursprünglich keine URL oder Suche erfolglos."
|
||
elif answer.startswith("X |"):
|
||
parts = answer.split("|", 2)
|
||
wiki_confirm = "X"
|
||
if len(parts) > 1:
|
||
detail = parts[1].strip()
|
||
if detail.startswith("Alternativer Artikel:"):
|
||
alt_article = detail.split(":", 1)[1].strip()
|
||
elif detail == "Kein passender Artikel gefunden":
|
||
alt_article = detail
|
||
else:
|
||
alt_article = detail
|
||
if len(parts) > 2:
|
||
reason_part = parts[2].strip()
|
||
if reason_part.startswith("Begründung:"):
|
||
wiki_explanation = reason_part.split(":", 1)[1].strip()
|
||
else:
|
||
wiki_explanation = reason_part
|
||
else: # Unerwartetes Format
|
||
wiki_confirm = "?"
|
||
wiki_explanation = f"Unerwartetes Format: {answer}"
|
||
|
||
# Füge Updates NUR für S-Y zur Liste hinzu
|
||
updates.append({'range': f'S{row_num}', 'values': [[wiki_confirm]]})
|
||
updates.append({'range': f'T{row_num}', 'values': [[alt_article]]})
|
||
updates.append({'range': f'U{row_num}', 'values': [[wiki_explanation]]})
|
||
updates.append({'range': f'V{row_num}:Y{row_num}', 'values': [[v_val, w_val, x_val, y_val]]})
|
||
|
||
# --- Führe das Batch-Update für S-Y durch ---
|
||
if updates:
|
||
try:
|
||
# Verwende das übergebene sheet-Objekt direkt
|
||
sheet.batch_update(updates, value_input_option='USER_ENTERED')
|
||
debug_print(f"Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]} (S-Y) erfolgreich in Google Sheet aktualisiert.")
|
||
except Exception as e:
|
||
# Gib eine spezifischere Fehlermeldung aus
|
||
debug_print(f"FEHLER beim Batch-Update (S-Y) für Zeilen {row_numbers[0]}-{row_numbers[-1]}: {type(e).__name__} - {e}")
|
||
# Optional: Fehler weitergeben, wenn retry gewünscht wird (nicht empfohlen für Sheet-Updates hier)
|
||
# raise e
|
||
else:
|
||
debug_print(f"Keine Updates (S-Y) für Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]} generiert.")
|
||
|
||
# KEINE Pause hier mehr, wird in der aufrufenden Funktion gemacht
|
||
# time.sleep(Config.RETRY_DELAY)
|
||
|
||
|
||
def process_verification_only(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet):
|
||
"""
|
||
Batch-Prozess nur für Wikipedia-Verifizierung (Spalten S-Y).
|
||
Lädt Daten neu, prüft für jede Zeile im Bereich, ob Timestamp AX (Wiki Verif.)
|
||
bereits gesetzt ist, und überspringt diese ggf. Setzt AX für bearbeitete Zeilen.
|
||
AN wird hier *nicht* mehr gesetzt, das muss ggf. _process_single_row tun.
|
||
"""
|
||
debug_print(f"Starte Wikipedia-Verifizierungsmodus (Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...")
|
||
|
||
if not sheet_handler.load_data():
|
||
debug_print("FEHLER beim Laden der Daten in process_verification_only.")
|
||
return
|
||
all_data = sheet_handler.get_all_data_with_headers()
|
||
if not all_data or len(all_data) <= 5:
|
||
debug_print("FEHLER/WARNUNG: Keine Daten zum Verarbeiten in process_verification_only gefunden.")
|
||
return
|
||
|
||
# Hole Index für AX Timestamp (Wiki Verif.)
|
||
timestamp_col_key = "Wiki Verif. Timestamp" # NEUER SCHLÜSSEL
|
||
timestamp_col_index = COLUMN_MAP.get(timestamp_col_key)
|
||
ts_col_letter = sheet_handler._get_col_letter(timestamp_col_index + 1) if timestamp_col_index is not None else "AX_FEHLER"
|
||
|
||
if timestamp_col_index is None:
|
||
debug_print(f"FEHLER: Spaltenschlüssel '{timestamp_col_key}' nicht in COLUMN_MAP gefunden. Breche Wiki-Verifizierung ab.")
|
||
return
|
||
|
||
batch_size = Config.BATCH_SIZE
|
||
current_batch = []
|
||
current_row_numbers = []
|
||
processed_count = 0
|
||
skipped_count = 0
|
||
|
||
for i in range(start_row_index_in_sheet, end_row_index_in_sheet + 1):
|
||
row_index_in_list = i - 1
|
||
if row_index_in_list >= len(all_data): continue
|
||
row = all_data[row_index_in_list]
|
||
|
||
# --- Timestamp-Prüfung für jede Zeile (AX) ---
|
||
ts_value_ax = "INDEX_FEHLER"
|
||
ts_ax_is_set = False
|
||
if len(row) > timestamp_col_index:
|
||
ts_value_ax = row[timestamp_col_index]
|
||
ts_ax_is_set = bool(str(ts_value_ax).strip())
|
||
|
||
log_debug = (i < start_row_index_in_sheet + 5 or i > end_row_index_in_sheet - 5 or i % 500 == 0 or i in range(2122, 2132))
|
||
if log_debug:
|
||
debug_print(f"Zeile {i} (Wiki Verif. Check): Prüfe Timestamp {ts_col_letter} (Index {timestamp_col_index}). Rohwert='{ts_value_ax}', Strip='{str(ts_value_ax).strip()}', Überspringen? -> {ts_ax_is_set}")
|
||
|
||
if ts_ax_is_set:
|
||
skipped_count += 1
|
||
continue
|
||
# --- Ende Timestamp-Prüfung ---
|
||
|
||
# Nur wenn AX leer ist, wird die Zeile für den Batch vorbereitet
|
||
company_name = row[COLUMN_MAP.get("CRM Name", 1)] if len(row) > COLUMN_MAP.get("CRM Name", 1) else ''
|
||
crm_desc = row[COLUMN_MAP.get("CRM Beschreibung", 5)] if len(row) > COLUMN_MAP.get("CRM Beschreibung", 5) else ''
|
||
wiki_url_idx = COLUMN_MAP.get("Wiki URL")
|
||
wiki_url = row[wiki_url_idx] if wiki_url_idx is not None and len(row) > wiki_url_idx and row[wiki_url_idx].strip() not in ['', 'k.A.'] else 'k.A.'
|
||
wiki_para_idx = COLUMN_MAP.get("Wiki Absatz")
|
||
wiki_paragraph = row[wiki_para_idx] if wiki_para_idx is not None and len(row) > wiki_para_idx else 'k.A.'
|
||
wiki_cat_idx = COLUMN_MAP.get("Wiki Kategorien")
|
||
wiki_categories = row[wiki_cat_idx] if wiki_cat_idx is not None and len(row) > wiki_cat_idx else 'k.A.'
|
||
|
||
entry_text = (
|
||
f"Eintrag {i}:\n"
|
||
f" Firmenname: {company_name}\n"
|
||
f" CRM-Beschreibung: {crm_desc[:200]}...\n"
|
||
f" Wikipedia-URL: {wiki_url}\n"
|
||
f" Wiki-Absatz: {wiki_paragraph[:200]}...\n"
|
||
f" Wiki-Kategorien: {wiki_categories[:200]}...\n"
|
||
f"----\n"
|
||
)
|
||
current_batch.append(entry_text)
|
||
current_row_numbers.append(i)
|
||
processed_count += 1
|
||
|
||
if len(current_batch) >= batch_size or i == end_row_index_in_sheet:
|
||
if current_batch:
|
||
# Rufe _process_batch auf (schreibt S-Y)
|
||
_process_batch(sheet_handler.sheet, current_batch, current_row_numbers)
|
||
|
||
# Setze den AX Timestamp für die bearbeiteten Zeilen
|
||
wiki_ts_updates = []
|
||
current_wiki_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
for row_num in current_row_numbers:
|
||
wiki_ts_updates.append({'range': f'{ts_col_letter}{row_num}', 'values': [[current_wiki_timestamp]]})
|
||
|
||
if wiki_ts_updates:
|
||
success_ts = sheet_handler.batch_update_cells(wiki_ts_updates)
|
||
if success_ts:
|
||
debug_print(f"Wiki Verif. Timestamp {ts_col_letter} für Batch {current_row_numbers[0]}-{current_row_numbers[-1]} gesetzt.")
|
||
else:
|
||
debug_print(f"FEHLER beim Setzen des Wiki Verif. Timestamps {ts_col_letter} für Batch.")
|
||
|
||
time.sleep(Config.RETRY_DELAY)
|
||
|
||
current_batch = []
|
||
current_row_numbers = []
|
||
|
||
debug_print(f"Wikipedia-Verifizierungs-Batch abgeschlossen. {processed_count} Zeilen zur Verarbeitung weitergegeben, {skipped_count} Zeilen übersprungen.")
|
||
|
||
|
||
# Anpassung in _process_batch: Setzt jetzt *nicht* mehr AO/AP, sondern nur S-Y
|
||
def _process_batch(sheet, batches, row_numbers):
|
||
"""
|
||
Hilfsfunktion für process_verification_only: Verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen.
|
||
Aktualisiert NUR die Spalten S bis Y. Zeitstempel werden von der aufrufenden Funktion gesetzt.
|
||
"""
|
||
if not batches: return
|
||
# (Prompt Erstellung wie gehabt)
|
||
aggregated_prompt = (
|
||
"Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln für Unternehmen. "
|
||
"Für jeden der folgenden Einträge prüfe, ob der vorhandene Wikipedia-Artikel (URL, Absatz, Kategorien) plausibel zum Firmennamen und zur Beschreibung passt. "
|
||
"Gib das Ergebnis für jeden Eintrag ausschließlich im folgenden Format auf einer neuen Zeile aus:\n"
|
||
"Eintrag <Zeilennummer>: <Antwort>\n\n"
|
||
"Mögliche Antworten:\n"
|
||
"- 'OK' (wenn der Artikel gut passt)\n"
|
||
"- 'X | Alternativer Artikel: <URL> | Begründung: <Kurze Begründung>' (wenn der Artikel nicht passt, aber ein besserer gefunden wurde)\n"
|
||
"- 'X | Kein passender Artikel gefunden | Begründung: <Kurze Begründung>' (wenn der Artikel nicht passt und kein besserer gefunden wurde)\n"
|
||
"- 'Kein Wikipedia-Eintrag vorhanden.' (wenn initial keine URL angegeben wurde und keine Suche erfolgreich war - dieser Fall sollte selten sein, da die Suche vorher stattfindet)\n\n"
|
||
"Einträge:\n"
|
||
"----------\n"
|
||
)
|
||
aggregated_prompt += "".join(batches)
|
||
aggregated_prompt += "----------\nBitte nur die 'Eintrag X: Antwort'-Zeilen ausgeben."
|
||
|
||
debug_print(f"Verarbeite Verifizierungs-Batch für Zeilen {row_numbers[0]} bis {row_numbers[-1]}.")
|
||
prompt_tokens = token_count(aggregated_prompt)
|
||
debug_print(f"Token-Zahl für Verifizierungs-Batch: {prompt_tokens}")
|
||
|
||
chat_response = call_openai_chat(aggregated_prompt, temperature=0.0)
|
||
|
||
if not chat_response:
|
||
debug_print(f"Fehler: Keine Antwort von OpenAI für Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]}.")
|
||
return
|
||
|
||
# Parse die aggregierte Antwort (wie gehabt)
|
||
answers = {}
|
||
lines = chat_response.strip().split('\n')
|
||
for line in lines:
|
||
match = re.match(r"Eintrag (\d+): (.*)", line.strip())
|
||
if match:
|
||
row_num = int(match.group(1))
|
||
answer_text = match.group(2).strip()
|
||
if row_num in row_numbers: answers[row_num] = answer_text
|
||
|
||
# Bereite Batch-Update nur für Spalten S-Y vor
|
||
updates = []
|
||
# current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Nicht mehr hier
|
||
# current_version = Config.VERSION # Nicht mehr hier
|
||
|
||
for row_num in row_numbers:
|
||
answer = answers.get(row_num, "k.A. (Keine Antwort im Batch)")
|
||
# debug_print(f"Zeile {row_num} Verifizierungsantwort: '{answer}'") # Optional weniger Lärm
|
||
|
||
wiki_confirm, alt_article, wiki_explanation = "", "", ""
|
||
v_val, w_val, x_val, y_val = "", "", "", ""
|
||
|
||
if answer.upper() == "OK": wiki_confirm = "OK"
|
||
elif answer.upper() == "KEIN WIKIPEDIA-EINTRAG VORHANDEN.":
|
||
wiki_confirm, alt_article, wiki_explanation = "X", "Kein Wikipedia-Eintrag vorhanden.", "Ursprünglich keine URL oder Suche erfolglos."
|
||
elif answer.startswith("X |"):
|
||
parts = answer.split("|", 2)
|
||
wiki_confirm = "X"
|
||
if len(parts) > 1:
|
||
detail = parts[1].strip()
|
||
if detail.startswith("Alternativer Artikel:"): alt_article = detail.split(":", 1)[1].strip()
|
||
elif detail == "Kein passender Artikel gefunden": alt_article = detail
|
||
else: alt_article = detail
|
||
if len(parts) > 2:
|
||
reason_part = parts[2].strip()
|
||
if reason_part.startswith("Begründung:"): wiki_explanation = reason_part.split(":", 1)[1].strip()
|
||
else: wiki_explanation = reason_part
|
||
else:
|
||
wiki_confirm, wiki_explanation = "?", f"Unerwartetes Format: {answer}"
|
||
|
||
# Füge Updates für S-Y hinzu
|
||
updates.append({'range': f'S{row_num}', 'values': [[wiki_confirm]]})
|
||
updates.append({'range': f'T{row_num}', 'values': [[alt_article]]})
|
||
updates.append({'range': f'U{row_num}', 'values': [[wiki_explanation]]})
|
||
updates.append({'range': f'V{row_num}:Y{row_num}', 'values': [[v_val, w_val, x_val, y_val]]})
|
||
|
||
# Führe das Batch-Update für S-Y durch
|
||
if updates:
|
||
# Direkten Sheet-Zugriff nutzen, da sheet übergeben wird
|
||
try:
|
||
sheet.batch_update(updates)
|
||
debug_print(f"Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]} (S-Y) erfolgreich in Google Sheet aktualisiert.")
|
||
except Exception as e:
|
||
debug_print(f"FEHLER beim Batch-Update (S-Y) für Batch {row_numbers[0]}-{row_numbers[-1]}: {e}")
|
||
else:
|
||
debug_print(f"Keine Updates (S-Y) für Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]} generiert.")
|
||
|
||
# Kurze Pause nach jedem Batch-API-Call (jetzt in der aufrufenden Funktion)
|
||
# time.sleep(Config.RETRY_DELAY) # Entfernt
|
||
|
||
# Komplette Funktion process_website_batch (prüft jetzt Timestamp AT mit erzwungenem Debugging)
|
||
# Komplette Funktion process_website_batch (MIT Batched Google Sheet Updates)
|
||
# Komplette Funktion process_website_batch (NEUE STRUKTUR - ECHTER BATCH WORKFLOW)
|
||
# Komplette Funktion process_website_batch (NUR SCRAPING)
|
||
# Komplette Funktion process_website_batch (Korrigierte Config-Referenzen)
|
||
def process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet):
|
||
"""
|
||
Batch-Prozess NUR für Website-Scraping (Rohtext AR).
|
||
Lädt Daten neu, prüft Spalte AR auf Inhalt ('', 'k.A.', etc.) und überspringt Zeilen mit Inhalt.
|
||
Setzt AR + AP für bearbeitete Zeilen. Sendet Updates gebündelt.
|
||
"""
|
||
debug_print(f"Starte Website-Scraping NUR ROHDATEN (Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...")
|
||
|
||
# --- Lade Daten ---
|
||
if not sheet_handler.load_data(): return
|
||
all_data = sheet_handler.get_all_data_with_headers()
|
||
if not all_data or len(all_data) <= 5: return
|
||
header_rows = 5
|
||
|
||
# --- Indizes und Buchstaben ---
|
||
rohtext_col_key = "Website Rohtext"
|
||
rohtext_col_index = COLUMN_MAP.get(rohtext_col_key)
|
||
website_col_idx = COLUMN_MAP.get("CRM Website")
|
||
version_col_idx = COLUMN_MAP.get("Version")
|
||
if None in [rohtext_col_index, website_col_idx, version_col_idx]:
|
||
debug_print(f"FEHLER: Benötigte Indizes für process_website_batch fehlen.")
|
||
return
|
||
rohtext_col_letter = sheet_handler._get_col_letter(rohtext_col_index + 1)
|
||
version_col_letter = sheet_handler._get_col_letter(version_col_idx + 1)
|
||
|
||
# --- Worker-Funktion für Scraping (unverändert) ---
|
||
def scrape_raw_text_task(task_info):
|
||
row_num = task_info['row_num']; url = task_info['url']; raw_text = "k.A."; error = None
|
||
try: raw_text = get_website_raw(url) # Annahme: get_website_raw ist definiert
|
||
except Exception as e: error = f"Scraping Fehler Zeile {row_num}: {e}"; debug_print(error)
|
||
return {"row_num": row_num, "raw_text": raw_text, "error": error}
|
||
|
||
# --- Hauptlogik: Iteriere und sammle Batches ---
|
||
tasks_for_processing_batch = []
|
||
all_sheet_updates = []
|
||
total_processed_count = 0
|
||
total_skipped_count = 0
|
||
total_skipped_url_count = 0
|
||
total_error_count = 0
|
||
|
||
# Werte aus Config holen
|
||
processing_batch_size = Config.PROCESSING_BATCH_SIZE
|
||
max_scraping_workers = Config.MAX_SCRAPING_WORKERS
|
||
update_batch_row_limit = Config.UPDATE_BATCH_ROW_LIMIT
|
||
empty_values_for_skip = ["", "k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]
|
||
|
||
for i in range(start_row_index_in_sheet, end_row_index_in_sheet + 1):
|
||
row_index_in_list = i - 1
|
||
if row_index_in_list >= len(all_data): continue
|
||
row = all_data[row_index_in_list]
|
||
|
||
# --- Prüfung, ob AR schon Inhalt hat ---
|
||
should_skip = False
|
||
cell_value_ar_str_lower = "INDEX_FEHLER"
|
||
if len(row) > rohtext_col_index:
|
||
cell_value_ar_str_lower = str(row[rohtext_col_index]).strip().lower()
|
||
if cell_value_ar_str_lower not in empty_values_for_skip:
|
||
should_skip = True
|
||
|
||
log_debug = (i < start_row_index_in_sheet + 5 or i > end_row_index_in_sheet - 5 or i % 500 == 0)
|
||
if log_debug:
|
||
debug_print(f"Zeile {i} (Website AR Check): Prüfe Inhalt Spalte {rohtext_col_letter}. Wert='{cell_value_ar_str_lower}'. Überspringen (da schon Inhalt)? -> {should_skip}")
|
||
|
||
if should_skip:
|
||
total_skipped_count += 1
|
||
continue
|
||
# --- Ende AR Prüfung ---
|
||
|
||
# URL Prüfung
|
||
website_url = row[website_col_idx] if len(row) > website_col_idx else ""
|
||
if not website_url or website_url.strip().lower() == "k.a.":
|
||
total_skipped_url_count += 1
|
||
continue
|
||
|
||
tasks_for_processing_batch.append({"row_num": i, "url": website_url})
|
||
|
||
# --- Verarbeitungs-Batch ausführen ---
|
||
if len(tasks_for_processing_batch) >= processing_batch_size or i == end_row_index_in_sheet:
|
||
if tasks_for_processing_batch:
|
||
batch_start_row = tasks_for_processing_batch[0]['row_num']
|
||
batch_end_row = tasks_for_processing_batch[-1]['row_num']
|
||
batch_task_count = len(tasks_for_processing_batch)
|
||
debug_print(f"\n--- Starte Scraping-Batch ({batch_task_count} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---")
|
||
|
||
scraping_results = {}
|
||
batch_error_count = 0 # Fehlerzähler für diesen spezifischen Batch
|
||
debug_print(f" Scrape {batch_task_count} Websites parallel (max {max_scraping_workers} worker)...")
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_scraping_workers) as executor:
|
||
future_to_task = {executor.submit(scrape_raw_text_task, task): task for task in tasks_for_processing_batch}
|
||
for future in concurrent.futures.as_completed(future_to_task):
|
||
task = future_to_task[future]
|
||
# --- KORRIGIERTER TRY-EXCEPT Block ---
|
||
try:
|
||
result = future.result()
|
||
scraping_results[result['row_num']] = result['raw_text']
|
||
if result['error']:
|
||
batch_error_count += 1
|
||
total_error_count += 1
|
||
except Exception as exc:
|
||
row_num = task['row_num']
|
||
err_msg = f"Generischer Fehler Scraping Task Zeile {row_num}: {exc}"
|
||
debug_print(err_msg)
|
||
scraping_results[row_num] = "k.A. (Fehler)"
|
||
batch_error_count += 1
|
||
total_error_count +=1
|
||
# --- Ende Korrektur ---
|
||
|
||
current_batch_processed_count = len(scraping_results) # Anzahl Ergebnisse (inkl. Fehler)
|
||
total_processed_count += current_batch_processed_count
|
||
debug_print(f" Scraping für Batch beendet. {current_batch_processed_count} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).")
|
||
|
||
# Sheet Updates vorbereiten (AR und AP)
|
||
if scraping_results:
|
||
current_version = Config.VERSION
|
||
batch_sheet_updates = []
|
||
for row_num, raw_text_res in scraping_results.items():
|
||
# Updates für AR und AP
|
||
row_updates = [
|
||
{'range': f'{rohtext_col_letter}{row_num}', 'values': [[raw_text_res]]},
|
||
{'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}
|
||
]
|
||
batch_sheet_updates.extend(row_updates)
|
||
all_sheet_updates.extend(batch_sheet_updates) # Sammle für größeren Batch-Update
|
||
|
||
tasks_for_processing_batch = [] # Batch leeren
|
||
|
||
# Sheet Updates senden (wenn update_batch_row_limit erreicht)
|
||
# Prüfe die Anzahl der *Zeilen*, für die Updates gesammelt wurden
|
||
# Da wir jetzt Updates für alle Ergebnisse sammeln, prüfen wir direkt die Länge von all_sheet_updates
|
||
if len(all_sheet_updates) >= update_batch_row_limit * 2: # *2 weil 2 Updates pro Zeile
|
||
debug_print(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...")
|
||
success = sheet_handler.batch_update_cells(all_sheet_updates)
|
||
if success: debug_print(f" Sheet-Update bis Zeile {batch_end_row} erfolgreich.") # Logge Endzeile des Batches
|
||
else: debug_print(f" FEHLER beim Sheet-Update bis Zeile {batch_end_row}.")
|
||
all_sheet_updates = [] # Zurücksetzen nach Senden
|
||
|
||
# Finale Sheet Updates senden
|
||
if all_sheet_updates:
|
||
debug_print(f"Sende finale Sheet-Updates ({len(all_sheet_updates)} Zellen)...")
|
||
sheet_handler.batch_update_cells(all_sheet_updates)
|
||
|
||
debug_print(f"Website-Scraping NUR ROHDATEN abgeschlossen. {total_processed_count} Websites verarbeitet (inkl. Fehler), {total_error_count} Fehler, {total_skipped_count} Zeilen wg. Inhalt übersprungen, {total_skipped_url_count} Zeilen ohne URL übersprungen.")
|
||
|
||
|
||
|
||
# NEUE Funktion process_website_summarization_batch
|
||
def process_website_summarization_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet):
|
||
"""
|
||
Batch-Prozess NUR für Website-Zusammenfassung (AS).
|
||
Lädt Daten neu, prüft, ob Rohtext (AR) vorhanden und Zusammenfassung (AS) fehlt.
|
||
Fasst Rohtexte im Batch via OpenAI zusammen und setzt AS + AP.
|
||
"""
|
||
debug_print(f"Starte Website-Zusammenfassung (OpenAI Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...")
|
||
|
||
# --- Konfiguration ---
|
||
openai_batch_size = Config.OPENAI_BATCH_SIZE_LIMIT # Holt Wert aus Config (jetzt z.B. 1)
|
||
update_batch_row_limit = Config.UPDATE_BATCH_ROW_LIMIT # z.B. 50
|
||
|
||
# --- Lade Daten ---
|
||
if not sheet_handler.load_data(): return
|
||
all_data = sheet_handler.get_all_data_with_headers()
|
||
if not all_data or len(all_data) <= 5: return
|
||
header_rows = 5
|
||
|
||
# --- Indizes und Buchstaben ---
|
||
rohtext_col_idx = COLUMN_MAP.get("Website Rohtext")
|
||
summary_col_idx = COLUMN_MAP.get("Website Zusammenfassung")
|
||
version_col_idx = COLUMN_MAP.get("Version")
|
||
if None in [rohtext_col_idx, summary_col_idx, version_col_idx]: return debug_print(f"FEHLER: Indizes fehlen.")
|
||
summary_col_letter = sheet_handler._get_col_letter(summary_col_idx + 1)
|
||
version_col_letter = sheet_handler._get_col_letter(version_col_idx + 1)
|
||
|
||
# --- Verarbeitung ---
|
||
tasks_for_openai_batch = []
|
||
all_sheet_updates = []
|
||
rows_in_current_update_batch = 0
|
||
processed_count = 0
|
||
skipped_no_rohtext = 0
|
||
skipped_summary_exists = 0
|
||
|
||
for i in range(start_row_index_in_sheet, end_row_index_in_sheet + 1):
|
||
row_index_in_list = i - 1
|
||
if row_index_in_list >= len(all_data): continue
|
||
row = all_data[row_index_in_list]
|
||
|
||
# Prüfung 1: Ist Rohtext vorhanden und gültig?
|
||
raw_text = ""
|
||
if len(row) > rohtext_col_idx: raw_text = str(row[rohtext_col_idx]).strip()
|
||
if not raw_text or raw_text == "k.A." or raw_text == "k.A. (Nur Cookie-Banner erkannt)" or raw_text == "k.A. (Fehler)":
|
||
skipped_no_rohtext += 1; continue
|
||
|
||
# Prüfung 2: Fehlt die Zusammenfassung (AS)?
|
||
summary_exists = False
|
||
if len(row) > summary_col_idx and str(row[summary_col_idx]).strip() and str(row[summary_col_idx]).strip() != "k.A.":
|
||
summary_exists = True
|
||
if summary_exists: skipped_summary_exists += 1; continue
|
||
|
||
# Task hinzufügen
|
||
tasks_for_openai_batch.append({'row_num': i, 'raw_text': raw_text})
|
||
processed_count += 1
|
||
|
||
# --- OpenAI Batch verarbeiten, wenn voll oder letzte Zeile ---
|
||
if tasks_for_openai_batch and \
|
||
(len(tasks_for_openai_batch) >= openai_batch_size or (processed_count > 0 and i == end_row_index_in_sheet)):
|
||
debug_print(f" Verarbeite OpenAI Batch für {len(tasks_for_openai_batch)} Aufgaben (Start: {tasks_for_openai_batch[0]['row_num']})...")
|
||
summaries_result = summarize_batch_openai(tasks_for_openai_batch) # Ruft modifizierte Funktion auf
|
||
|
||
# Sheet Updates für diesen OpenAI Batch vorbereiten
|
||
current_version = Config.VERSION
|
||
for task in tasks_for_openai_batch: # Iteriere über die *gesendeten* Tasks
|
||
row_num = task['row_num']
|
||
summary = summaries_result.get(row_num, "k.A. (Fehler Batch Zuordnung)")
|
||
row_updates = [
|
||
{'range': f'{summary_col_letter}{row_num}', 'values': [[summary]]},
|
||
{'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}
|
||
]
|
||
all_sheet_updates.extend(row_updates)
|
||
rows_in_current_update_batch += 1
|
||
|
||
tasks_for_openai_batch = [] # OpenAI Batch leeren
|
||
|
||
# --- Gesammelte Sheet Updates senden ---
|
||
if all_sheet_updates and \
|
||
(rows_in_current_update_batch >= update_batch_row_limit or (processed_count > 0 and i == end_row_index_in_sheet)):
|
||
debug_print(f" Sende Sheet-Update für {rows_in_current_update_batch} Zusammenfassungen...")
|
||
success = sheet_handler.batch_update_cells(all_sheet_updates)
|
||
if success: debug_print(f" Sheet-Update bis Zeile {i} erfolgreich.")
|
||
else: debug_print(f" FEHLER beim Sheet-Update bis Zeile {i}.")
|
||
all_sheet_updates = []; rows_in_current_update_batch = 0
|
||
|
||
# Letzten Sheet Update Batch senden
|
||
if all_sheet_updates:
|
||
debug_print(f"Sende LETZTES Sheet-Update für {rows_in_current_update_batch} Zusammenfassungen...")
|
||
sheet_handler.batch_update_cells(all_sheet_updates)
|
||
|
||
debug_print(f"Website-Zusammenfassungs-Batch abgeschlossen. {processed_count} Zusammenfassungen angefordert, {skipped_no_rohtext} wg. fehlendem Rohtext übersprungen, {skipped_summary_exists} wg. vorhandener Zusammenfassung übersprungen.")
|
||
|
||
# Komplette Funktion process_branch_batch (prüft jetzt Timestamp AO mit erzwungenem Debugging)
|
||
# Komplette Funktion process_branch_batch (MIT Korrektur und Prüfung auf AO)
|
||
def process_branch_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet):
|
||
"""
|
||
Batch-Prozess für Brancheneinschätzung mit paralleler Verarbeitung via Threads.
|
||
Prüft Timestamp AO, führt evaluate_branche_chatgpt parallel aus (limitiert),
|
||
setzt W, X, Y, AO + AP und sendet Sheet-Updates GEBÜNDELT PRO VERARBEITUNGS-BATCH.
|
||
"""
|
||
debug_print(f"Starte Brancheneinschätzung (Parallel Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...")
|
||
|
||
if not sheet_handler.load_data(): return
|
||
all_data = sheet_handler.get_all_data_with_headers()
|
||
if not all_data or len(all_data) <= 5: return
|
||
header_rows = 5
|
||
|
||
# --- Indizes und Buchstaben ---
|
||
timestamp_col_key = "Timestamp letzte Prüfung"; timestamp_col_index = COLUMN_MAP.get(timestamp_col_key)
|
||
branche_crm_idx = COLUMN_MAP.get("CRM Branche"); beschreibung_idx = COLUMN_MAP.get("CRM Beschreibung")
|
||
branche_wiki_idx = COLUMN_MAP.get("Wiki Branche"); kategorien_wiki_idx = COLUMN_MAP.get("Wiki Kategorien")
|
||
summary_web_idx = COLUMN_MAP.get("Website Zusammenfassung"); version_col_idx = COLUMN_MAP.get("Version")
|
||
branch_w_idx = COLUMN_MAP.get("Chat Vorschlag Branche"); branch_x_idx = COLUMN_MAP.get("Chat Konsistenz Branche")
|
||
branch_y_idx = COLUMN_MAP.get("Chat Begründung Abweichung Branche")
|
||
required_indices = [timestamp_col_index, branche_crm_idx, beschreibung_idx, branche_wiki_idx, kategorien_wiki_idx, summary_web_idx, version_col_idx, branch_w_idx, branch_x_idx, branch_y_idx]
|
||
if None in required_indices: return debug_print(f"FEHLER: Indizes fehlen.")
|
||
ts_col_letter = sheet_handler._get_col_letter(timestamp_col_index + 1)
|
||
version_col_letter = sheet_handler._get_col_letter(version_col_idx + 1)
|
||
branch_w_letter = sheet_handler._get_col_letter(branch_w_idx + 1)
|
||
branch_x_letter = sheet_handler._get_col_letter(branch_x_idx + 1)
|
||
branch_y_letter = sheet_handler._get_col_letter(branch_y_idx + 1)
|
||
|
||
# --- Konfiguration ---
|
||
MAX_BRANCH_WORKERS = Config.MAX_BRANCH_WORKERS
|
||
OPENAI_CONCURRENCY_LIMIT = Config.OPENAI_CONCURRENCY_LIMIT
|
||
openai_semaphore_branch = threading.Semaphore(OPENAI_CONCURRENCY_LIMIT)
|
||
PROCESSING_BRANCH_BATCH_SIZE = Config.PROCESSING_BRANCH_BATCH_SIZE
|
||
update_batch_row_limit = Config.UPDATE_BATCH_ROW_LIMIT # Wird derzeit nicht verwendet, da wir pro Batch senden
|
||
|
||
# --- Worker Funktion ---
|
||
def evaluate_branch_task(task_data):
|
||
row_num = task_data['row_num']; result = {"branch": "k.A. (Fehler Task)", "consistency": "error", "justification": "Fehler in Worker-Task"}; error = None
|
||
try:
|
||
with openai_semaphore_branch:
|
||
# debug_print(f" Task {row_num}: Warte auf Semaphore...") # Sehr detailliertes Logging
|
||
# time.sleep(0.1) # Minimale Pause reduziert manchmal Race Conditions bei hoher Last
|
||
# debug_print(f" Task {row_num}: Semaphore erhalten, starte evaluate_branche_chatgpt...")
|
||
result = evaluate_branche_chatgpt( task_data['crm_branche'], task_data['beschreibung'], task_data['wiki_branche'], task_data['wiki_kategorien'], task_data['website_summary'])
|
||
# debug_print(f" Task {row_num}: evaluate_branche_chatgpt beendet.")
|
||
except Exception as e:
|
||
error = f"Fehler bei Branchenevaluation Zeile {row_num}: {e}"; debug_print(error); result['justification'] = error[:500]; result['consistency'] = 'error_task'
|
||
return {"row_num": row_num, "result": result, "error": error}
|
||
|
||
# --- Hauptverarbeitung ---
|
||
tasks_for_processing_batch = []
|
||
total_processed_count = 0; total_skipped_count = 0; total_error_count = 0
|
||
|
||
if not ALLOWED_TARGET_BRANCHES: load_target_schema();
|
||
if not ALLOWED_TARGET_BRANCHES: return debug_print("FEHLER: Ziel-Schema nicht geladen.")
|
||
|
||
for i in range(start_row_index_in_sheet, end_row_index_in_sheet + 1):
|
||
row_index_in_list = i - 1
|
||
if row_index_in_list >= len(all_data): continue
|
||
row = all_data[row_index_in_list]
|
||
|
||
# Timestamp-Prüfung (AO)
|
||
should_skip = False
|
||
if len(row) > timestamp_col_index and str(row[timestamp_col_index]).strip(): should_skip = True
|
||
if should_skip: total_skipped_count += 1; continue
|
||
|
||
# Task sammeln
|
||
task_data = { "row_num": i, "crm_branche": row[branche_crm_idx] if len(row) > branche_crm_idx else "", "beschreibung": row[beschreibung_idx] if len(row) > beschreibung_idx else "", "wiki_branche": row[branche_wiki_idx] if len(row) > branche_wiki_idx else "", "wiki_kategorien": row[kategorien_wiki_idx] if len(row) > kategorien_wiki_idx else "", "website_summary": row[summary_web_idx] if len(row) > summary_web_idx else ""}
|
||
tasks_for_processing_batch.append(task_data)
|
||
|
||
# --- Verarbeitungs-Batch ausführen ---
|
||
if len(tasks_for_processing_batch) >= PROCESSING_BRANCH_BATCH_SIZE or i == end_row_index_in_sheet:
|
||
if tasks_for_processing_batch:
|
||
batch_start_row = tasks_for_processing_batch[0]['row_num']; batch_end_row = tasks_for_processing_batch[-1]['row_num']
|
||
batch_task_count = len(tasks_for_processing_batch)
|
||
debug_print(f"\n--- Starte Branch-Evaluation Batch ({batch_task_count} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---")
|
||
|
||
results_list = []; batch_error_count = 0
|
||
debug_print(f" Evaluiere {batch_task_count} Zeilen parallel (max {MAX_BRANCH_WORKERS} worker, {OPENAI_CONCURRENCY_LIMIT} OpenAI gleichzeitig)...")
|
||
# *** BEGINN PARALLELE VERARBEITUNG ***
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_BRANCH_WORKERS) as executor:
|
||
future_to_task = {executor.submit(evaluate_branch_task, task): task for task in tasks_for_processing_batch}
|
||
# Warte auf Ergebnisse und sammle sie
|
||
for future in concurrent.futures.as_completed(future_to_task):
|
||
task = future_to_task[future]
|
||
try: result_data = future.result(); results_list.append(result_data);
|
||
except Exception as exc:
|
||
row_num = task['row_num']; err_msg = f"Generischer Fehler Branch Task Zeile {row_num}: {exc}"; debug_print(err_msg)
|
||
results_list.append({"row_num": row_num, "result": {"branch": "FEHLER", "consistency": "error_task", "justification": err_msg[:500]}, "error": err_msg})
|
||
batch_error_count += 1; total_error_count +=1
|
||
# Zähle Fehler aus dem Ergebnis-Dict
|
||
if results_list[-1]['error']: batch_error_count += 1; total_error_count +=1
|
||
|
||
# *** ENDE PARALLELE VERARBEITUNG ***
|
||
current_batch_processed_count = len(results_list)
|
||
total_processed_count += current_batch_processed_count
|
||
debug_print(f" Branch-Evaluation für Batch beendet. {current_batch_processed_count} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).")
|
||
|
||
# Sheet Updates vorbereiten FÜR DIESEN BATCH
|
||
if results_list:
|
||
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
current_version = Config.VERSION
|
||
batch_sheet_updates = []
|
||
# Sortiere Ergebnisse nach Zeilennummer für geordnetes Schreiben (optional)
|
||
results_list.sort(key=lambda x: x['row_num'])
|
||
for res_data in results_list:
|
||
row_num = res_data['row_num']; result = res_data['result']
|
||
# Logge das individuelle Ergebnis VOR dem Update
|
||
debug_print(f" Zeile {row_num}: Ergebnis -> Branch='{result.get('branch')}', Consistency='{result.get('consistency')}', Justification='{result.get('justification', '')[:50]}...'")
|
||
row_updates = [
|
||
{'range': f'{branch_w_letter}{row_num}', 'values': [[result.get("branch", "Fehler")]]},
|
||
{'range': f'{branch_x_letter}{row_num}', 'values': [[result.get("consistency", "Fehler")]]},
|
||
{'range': f'{branch_y_letter}{row_num}', 'values': [[result.get("justification", "Fehler")]]},
|
||
{'range': f'{ts_col_letter}{row_num}', 'values': [[current_timestamp]]},
|
||
{'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}
|
||
]
|
||
batch_sheet_updates.extend(row_updates)
|
||
|
||
# Sende Updates für DIESEN Batch SOFORT
|
||
if batch_sheet_updates:
|
||
debug_print(f" Sende Sheet-Update für {len(results_list)} Zeilen ({len(batch_sheet_updates)} Zellen)...")
|
||
success = sheet_handler.batch_update_cells(batch_sheet_updates)
|
||
if success: debug_print(f" Sheet-Update für Batch {batch_start_row}-{batch_end_row} erfolgreich.")
|
||
else: debug_print(f" FEHLER beim Sheet-Update für Batch {batch_start_row}-{batch_end_row}.")
|
||
else: debug_print(f" Keine Sheet-Updates für Batch {batch_start_row}-{batch_end_row} vorbereitet.")
|
||
|
||
tasks_for_processing_batch = [] # Batch leeren
|
||
debug_print(f"--- Verarbeitungs-Batch {batch_start_row}-{batch_end_row} abgeschlossen ---")
|
||
# Kurze Pause NACHDEM ein Batch komplett verarbeitet und geschrieben wurde
|
||
# debug_print(" Warte 1 Sekunde...") # Test-Log
|
||
time.sleep(1)
|
||
|
||
# !!! HIER DARF KEIN SLEEP STEHEN !!!
|
||
# time.sleep(Config.RETRY_DELAY) # <<< DIESE ZEILE MUSS WEG SEIN in deinem Code!
|
||
|
||
debug_print(f"Brancheneinschätzung (Parallel Batch) abgeschlossen. {total_processed_count} Zeilen verarbeitet (inkl. Fehler), {total_error_count} Fehler, {total_skipped_count} Zeilen wg. Timestamp übersprungen.")
|
||
|
||
|
||
# Annahmen:
|
||
# - Funktionen debug_print, process_verification_only, process_website_batch, process_branch_batch sind definiert.
|
||
# - sheet_handler ist eine initialisierte Instanz von GoogleSheetHandler (mit der korrekten get_start_row_index Methode).
|
||
# - Globale Konstante header_rows (oder besser, hol sie vom sheet_handler?)
|
||
|
||
# Komplette run_dispatcher Funktion (Start immer basierend auf AO)
|
||
# Komplette run_dispatcher Funktion (Keine Änderungen hier nötig)
|
||
def run_dispatcher(mode, sheet_handler, row_limit=None):
|
||
"""
|
||
Wählt den passenden Batch-Prozess basierend auf dem Modus.
|
||
Ermittelt die Startzeile dynamisch basierend auf der relevanten Spalte für den Modus.
|
||
"""
|
||
debug_print(f"Starte Dispatcher im Modus '{mode}' mit row_limit={row_limit}.")
|
||
header_rows = 5
|
||
|
||
# Startspalte für jeden Modus
|
||
start_col_key = "Timestamp letzte Prüfung" # Standard AO
|
||
min_start_row = 7
|
||
if mode == "website": start_col_key = "Website Rohtext" # AR
|
||
elif mode == "wiki": start_col_key = "Wiki Verif. Timestamp" # AX
|
||
elif mode == "branch": start_col_key = "Timestamp letzte Prüfung" # AO
|
||
elif mode == "summarize": start_col_key = "Website Zusammenfassung" # AS
|
||
elif mode == "combined": start_col_key = "Timestamp letzte Prüfung" # AO
|
||
|
||
debug_print(f"Dispatcher: Ermittle Startzeile basierend auf Spalte '{start_col_key}'...")
|
||
# get_start_row_index prüft jetzt auf exakt ""
|
||
start_data_index = sheet_handler.get_start_row_index(check_column_key=start_col_key, min_sheet_row=min_start_row)
|
||
|
||
if start_data_index == -1: return debug_print(f"FEHLER: Startspalte '{start_col_key}' prüfen!")
|
||
|
||
start_row_index_in_sheet = start_data_index + header_rows + 1
|
||
total_sheet_rows = len(sheet_handler.sheet_values)
|
||
|
||
# Prüfungen (wie gehabt)
|
||
if start_data_index >= len(sheet_handler.get_data()): return debug_print("Start nach Ende.")
|
||
if start_row_index_in_sheet > total_sheet_rows: return debug_print("Ungültige Startzeile.")
|
||
|
||
# Endzeile
|
||
if row_limit is not None and row_limit > 0: end_row_index_in_sheet = min(start_row_index_in_sheet + row_limit - 1, total_sheet_rows)
|
||
elif row_limit == 0: return debug_print("Limit 0.")
|
||
else: end_row_index_in_sheet = total_sheet_rows
|
||
debug_print(f"Dispatcher: Verarbeitung geplant für Sheet-Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}.")
|
||
if start_row_index_in_sheet > end_row_index_in_sheet: return debug_print("Start nach Ende (berechnet).")
|
||
|
||
# Modusauswahl
|
||
try:
|
||
if mode == "wiki": process_verification_only(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet)
|
||
elif mode == "website": process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet) # Prüft AR, Setzt AR+AP
|
||
elif mode == "branch": process_branch_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet)
|
||
elif mode == "summarize": process_website_summarization_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet)
|
||
elif mode == "combined":
|
||
debug_print("--- Start Combined Mode: Wiki ---"); process_verification_only(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet); time.sleep(1)
|
||
debug_print("--- Start Combined Mode: Website Scraping ---"); process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet); time.sleep(1)
|
||
debug_print("--- Start Combined Mode: Website Summarization ---"); process_website_summarization_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet); time.sleep(1)
|
||
debug_print("--- Start Combined Mode: Branch ---"); process_branch_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet)
|
||
debug_print("--- Combined Mode abgeschlossen ---")
|
||
else: debug_print(f"Ungültiger Modus '{mode}'.")
|
||
except Exception as e: debug_print(f"FEHLER im Dispatcher: {e}"); import traceback; debug_print(traceback.format_exc())
|
||
|
||
# --- Ende run_dispatcher Funktion ---
|
||
|
||
# ==================== SERP API / LINKEDIN FUNCTIONS ====================
|
||
|
||
@retry_on_failure
|
||
def serp_website_lookup(company_name):
|
||
"""Ermittelt Website via SERP API (Google Suche)."""
|
||
serp_key = Config.API_KEYS.get('serpapi')
|
||
if not serp_key:
|
||
debug_print("Fehler: SerpAPI Key nicht verfügbar für Website Lookup.")
|
||
return "k.A."
|
||
if not company_name: return "k.A."
|
||
|
||
# Blacklist unerwünschter Domains
|
||
blacklist = ["bloomberg.com", "northdata.de", "finanzen.net", "handelsblatt.com", "wikipedia.org", "linkedin.com"]
|
||
|
||
query = f'{company_name} offizielle Website' # Präzisere Query
|
||
params = {
|
||
"engine": "google",
|
||
"q": query,
|
||
"api_key": serp_key,
|
||
"hl": "de",
|
||
"gl": "de" # Geolocation auf Deutschland setzen
|
||
}
|
||
api_url = "https://serpapi.com/search"
|
||
|
||
try:
|
||
response = requests.get(api_url, params=params, timeout=10)
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
|
||
# 1. Knowledge Graph prüfen (oft die offizielle Seite)
|
||
if "knowledge_graph" in data and "website" in data["knowledge_graph"]:
|
||
kg_url = data["knowledge_graph"]["website"]
|
||
if kg_url and not any(bad_domain in kg_url for bad_domain in blacklist):
|
||
normalized_url = simple_normalize_url(kg_url)
|
||
if normalized_url != "k.A.":
|
||
debug_print(f"SERP Lookup: Website '{normalized_url}' aus Knowledge Graph für '{company_name}' gefunden.")
|
||
return normalized_url
|
||
|
||
# 2. Organische Ergebnisse prüfen
|
||
if "organic_results" in data:
|
||
for result in data["organic_results"]:
|
||
url = result.get("link", "")
|
||
# Prüfe Blacklist und ob es eine "echte" Website ist (nicht nur Suche etc.)
|
||
if url and not any(bad_domain in url for bad_domain in blacklist) and url.startswith("http"):
|
||
normalized_url = simple_normalize_url(url)
|
||
if normalized_url != "k.A.":
|
||
# Zusätzliche Plausibilitätsprüfung: Enthält die Domain Teile des Firmennamens?
|
||
domain_part = normalized_url.replace('www.', '').split('.')[0]
|
||
if domain_part in normalize_company_name(company_name):
|
||
debug_print(f"SERP Lookup: Website '{normalized_url}' aus Organic Results für '{company_name}' gefunden.")
|
||
return normalized_url
|
||
else:
|
||
debug_print(f"SERP Lookup: URL '{normalized_url}' übersprungen (Domain passt nicht zu '{company_name}').")
|
||
|
||
debug_print(f"SERP Lookup: Keine passende Website für '{company_name}' gefunden.")
|
||
return "k.A."
|
||
except requests.exceptions.RequestException as e:
|
||
debug_print(f"Fehler beim SERP API Website Lookup für '{company_name}': {e}")
|
||
return "k.A."
|
||
except Exception as e:
|
||
debug_print(f"Allgemeiner Fehler beim SERP API Website Lookup für '{company_name}': {e}")
|
||
return "k.A."
|
||
|
||
|
||
@retry_on_failure
|
||
def search_linkedin_contacts(company_name, website, position_query, crm_kurzform, num_results=10):
|
||
"""Sucht LinkedIn Kontakte via SERP API."""
|
||
serp_key = Config.API_KEYS.get('serpapi')
|
||
if not serp_key:
|
||
debug_print("Fehler: SerpAPI Key nicht verfügbar für LinkedIn Suche.")
|
||
return []
|
||
if not all([company_name, position_query, crm_kurzform]):
|
||
return []
|
||
|
||
# Query anpassen für bessere Ergebnisse
|
||
query = f'site:linkedin.com/in "{position_query}" "{crm_kurzform}"' # Suche nach Kurzform im Titel
|
||
# query = f'site:linkedin.com/in "{position_query}" "{company_name}"' # Original Query
|
||
params = {
|
||
"engine": "google",
|
||
"q": query,
|
||
"api_key": serp_key,
|
||
"hl": "de",
|
||
"gl": "de",
|
||
"num": num_results # Google's num Parameter (max 100, aber oft weniger geliefert)
|
||
}
|
||
api_url = "https://serpapi.com/search"
|
||
|
||
try:
|
||
response = requests.get(api_url, params=params, timeout=15) # Längerer Timeout
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
contacts = []
|
||
|
||
if "organic_results" in data:
|
||
for result in data["organic_results"]:
|
||
title = result.get("title", "")
|
||
linkedin_url = result.get("link", "")
|
||
|
||
# Filter: Muss LinkedIn URL sein und Kurzform muss im Titel vorkommen
|
||
if not linkedin_url or "linkedin.com/in/" not in linkedin_url:
|
||
continue
|
||
if crm_kurzform.lower() not in title.lower():
|
||
debug_print(f"LinkedIn Treffer übersprungen: Kurzform '{crm_kurzform}' nicht in Titel '{title}'")
|
||
continue
|
||
|
||
# Extrahiere Name und Position aus Titel
|
||
name_part = ""
|
||
pos_part = position_query # Fallback auf Suchbegriff
|
||
|
||
# Versuche gängige Trennzeichen
|
||
separators = ["–", "-", "|", " at ", " bei "]
|
||
title_cleaned = title.replace("...", "").strip() # Bereinige Titel
|
||
|
||
found_sep = False
|
||
for sep in separators:
|
||
if sep in title_cleaned:
|
||
parts = title_cleaned.split(sep, 1)
|
||
name_part = parts[0].strip()
|
||
# Versuche, LinkedIn/Profil etc. aus Namen zu entfernen
|
||
name_part = name_part.replace(" | LinkedIn", "").replace(" - LinkedIn", "").replace(" - Profil", "").strip()
|
||
|
||
# Positionsteil kann komplex sein, nehme alles nach dem Trenner
|
||
potential_pos = parts[1].strip()
|
||
# Entferne Firmenteil, wenn er dem Kurznamen ähnelt
|
||
if crm_kurzform.lower() in potential_pos.lower():
|
||
potential_pos = potential_pos.replace(crm_kurzform, "", 1).strip() # Nur erste Instanz ersetzen
|
||
# Entferne generische Endungen
|
||
potential_pos = potential_pos.split(" | LinkedIn")[0].split(" - LinkedIn")[0].strip()
|
||
pos_part = potential_pos if potential_pos else position_query
|
||
found_sep = True
|
||
break
|
||
|
||
if not found_sep: # Kein Trennzeichen gefunden
|
||
name_part = title_cleaned.split(" | LinkedIn")[0].split(" - LinkedIn")[0].strip()
|
||
# Prüfe, ob der Suchbegriff im verbleibenden Namensteil ist
|
||
if position_query.lower() in name_part.lower():
|
||
name_part = name_part.replace(position_query, "", 1).strip() # Versuche Position zu entfernen
|
||
|
||
# Teile Namen in Vor- und Nachname
|
||
firstname = ""
|
||
lastname = ""
|
||
name_parts = name_part.split()
|
||
if len(name_parts) > 1:
|
||
firstname = name_parts[0]
|
||
lastname = " ".join(name_parts[1:])
|
||
elif len(name_parts) == 1:
|
||
firstname = name_parts[0] # Nur Vorname gefunden?
|
||
|
||
if not firstname: # Wenn Name nicht extrahiert werden konnte, überspringe
|
||
debug_print(f"Kontakt übersprungen: Name konnte nicht extrahiert werden aus Titel '{title}'")
|
||
continue
|
||
|
||
contact_data = {
|
||
"Firmenname": company_name, # Originalname für Kontext
|
||
"CRM Kurzform": crm_kurzform,
|
||
"Website": website,
|
||
"Vorname": firstname,
|
||
"Nachname": lastname,
|
||
"Position": pos_part,
|
||
"LinkedInURL": linkedin_url
|
||
}
|
||
contacts.append(contact_data)
|
||
debug_print(f"Gefundener LinkedIn Kontakt: {firstname} {lastname} - {pos_part}")
|
||
|
||
debug_print(f"LinkedIn Suche für '{position_query}' bei '{crm_kurzform}' ergab {len(contacts)} Kontakte.")
|
||
return contacts
|
||
|
||
except requests.exceptions.RequestException as e:
|
||
debug_print(f"Fehler bei der SERP API LinkedIn Suche: {e}")
|
||
return []
|
||
except Exception as e:
|
||
debug_print(f"Allgemeiner Fehler bei der SERP API LinkedIn Suche: {e}")
|
||
return []
|
||
|
||
|
||
# Funktion count_linkedin_contacts wurde entfernt, da search_linkedin_contacts jetzt die Liste liefert
|
||
# und len() darauf angewendet werden kann.
|
||
|
||
|
||
def process_contact_research(sheet_handler):
|
||
"""Sucht LinkedIn Kontakte und trägt sie in 'Contacts' Sheet ein."""
|
||
debug_print("Starte Contact Research (LinkedIn)...")
|
||
|
||
main_sheet = sheet_handler.sheet
|
||
all_data = sheet_handler.get_all_data_with_headers()
|
||
header_rows = 5
|
||
|
||
# Finde Startzeile basierend auf Timestamp in Spalte AM (Index 38)
|
||
timestamp_col_index = COLUMN_MAP["Contact Search Timestamp"]
|
||
start_row_index_in_sheet = -1
|
||
for i in range(header_rows + 1, len(all_data) + 1):
|
||
if i < 7: continue # Normalerweise ab Zeile 7
|
||
row_index_in_list = i - 1
|
||
row = all_data[row_index_in_list]
|
||
if len(row) <= timestamp_col_index or not row[timestamp_col_index].strip():
|
||
start_row_index_in_sheet = i
|
||
break
|
||
|
||
if start_row_index_in_sheet == -1:
|
||
debug_print("Keine Zeile ohne Contact Search Timestamp (Spalte AM, ab Zeile 7) gefunden. Überspringe.")
|
||
return
|
||
|
||
debug_print(f"Contact Research startet ab Zeile {start_row_index_in_sheet}.")
|
||
|
||
# Kontakte-Blatt öffnen oder erstellen
|
||
try:
|
||
contacts_sheet = sheet_handler.sheet.spreadsheet.worksheet("Contacts")
|
||
debug_print("Blatt 'Contacts' gefunden.")
|
||
except gspread.exceptions.WorksheetNotFound:
|
||
debug_print("Blatt 'Contacts' nicht gefunden, erstelle neu...")
|
||
contacts_sheet = sheet_handler.sheet.spreadsheet.add_worksheet(title="Contacts", rows="1000", cols="12")
|
||
header = ["Firmenname", "CRM Kurzform", "Website", "Geschlecht", "Vorname", "Nachname", "Position",
|
||
"Suchbegriffskategorie", "E-Mail-Adresse", "LinkedIn-Link", "Timestamp"]
|
||
contacts_sheet.update(values=[header], range_name="A1:K1")
|
||
# Optional: Alignment Demo hier nicht mehr aufrufen
|
||
# alignment_demo(contacts_sheet) # NICHT MEHR NÖTIG/FALSCH
|
||
debug_print("Neues Blatt 'Contacts' erstellt und Header eingetragen.")
|
||
|
||
# Positionen, nach denen gesucht wird
|
||
positions_to_search = ["Serviceleiter", "Leiter Kundendienst", "IT-Leiter", "Leiter IT", "Geschäftsführer", "Vorstand", "Disponent", "Einsatzleiter"]
|
||
|
||
# Gehe Zeilen im Hauptblatt durch
|
||
for i in range(start_row_index_in_sheet, len(all_data) + 1):
|
||
row_index_in_list = i - 1
|
||
row = all_data[row_index_in_list]
|
||
|
||
company_name = row[COLUMN_MAP["CRM Name"]] if len(row) > COLUMN_MAP["CRM Name"] else ""
|
||
crm_kurzform = row[COLUMN_MAP["CRM Kurzform"]] if len(row) > COLUMN_MAP["CRM Kurzform"] else ""
|
||
website = row[COLUMN_MAP["CRM Website"]] if len(row) > COLUMN_MAP["CRM Website"] else ""
|
||
|
||
if not all([company_name, crm_kurzform, website]):
|
||
debug_print(f"Zeile {i}: Übersprungen (fehlende CRM Daten: Name, Kurzform oder Website).")
|
||
continue
|
||
|
||
debug_print(f"Zeile {i}: Suche Kontakte für '{crm_kurzform}'...")
|
||
all_found_contacts = []
|
||
contact_counts = {pos: 0 for pos in ["Serviceleiter", "IT-Leiter", "Geschäftsführer", "Disponent"]} # Für die Zählung im Hauptblatt
|
||
|
||
for position in positions_to_search:
|
||
# Suche max. 5 Kontakte pro Position, um API Calls/Kosten zu begrenzen
|
||
found_contacts = search_linkedin_contacts(company_name, website, position, crm_kurzform, num_results=5)
|
||
|
||
# Zählung für das Hauptblatt (vereinfachte Kategorien)
|
||
if "serviceleiter" in position.lower() or "kundendienst" in position.lower() or "einsatzleiter" in position.lower():
|
||
contact_counts["Serviceleiter"] += len(found_contacts)
|
||
elif "it-leiter" in position.lower() or "leiter it" in position.lower():
|
||
contact_counts["IT-Leiter"] += len(found_contacts)
|
||
elif "geschäftsführer" in position.lower() or "vorstand" in position.lower():
|
||
contact_counts["Geschäftsführer"] += len(found_contacts)
|
||
elif "disponent" in position.lower():
|
||
contact_counts["Disponent"] += len(found_contacts)
|
||
|
||
# Füge gefundene Kontakte zur Liste hinzu (mit Suchkategorie)
|
||
for contact in found_contacts:
|
||
contact["Suchbegriffskategorie"] = position
|
||
all_found_contacts.append(contact)
|
||
|
||
time.sleep(1.5) # Kleine Pause zwischen SerpAPI-Aufrufen
|
||
|
||
# Verarbeite gefundene Kontakte und schreibe ins Contacts-Sheet
|
||
rows_to_append = []
|
||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
unique_contacts = {c['LinkedInURL']: c for c in all_found_contacts}.values() # Deduplizieren basierend auf URL
|
||
|
||
for contact in unique_contacts:
|
||
firstname = contact.get("Vorname", "")
|
||
lastname = contact.get("Nachname", "")
|
||
gender_value = get_gender(firstname)
|
||
email = get_email_address(firstname, lastname, website)
|
||
|
||
contact_row = [
|
||
contact.get("Firmenname", ""),
|
||
contact.get("CRM Kurzform", ""),
|
||
contact.get("Website", ""),
|
||
gender_value,
|
||
firstname,
|
||
lastname,
|
||
contact.get("Position", ""),
|
||
contact.get("Suchbegriffskategorie", ""),
|
||
email,
|
||
contact.get("LinkedInURL", ""),
|
||
timestamp
|
||
]
|
||
rows_to_append.append(contact_row)
|
||
|
||
if rows_to_append:
|
||
try:
|
||
# Verwende append_rows für Effizienz
|
||
contacts_sheet.append_rows(rows_to_append, value_input_option='USER_ENTERED')
|
||
debug_print(f"Zeile {i}: {len(rows_to_append)} neue Kontakte zum 'Contacts'-Blatt hinzugefügt.")
|
||
except Exception as e:
|
||
debug_print(f"Zeile {i}: Fehler beim Hinzufügen von Kontakten zum Sheet: {e}")
|
||
# Evtl. einzeln versuchen bei Fehler?
|
||
|
||
# Aktualisiere Trefferzahlen und Timestamp im Hauptblatt (Batch Update)
|
||
main_sheet_updates = []
|
||
main_sheet_updates.append({'range': f'AI{i}', 'values': [[str(contact_counts["Serviceleiter"])]]})
|
||
main_sheet_updates.append({'range': f'AJ{i}', 'values': [[str(contact_counts["IT-Leiter"])]]})
|
||
main_sheet_updates.append({'range': f'AK{i}', 'values': [[str(contact_counts["Geschäftsführer"])]]})
|
||
main_sheet_updates.append({'range': f'AL{i}', 'values': [[str(contact_counts["Disponent"])]]})
|
||
main_sheet_updates.append({'range': f'AM{i}', 'values': [[timestamp]]}) # Contact Search Timestamp
|
||
|
||
sheet_handler.batch_update_cells(main_sheet_updates)
|
||
debug_print(f"Zeile {i}: Kontaktzahlen im Hauptblatt aktualisiert: {contact_counts} – Timestamp in AM gesetzt.")
|
||
|
||
# Pause nach Verarbeitung einer Firma
|
||
time.sleep(Config.RETRY_DELAY)
|
||
|
||
debug_print("Contact Research abgeschlossen.")
|
||
|
||
# ==================== ALIGNMENT DEMO (Hauptblatt) ====================
|
||
# ==================== ALIGNMENT DEMO (Hauptblatt) ====================
|
||
# Diese Funktion ist bereits im Code vorhanden (Zeile ~1230 in der vorherigen Version)
|
||
# Sie bleibt unverändert:
|
||
def alignment_demo(sheet):
|
||
"""Schreibt die Header-Struktur (Zeilen 1-5, jetzt bis Spalte AX) ins angegebene Sheet."""
|
||
new_headers = [
|
||
[ # Spaltenname (Zeile 1)
|
||
"ReEval Flag", "CRM Name", "CRM Kurzform", "CRM Website", "CRM Ort", "CRM Beschreibung", "CRM Branche", "CRM Beschreibung Branche extern", "CRM Anzahl Techniker", "CRM Umsatz", "CRM Anzahl Mitarbeiter", "CRM Vorschlag Wiki URL", "Wiki URL", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Chat Wiki Konsistenzprüfung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", "Begründung bei Abweichung", "Chat Vorschlag Branche", "Chat Konsistenz Branche", "Chat Begründung Abweichung Branche", "Chat Prüfung FSM Relevanz", "Chat Begründung für FSM Relevanz", "Chat Schätzung Anzahl Mitarbeiter", "Chat Konsistenzprüfung Mitarbeiterzahl", "Chat Begründung Abweichung Mitarbeiterzahl", "Chat Einschätzung Anzahl Servicetechniker", "Chat Begründung Abweichung Anzahl Servicetechniker", "Chat Schätzung Umsatz", "Chat Begründung Abweichung Umsatz", "Linked Serviceleiter gefunden", "Linked It-Leiter gefunden", "Linked Management gefunden", "Linked Disponent gefunden", "Contact Search Timestamp", "Wikipedia Timestamp", "Timestamp letzte Prüfung", "Version", "Tokens", "Website Rohtext", "Website Zusammenfassung",
|
||
"Website Scrape Timestamp", # AT
|
||
"Geschätzter Techniker Bucket", # AU
|
||
"Finaler Umsatz (Wiki>CRM)", # AV
|
||
"Finaler Mitarbeiter (Wiki>CRM)", # AW
|
||
"Wiki Verif. Timestamp", # AX
|
||
"SerpAPI Wiki Search Timestamp" # AY (NEU)
|
||
],
|
||
[ # Quelle der Daten (Zeile 2)
|
||
"CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "System", "System", "System", "System", "System", "Web Scraper", "Chat GPT API",
|
||
"System", # AT
|
||
"ML Modell / Skript", # AU
|
||
"Skript (Wiki/CRM)", # AV
|
||
"Skript (Wiki/CRM)", # AW
|
||
"System", # AX
|
||
"System" # AY (NEU)
|
||
],
|
||
[ # Feldkategorie (Zeile 3)
|
||
"Prozess", "Firmenname", "Firmenname", "Website", "Ort", "Beschreibung (Text)", "Branche", "Branche", "Anzahl Servicetechniker", "Umsatz", "Anzahl Mitarbeiter", "Wikipedia Artikel URL", "Wikipedia Artikel", "Beschreibung (Text)", "Branche", "Umsatz", "Anzahl Mitarbeiter", "Kategorien (Text)", "Verifizierung", "Begründung bei Abweichung", "Wikipedia Artikel", "Wikipedia Artikel", "Branche", "Branche", "Branche", "FSM Relevanz", "FSM Relevanz", "Anzahl Mitarbeiter", "Anzahl Mitarbeiter", "Anzahl Mitarbeiter", "Anzahl Servicetechniker", "Anzahl Servicetechniker", "Umsatz", "Umsatz", "Kontakte zur Firma", "Kontakte zur Firma", "Kontakte zur Firma", "Kontakte zur Firma", "Timestamp", "Timestamp", "Timestamp", "Version des Skripts die verwendet wurde", "ChatGPT Tokens", "Website-Content", "Website Zusammenfassung",
|
||
"Timestamp", # AT
|
||
"Anzahl Servicetechniker Bucket", # AU
|
||
"Umsatz", # AV
|
||
"Anzahl Mitarbeiter", # AW
|
||
"Timestamp", # AX
|
||
"Timestamp" # AY (NEU)
|
||
],
|
||
[ # Kurze Beschreibung (Zeile 4)
|
||
"Systemspalte...", "Enthält den Firmennamen...", "Manuell gepflegte Kurzform...", "Website des Unternehmens.", "Ort des Unternehmens.", "Kurze Beschreibung...", "Aktuelle Branchenzuweisung...", "Externe Branchenbeschreibung...", "Recherchierte Anzahl...", "Umsatz in Mio. € (CRM).", "Anzahl Mitarbeiter (CRM).", "Vorgeschlagene Wikipedia URL...", "Wikipedia URL...", "Erster Absatz...", "Wikipedia-Branche...", "Wikipedia-Umsatz...", "Wikipedia-Mitarbeiterzahl...", "Liste der Wikipedia-Kategorien.", "\"OK\" oder \"X\" – Ergebnis...", "Begründung bei Inkonsistenz...", "Chat-Vorschlag Wiki Artikel...", "Nicht genutzt...", "Branchenvorschlag via ChatGPT...", "Vergleich: Übereinstimmung CRM vs. ...", "Begründung bei abweichender...", "FSM-Relevanz: Bewertung...", "Begründung zur FSM-Bewertung.", "Schätzung Anzahl Mitarbeiter...", "Vergleich CRM vs. Wiki vs. ...", "Begründung bei Mitarbeiterabweichung...", "Schätzung Servicetechniker...", "Begründung bei Abweichung...", "Schätzung Umsatz via ChatGPT.", "Begründung bei Umsatzabweichung.", "Anzahl Kontakte (Serviceleiter)...", "Anzahl Kontakte (IT-Leiter)...", "Anzahl Kontakte (Management)...", "Anzahl Kontakte (Disponent)...", "Timestamp der Kontaktsuche.", "Timestamp der Wikipedia-Suche/Extraktion.", "Timestamp der ChatGPT-Bewertung / Letzte Prüfung der Zeile.", "Ausgabe der Skriptversion...", "Token-Zählung...", "Roh extrahierter Text...", "Zusammenfassung des Webseiteninhalts...",
|
||
"Timestamp des letzten Website-Scrapings (AR, AS).", # AT
|
||
"Geschätzter Bucket (1-7) für Servicetechniker...", # AU
|
||
"Konsolidierter Umsatz (Mio €) nach Priorität Wiki > CRM.", # AV
|
||
"Konsolidierte Mitarbeiterzahl nach Priorität Wiki > CRM.", # AW
|
||
"Timestamp der letzten Wiki-Verifikation (Spalten S-Y).", # AX
|
||
"Timestamp der letzten SerpAPI-Suche nach fehlender Wiki-URL (Modus find_wiki_serp)." # AY (NEU)
|
||
],
|
||
[ # Aufgabe / Funktion (Zeile 5) - Ergänzt um AX
|
||
"Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Wird durch Wikipedia Scraper bereitgestellt", "Wird zunächst nicht verwendet...", "Wird u.a. zur finalen Ermittlung...", "Wird u.a. mit CRM-Umsatz...", "Wird u.a. mit CRM-Anzahl...", "Wenn Website-Daten fehlen...", "\"Es soll durch ChatGPT geprüft werden...", "\"Liegt eine Inkonsistenz...", "\"Sollte durch die Wikipedia-Suche...", "XXX derzeit nicht verwendet...", "\"ChatGPT soll anhand der vorliegenden...", "Die in Spalte CRM festgelegte...", "Weicht die von ChatGPT ermittelte...", "ChatGPT soll anhand der vorliegenden Daten prüfen...", "Die in 'Chat Begründung für FSM Relevanz'...", "Nur wenn kein Wikipedia-Eintrag...", "Entspricht die durch ChatGPT ermittelte...", "Weicht die von ChatGPT geschätzte...", "ChatGPT soll auf Basis öffentlich...", "Weicht die von ChatGPT geschätzte...", "Nur wenn kein Wikipedia-Eintrag...", "ChatGPT soll signifikante Umsatzabweichungen...", "Über SerpAPI wird zusammen...", "Über SerpAPI wird zusammen...", "Über SerpAPI wird zusammen...", "Über SerpAPI wird zusammen...", "Wenn die Kontaktsuche gestartet wird...", "Wenn die Wikipedia-Suche gestartet wird...", "Wenn die ChatGPT-Bewertung gestartet wird...", "Wird durch das System befüllt", "Wird durch tiktoken berechnet", "Wird durch Web Scraper...", "Wird durch ChatGPT API...",
|
||
"Timestamp wird gesetzt, wenn Website Rohtext/Zusammenfassung geschrieben werden.", # AT
|
||
"Ergebnis der Schätzung durch das trainierte ML-Modell.", # AU
|
||
"Vom Skript berechneter Wert, priorisiert Wiki > CRM...", # AV
|
||
"Vom Skript berechneter Wert, priorisiert Wiki > CRM...", # AW
|
||
"Timestamp wird gesetzt, wenn Wiki-Verifikation (S-Y) durchgeführt wurde.", # AX
|
||
"Timestamp wird gesetzt, nachdem versucht wurde, eine fehlende Wiki-URL via SerpAPI zu finden." # AY (NEU)
|
||
]
|
||
]
|
||
num_cols = len(new_headers[0])
|
||
# ... (Rest der Funktion zum Schreiben der Header bleibt gleich, verwendet jetzt AY als Endspalte) ...
|
||
def colnum_string(n): # Hilfsfunktion bleibt
|
||
string = ""
|
||
while n > 0: n, remainder = divmod(n - 1, 26); string = chr(65 + remainder) + string
|
||
return string
|
||
end_col_letter = colnum_string(num_cols)
|
||
header_range = f"A1:{end_col_letter}{len(new_headers)}"
|
||
try:
|
||
sheet.update(values=new_headers, range_name=header_range)
|
||
logging.info(f"Alignment-Demo abgeschlossen: Header in Bereich {header_range} geschrieben.")
|
||
except Exception as e:
|
||
logging.error(f"FEHLER beim Schreiben der Alignment-Demo Header: {e}")
|
||
|
||
# ==================== DATA PROCESSOR ====================
|
||
class DataProcessor:
|
||
"""
|
||
Verarbeitet Daten aus dem Google Sheet, führt verschiedene Anreicherungs-
|
||
und Analyseprozesse durch, inklusive Timestamp-basierter Überspringung
|
||
und erzwungener Neuverarbeitung im Re-Eval-Modus.
|
||
Enthält auch die Datenvorbereitung für das ML-Modell.
|
||
"""
|
||
def __init__(self, sheet_handler):
|
||
"""
|
||
Initialisiert den DataProcessor.
|
||
|
||
Args:
|
||
sheet_handler (GoogleSheetHandler): Eine initialisierte Instanz des GoogleSheetHandlers.
|
||
"""
|
||
# WICHTIG: Stelle sicher, dass sheet_handler übergeben wird und nicht None ist
|
||
if sheet_handler is None:
|
||
# Kritischer Fehler, da der Handler benötigt wird
|
||
logging.critical("DataProcessor Init FEHLER: Kein gültiger sheet_handler übergeben!")
|
||
# Hier ist es sinnvoll, einen Fehler zu werfen, um das Problem sofort sichtbar zu machen
|
||
raise ValueError("DataProcessor benötigt einen gültigen GoogleSheetHandler.")
|
||
|
||
self.sheet_handler = sheet_handler
|
||
# Erstelle eine Instanz des Scrapers für diesen Prozessor
|
||
# Annahme: WikipediaScraper ist importiert und korrekt
|
||
try:
|
||
# Annahme: WikipediaScraper benötigt keinen Parameter mehr,
|
||
# oder kann mit einem Default initialisiert werden,
|
||
# und Config.USER_AGENT existiert
|
||
self.wiki_scraper = WikipediaScraper() # Geht davon aus, dass Config etc. verfügbar ist
|
||
except NameError:
|
||
logging.critical("DataProcessor Init FEHLER: WikipediaScraper Klasse nicht gefunden/importiert!")
|
||
raise
|
||
except Exception as e:
|
||
logging.critical(f"DataProcessor Init FEHLER beim Initialisieren von WikipediaScraper: {e}")
|
||
raise
|
||
|
||
logging.info("DataProcessor initialisiert.")
|
||
|
||
# --- Methode: Verarbeitung einer einzelnen Zeile ---
|
||
# Diese Methode gehört in die Klasse
|
||
# @retry_on_failure # Achtung: retry_on_failure macht bei dieser Methode WENIG Sinn,
|
||
# da sie interne Logik steuert, keine externen Calls.
|
||
# Besser: retry auf den einzelnen Schritten (API/Scrape)
|
||
def _process_single_row(self, row_num_in_sheet, row_data,
|
||
process_wiki=True, process_chatgpt=True, process_website=True,
|
||
force_reeval=False):
|
||
"""
|
||
Verarbeitet die Daten für eine einzelne Zeile im Sheet.
|
||
Führt Website-Scraping/Lookup, Wikipedia-Extraktion/Validierung
|
||
und ChatGPT-Evaluationen durch, basierend auf Timestamps/Status
|
||
oder dem force_reeval Flag. Schreibt Ergebnisse zurück.
|
||
|
||
Args:
|
||
row_num_in_sheet (int): Die 1-basierte Zeilennummer im Google Sheet.
|
||
row_data (list): Die rohen Listendaten für diese Zeile.
|
||
process_wiki (bool, optional): Soll Wiki-Verarbeitung durchgeführt werden?. Defaults to True.
|
||
process_chatgpt (bool, optional): Sollen ChatGPT-Evaluationen durchgeführt werden?. Defaults to True.
|
||
process_website (bool, optional): Soll Website-Verarbeitung durchgeführt werden?. Defaults to True.
|
||
force_reeval (bool, optional): Ignoriert Timestamps und erzwingt Neuverarbeitung. Defaults to False.
|
||
"""
|
||
logging.info(f"--- Starte Verarbeitung für Zeile {row_num_in_sheet} {'(Re-Eval)' if force_reeval else ''} ---")
|
||
updates = []
|
||
now_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
any_processing_done = False
|
||
wiki_data_updated_in_this_run = False # Flag, ob Wiki-Daten NEU extrahiert/gesetzt wurden
|
||
|
||
# Hilfsfunktion für sicheren Zellenzugriff
|
||
def get_cell_value(key):
|
||
idx = COLUMN_MAP.get(key)
|
||
if idx is not None and len(row_data) > idx:
|
||
# Stelle sicher, dass der Wert nicht None ist, falls Sheet-Zelle leer ist
|
||
return row_data[idx] if row_data[idx] is not None else ''
|
||
return "" # Gebe leeren String für fehlende Spalten zurück
|
||
|
||
# Initiale Werte lesen
|
||
company_name = get_cell_value("CRM Name")
|
||
website_url = get_cell_value("CRM Website"); original_website = website_url
|
||
crm_branche = get_cell_value("CRM Branche"); crm_beschreibung = get_cell_value("CRM Beschreibung")
|
||
konsistenz_s = get_cell_value("Chat Wiki Konsistenzprüfung").strip() # Trimme hier schon
|
||
|
||
# Lade vorhandene Wiki-Daten (könnten alt sein, werden ggf. überschrieben)
|
||
final_wiki_data = {
|
||
'url': get_cell_value("Wiki URL") or 'k.A.', 'first_paragraph': get_cell_value("Wiki Absatz") or 'k.A.',
|
||
'branche': get_cell_value("Wiki Branche") or 'k.A.', 'umsatz': get_cell_value("Wiki Umsatz") or 'k.A.',
|
||
'mitarbeiter': get_cell_value("Wiki Mitarbeiter") or 'k.A.', 'categories': get_cell_value("Wiki Kategorien") or 'k.A.'
|
||
}
|
||
|
||
# --- 1. Website Handling (Prüft AT oder force_reeval) ---
|
||
website_ts_missing = not get_cell_value("Website Scrape Timestamp").strip()
|
||
# Website Verarbeitung notwendig, wenn:
|
||
# - process_website True ist UND
|
||
# ( force_reeval True ist ODER Timestamp AT fehlt )
|
||
website_processing_needed = process_website and (force_reeval or website_ts_missing)
|
||
|
||
if website_processing_needed:
|
||
any_processing_done = True
|
||
logging.info(f"Zeile {row_num_in_sheet}: Starte Website Verarbeitung (Grund: {'Re-Eval' if force_reeval else 'AT fehlt'})...")
|
||
# Website Lookup nur, wenn URL leer ist
|
||
if not website_url or website_url.strip().lower() == "k.a.":
|
||
logging.debug(" -> Suche Website via SERP...")
|
||
# Annahme: serp_website_lookup existiert und nutzt logging/retry
|
||
new_website = serp_website_lookup(company_name)
|
||
if new_website != "k.A.":
|
||
website_url = new_website # Update die lokale Variable für den weiteren Schritt
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["CRM Website"] + 1)}{row_num_in_sheet}', 'values': [[website_url]]})
|
||
logging.info(f" -> Neue Website gefunden und für Update vorgemerkt: {website_url}")
|
||
else:
|
||
logging.warning(f" -> Keine neue Website via SERP gefunden für '{company_name}'.")
|
||
|
||
if website_url and website_url.strip().lower() != "k.a.":
|
||
logging.debug(f" -> Scrape Rohtext von {website_url}...")
|
||
# Annahme: get_website_raw existiert und nutzt logging/retry
|
||
new_website_raw = get_website_raw(website_url)
|
||
website_raw = new_website_raw # Lokale Variable aktualisieren
|
||
|
||
# Zusammenfassung nur, wenn Rohtext extrahiert wurde
|
||
if website_raw != "k.A." and website_raw.strip():
|
||
logging.debug(f" -> Fasse Rohtext zusammen (Länge: {len(str(website_raw))})...")
|
||
# Annahme: summarize_website_content existiert und nutzt logging/retry
|
||
new_website_summary = summarize_website_content(website_raw)
|
||
website_summary = new_website_summary # Lokale Variable aktualisieren
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[website_summary]]})
|
||
else:
|
||
logging.warning(" -> Kein gültiger Rohtext zum Zusammenfassen vorhanden.")
|
||
website_summary = "k.A."
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]})
|
||
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [[website_raw]]}) # Rohtext immer schreiben (k.A. oder Inhalt)
|
||
|
||
else:
|
||
logging.warning(f" -> Keine gültige Website URL vorhanden/gefunden für '{company_name}'. Website Verarbeitung übersprungen.")
|
||
website_raw, website_summary = "k.A.", "k.A."
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]})
|
||
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Scrape Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # Timestamp AT immer setzen, wenn Verarbeitung versucht wurde
|
||
|
||
elif process_website:
|
||
logging.debug(f"Zeile {row_num_in_sheet}: Überspringe Website Verarbeitung (AT vorhanden und kein Re-Eval).")
|
||
|
||
# --- 2. Wikipedia Verarbeitung (Prüft AN, Status S='X (URL Copied)' oder force_reeval) ---
|
||
wiki_ts_an_missing = not get_cell_value("Wikipedia Timestamp").strip()
|
||
status_s_indicates_reparse = konsistenz_s.upper() == "X (URL COPIED)" # Prüfe getrimmten Wert
|
||
# Wiki Verarbeitung notwendig, wenn:
|
||
# - process_wiki True ist UND
|
||
# ( force_reeval True ist ODER Timestamp AN fehlt ODER Status S ist 'X (URL Copied)' )
|
||
wiki_processing_needed = process_wiki and (force_reeval or wiki_ts_an_missing or status_s_indicates_reparse)
|
||
|
||
|
||
if wiki_processing_needed:
|
||
any_processing_done = True
|
||
|
||
# Konstruiere den 'Grund' String separat VOR dem Logging-Aufruf
|
||
if force_reeval:
|
||
grund_message = 'Re-Eval'
|
||
else:
|
||
# Dieser f-String ist nun einfacher und nicht mehr Teil eines komplexen Ausdrucks im äußeren f-String
|
||
grund_message = f"AN fehlt? {wiki_ts_an_missing}, S='X (URL Copied)'? {status_s_indicates_reparse}"
|
||
|
||
logging.info(f"Zeile {row_num_in_sheet}: Starte Wikipedia Verarbeitung (Grund: {grund_message})...")
|
||
|
||
url_in_m = get_cell_value("Wiki URL").strip()
|
||
url_to_extract = None
|
||
search_was_needed = False # Flag, ob eine neue Suche durchgeführt wurde
|
||
|
||
# --- Kernlogik für Re-Eval oder Initiallauf / S="X (URL Copied)" ---
|
||
# Priorität:
|
||
# 1. force_reeval: Nimm M, wenn gültig. Sonst Suche.
|
||
# 2. S == "X (URL Copied)": Ignoriere M, mache Suche.
|
||
# 3. AN fehlt: Wenn M gültig, valide M. Sonst Suche.
|
||
# 4. Sonst (AN da, S nicht "X (URL Copied)", kein reeval): Überspringe.
|
||
|
||
if force_reeval:
|
||
logging.debug(" -> Re-Eval Modus aktiv.")
|
||
if url_in_m and url_in_m.lower() not in ["k.a.", "kein artikel gefunden"] and url_in_m.lower().startswith("http"):
|
||
# Im Re-Eval Modus nehmen wir die URL aus M an, ohne erneute Validierung oder Suche (Vertrauen auf M)!
|
||
logging.info(f" -> Re-Eval: Nutze vorhandene URL aus Spalte M direkt: {url_in_m}")
|
||
url_to_extract = url_in_m
|
||
else:
|
||
# Wenn M leer/ungültig ist, auch im Re-Eval Modus neu suchen
|
||
logging.warning(f" -> Re-Eval: Spalte M ist leer oder ungültig ('{url_in_m}'). Starte neue Suche...")
|
||
search_was_needed = True
|
||
validated_page = self.wiki_scraper.search_company_article(company_name, website_url) # Annahme: self.wiki_scraper existiert
|
||
if validated_page:
|
||
url_to_extract = validated_page.url
|
||
else: # Suche erfolglos
|
||
final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}
|
||
wiki_data_updated_in_this_run = True
|
||
elif status_s_indicates_reparse:
|
||
logging.warning(f" -> Status S ist 'X (URL Copied)', ignoriere URL '{url_in_m}' in M und starte neue Suche...")
|
||
search_was_needed = True
|
||
validated_page = self.wiki_scraper.search_company_article(company_name, website_url)
|
||
if validated_page: url_to_extract = validated_page.url
|
||
else: final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}; wiki_data_updated_in_this_run = True
|
||
elif wiki_ts_an_missing: # Nur wenn AN fehlt und S nicht 'X(Copied)' ist, UND kein reeval
|
||
if url_in_m and url_in_m.lower() not in ["k.a.", "kein artikel gefunden"] and url_in_m.lower().startswith("http"):
|
||
# Prüfe Validität nur im Initiallauf, wenn M schon befüllt ist
|
||
logging.debug(f" -> AN fehlt, prüfe Validität der URL aus M: {url_in_m}")
|
||
try:
|
||
# Extrahieren des Titels aus der URL für wikipedia.page
|
||
# Hier könnte ein Fehler passieren, wenn URL kein '/wiki/' hat
|
||
title_from_url = url_in_m.split('/wiki/')[-1].replace('_', ' ')
|
||
page_from_m = wikipedia.page(title_from_url, auto_suggest=False, preload=True)
|
||
# Validierung des Artikels
|
||
if self.wiki_scraper._validate_article(page_from_m, company_name, website_url):
|
||
url_to_extract = page_from_m.url
|
||
logging.info(f" -> Vorhandene URL aus M '{url_to_extract}' ist valide und wird verwendet.")
|
||
else:
|
||
logging.warning(f" -> Vorhandene URL aus M '{page_from_m.title}' ist NICHT valide. Starte neue Suche...")
|
||
search_was_needed = True
|
||
validated_page = self.wiki_scraper.search_company_article(company_name, website_url)
|
||
if validated_page: url_to_extract = validated_page.url
|
||
else: final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}; wiki_data_updated_in_this_run = True
|
||
except wikipedia.exceptions.PageError:
|
||
logging.warning(f" -> Seite für vorhandene URL aus M '{url_in_m}' nicht gefunden (PageError). Starte neue Suche...")
|
||
search_was_needed = True
|
||
validated_page = self.wiki_scraper.search_company_article(company_name, website_url)
|
||
if validated_page: url_to_extract = validated_page.url
|
||
else: final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}; wiki_data_updated_in_this_run = True
|
||
except wikipedia.exceptions.DisambiguationError as e_disamb_m:
|
||
logging.info(f" -> Vorhandene URL aus M '{url_in_m}' ist eine Begriffsklärung. Starte Suche...")
|
||
search_was_needed = True
|
||
validated_page = self.wiki_scraper.search_company_article(company_name, website_url)
|
||
if validated_page: url_to_extract = validated_page.url
|
||
else: final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}; wiki_data_updated_in_this_run = True
|
||
except Exception as e_val_m: # Fängt auch URL parsing Fehler hier ab
|
||
logging.exception(f" -> Fehler beim Prüfen der URL aus M '{url_in_m}': {e_val_m}. Starte neue Suche...")
|
||
search_was_needed = True
|
||
validated_page = self.wiki_scraper.search_company_article(company_name, website_url)
|
||
if validated_page: url_to_extract = validated_page.url
|
||
else: final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}; wiki_data_updated_in_this_run = True
|
||
else:
|
||
# M ist leer/ungültig und AN fehlt -> Suche starten
|
||
logging.info(f" -> AN fehlt und M leer/ungültig. Starte Wikipedia-Suche für '{company_name}'...")
|
||
search_was_needed = True
|
||
validated_page = self.wiki_scraper.search_company_article(company_name, website_url)
|
||
if validated_page:
|
||
url_to_extract = validated_page.url
|
||
else:
|
||
final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}
|
||
wiki_data_updated_in_this_run = True
|
||
|
||
# Datenextraktion, wenn eine URL bestimmt wurde
|
||
if url_to_extract and url_to_extract != 'Kein Artikel gefunden':
|
||
logging.info(f" -> Extrahiere Daten von URL: {url_to_extract}...")
|
||
# Annahme: self.wiki_scraper.extract_company_data existiert und nutzt logging
|
||
extracted_data = self.wiki_scraper.extract_company_data(url_to_extract)
|
||
if extracted_data:
|
||
final_wiki_data = extracted_data
|
||
wiki_data_updated_in_this_run = True # Markieren, dass extrahierte Daten da sind
|
||
logging.info(f" -> Datenextraktion erfolgreich.")
|
||
else:
|
||
logging.error(f" -> Fehler bei Datenextraktion von {url_to_extract}. Setze Daten auf 'k.A.'")
|
||
# Behalte die URL, aber setze alle anderen Felder auf k.A.
|
||
final_wiki_data = {'url': url_to_extract, 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}
|
||
wiki_data_updated_in_this_run = True # Markieren, dass überschrieben wird
|
||
|
||
# Sheet Updates für M-R und AN (nur wenn Wiki-Verarbeitung lief)
|
||
if wiki_processing_needed: # Hier wurde bereits geprüft, ob Wiki-Verarbeitung notwendig war
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki URL"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('url', 'k.A.')]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Absatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('first_paragraph', 'k.A.')]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Branche"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('branche', 'k.A.')]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('umsatz', 'k.A.')]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('mitarbeiter', 'k.A.')]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Kategorien"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('categories', 'k.A.')]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wikipedia Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # AN Timestamp setzen
|
||
|
||
# Setze S ('Chat Wiki Konsistenzprüfung') zurück, wenn eine Neubewertung nötig ist:
|
||
# - Immer bei force_reeval
|
||
# - Wenn die URL in M geändert wurde (entweder durch Suche oder weil M vorher leer war)
|
||
# - Wenn Status S zuvor "X (URL Copied)" war
|
||
url_changed = (url_in_m != final_wiki_data.get('url')) # Prüft ob die NEUE URL anders ist als die ursprünglich in M
|
||
|
||
# Prüfen, ob das Zurücksetzen des Status S überhaupt notwendig ist
|
||
if force_reeval or status_s_indicates_reparse or url_changed:
|
||
s_idx = COLUMN_MAP.get("Chat Wiki Konsistenzprüfung")
|
||
if s_idx is not None:
|
||
s_let = self.sheet_handler._get_col_letter(s_idx + 1)
|
||
# Füge das Update zum Zurücksetzen von S hinzu
|
||
updates.append({'range': f'{s_let}{row_num_in_sheet}', 'values': [["?"]]}) # Fragezeichen für Neubewertung
|
||
|
||
# Bestimme den Grund-String VOR dem Logging-Aufruf
|
||
grund_message_parts = []
|
||
if force_reeval:
|
||
grund_message_parts.append('Re-Eval')
|
||
# Beachten Sie: Hier verwenden wir einen normalen String, KEINEN f-String,
|
||
# für den Text "S='X (URL Copied)'". Wir escapen die einfachen Anführungszeichen
|
||
# nicht, weil wir die äußeren Anführungszeichen des String-Literals ändern (auf doppelt),
|
||
# oder wir lassen die einfachen Anführungszeichen einfach unescaped im String.
|
||
# Letzteres ist in einem normalen String erlaubt.
|
||
if status_s_indicates_reparse:
|
||
grund_message_parts.append("S='X (URL Copied)'") # Nutzen Sie doppelte Anführungszeichen außen
|
||
if url_changed:
|
||
grund_message_parts.append('URL geändert')
|
||
|
||
# Verbinde die Gründe, falls mehrere zutreffen
|
||
grund_message = ", ".join(grund_message_parts)
|
||
|
||
# Logge nun mit dem vorbereiteten Grund-String
|
||
logging.info(f" -> Status S zurückgesetzt auf '?' für erneute Verifikation (Grund: {grund_message}).")
|
||
|
||
# else: # Diesen else-Zweig gab es vorher nicht, ist auch nicht nötig
|
||
# logging.debug(f"Zeile {row_num_in_sheet}: Status S nicht zurückgesetzt (AN da, S nicht X(Copied), kein Re-Eval).")
|
||
|
||
|
||
elif process_wiki: # Dieser elif-Zweig gehört weiterhin zum if wiki_processing_needed: Block
|
||
logging.debug(f"Zeile {row_num_in_sheet}: Überspringe Wikipedia Verarbeitung (AN vorhanden, kein S='X (URL Copied)' und kein Re-Eval).")
|
||
|
||
|
||
# --- 3. ChatGPT Evaluationen (Branch etc.) (Prüft AO oder force_reeval oder wiki_data_updated_in_this_run) ---
|
||
chat_ts_ao_missing = not get_cell_value("Timestamp letzte Prüfung").strip()
|
||
# Chat Evaluationen notwendig, wenn:
|
||
# - process_chatgpt True ist UND
|
||
# ( force_reeval True ist ODER Timestamp AO fehlt ODER Wiki Daten gerade aktualisiert wurden )
|
||
run_chat_eval = process_chatgpt and (force_reeval or chat_ts_ao_missing or wiki_data_updated_in_this_run)
|
||
|
||
if run_chat_eval:
|
||
any_processing_done = True
|
||
logging.info(f"Zeile {row_num_in_sheet}: Starte ChatGPT Evaluationen (Grund: {'Re-Eval' if force_reeval else f'AO fehlt? {chat_ts_ao_missing}, Wiki gerade aktualisiert? {wiki_data_updated_in_this_run}'})...")
|
||
|
||
# Annahme: evaluate_branche_chatgpt existiert und nutzt logging/retry
|
||
# Nutze die (ggf. neu extrahierten) final_wiki_data
|
||
branch_result = evaluate_branche_chatgpt(
|
||
crm_branche, crm_beschreibung,
|
||
final_wiki_data.get('branche', 'k.A.'),
|
||
final_wiki_data.get('categories', 'k.A.'),
|
||
website_summary
|
||
)
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('branch', 'Fehler')]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenz Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('consistency', 'Fehler')]]})
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('justification', 'Fehler')]]})
|
||
|
||
# --- Hier Platz für weitere ChatGPT-Calls, die AO als Trigger nutzen ---
|
||
# z.B. FSM Relevanz, Mitarbeiter/Umsatz Schätzung etc.
|
||
# Stelle sicher, dass diese Funktionen existieren und die benötigten Daten nutzen
|
||
|
||
# Beispiel (Pseudo-Code, implementiere diese Funktionen falls nötig):
|
||
# fsm_result = evaluate_fsm_suitability(company_name, {'wiki': final_wiki_data, 'web_summary': website_summary, 'crm_desc': crm_beschreibung})
|
||
# updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Prüfung FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[fsm_result.get('suitability', 'Fehler')]]})
|
||
# updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung für FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[fsm_result.get('justification', 'Fehler')]]})
|
||
|
||
# emp_estimate_result = evaluate_employee_chatgpt(...)
|
||
# updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schätzung Anzahl Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[emp_estimate_result.get('estimate', 'Fehler')]]})
|
||
# ... etc.
|
||
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Timestamp letzte Prüfung"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # AO Timestamp setzen
|
||
|
||
elif process_chatgpt:
|
||
logging.debug(f"Zeile {row_num_in_sheet}: Überspringe ChatGPT Evaluationen (AO vorhanden, Wiki nicht gerade aktualisiert und kein Re-Eval).")
|
||
|
||
|
||
# --- 4. Abschließende Updates ---
|
||
# Version wird gesetzt, wenn IRGENDEINE Verarbeitung in dieser Zeile stattgefunden hat
|
||
if any_processing_done:
|
||
# Annahme: Config ist verfügbar
|
||
updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Version"] + 1)}{row_num_in_sheet}', 'values': [[Config.VERSION]]})
|
||
|
||
# --- 5. Batch Update für diese Zeile ---
|
||
if updates:
|
||
# Info-Log über die Anzahl der Updates für diese spezifische Zeile
|
||
logging.info(f"Zeile {row_num_in_sheet}: Sende Batch-Update mit {len(updates)} Operationen für diese Zeile...")
|
||
success = self.sheet_handler.batch_update_cells(updates) # Annahme: batch_update_cells nutzt logging intern
|
||
if not success:
|
||
logging.error(f"Zeile {row_num_in_sheet}: FEHLER beim Batch-Update.")
|
||
else:
|
||
# Info-Log, wenn nichts zu tun war
|
||
if not any_processing_done:
|
||
logging.info(f"Zeile {row_num_in_sheet}: Keine Updates zum Schreiben (alle relevanten Schritte übersprungen).")
|
||
|
||
|
||
logging.info(f"--- Verarbeitung für Zeile {row_num_in_sheet} abgeschlossen ---")
|
||
# Kleine Pause nach der Verarbeitung jeder Zeile, um API-Limits zu respektieren, auch bei sequenziellen Modi
|
||
# Der Wert kann in Config angepasst werden. 0.1s ist sehr kurz, 0.5-1.0s ist realistischer.
|
||
# Annahme: Config.RETRY_DELAY ist in Sekunden, also durch 10 oder 20 teilen
|
||
# logging.debug(f"Wartezeit nach Zeile {row_num_in_sheet}: {max(0.1, getattr(Config, 'RETRY_DELAY', 5) / 20):.2f}s")
|
||
time.sleep(max(0.1, getattr(Config, 'RETRY_DELAY', 5) / 20))
|
||
|
||
|
||
# --- Methode für den Re-Eval Modus ---
|
||
# Diese Methode gehört in die Klasse
|
||
def process_reevaluation_rows(self, row_limit=None, clear_flag=True,
|
||
# NEUE PARAMETER hinzugefügt:
|
||
process_wiki_steps=True,
|
||
process_chatgpt_steps=True,
|
||
process_website_steps=True):
|
||
"""
|
||
Verarbeitet nur Zeilen, die in Spalte A mit 'x' markiert sind.
|
||
Ruft _process_single_row für jede dieser Zeilen auf mit force_reeval=True.
|
||
Verarbeitet maximal row_limit Zeilen.
|
||
Löscht optional das 'x'-Flag nach erfolgreicher Verarbeitung.
|
||
Erlaubt die Auswahl spezifischer Verarbeitungsschritte.
|
||
|
||
Args:
|
||
row_limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None.
|
||
clear_flag (bool, optional): Flag 'x' nach erfolgreicher Verarbeitung löschen. Defaults to True.
|
||
process_wiki_steps (bool, optional): Soll der Wiki-Schritt in _process_single_row ausgeführt werden?. Defaults to True.
|
||
process_chatgpt_steps (bool, optional): Sollen ChatGPT-Schritte in _process_single_row ausgeführt werden?. Defaults to True.
|
||
process_website_steps (bool, optional): Soll der Website-Schritt in _process_single_row ausgeführt werden?. Defaults to True.
|
||
# Fügen Sie hier ggf. weitere Parameter hinzu, wenn Sie granularere Schritte in _process_single_row haben.
|
||
"""
|
||
logging.info(f"Starte Re-Evaluierungsmodus (Spalte A = 'x'). Max. Zeilen: {row_limit if row_limit is not None else 'Unbegrenzt'}")
|
||
# Logge, welche Schritte für Re-Eval ausgewählt wurden
|
||
selected_steps_log = []
|
||
if process_wiki_steps: selected_steps_log.append("Wiki")
|
||
if process_chatgpt_steps: selected_steps_log.append("ChatGPT")
|
||
if process_website_steps: selected_steps_log.append("Website")
|
||
logging.info(f"Ausgewählte Schritte für Re-Eval: {', '.join(selected_steps_log) if selected_steps_log else 'Keine ausgewählt!'} (force_reeval=True)")
|
||
|
||
|
||
# Daten neu laden vor der Verarbeitung
|
||
# ... (Code zum Laden der Daten, Finden der x-markierten Zeilen wie gehabt) ...
|
||
if not self.sheet_handler.load_data(): return logging.error("Fehler beim Laden der Daten für Re-Evaluation.")
|
||
all_data = self.sheet_handler.get_all_data_with_headers()
|
||
header_rows = 5
|
||
if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten für Re-Evaluation gefunden.")
|
||
reeval_col_idx = COLUMN_MAP.get("ReEval Flag")
|
||
if reeval_col_idx is None: return logging.critical("FEHLER: 'ReEval Flag' Spaltenindex nicht in COLUMN_MAP gefunden.")
|
||
|
||
rows_to_process = []
|
||
for idx_in_list in range(header_rows, len(all_data)):
|
||
row_data = all_data[idx_in_list]
|
||
row_num_in_sheet = idx_in_list + 1
|
||
if len(row_data) > reeval_col_idx and str(row_data[reeval_col_idx]).strip().lower() == "x":
|
||
rows_to_process.append({'row_num': row_num_in_sheet, 'data': row_data})
|
||
|
||
found_count = len(rows_to_process)
|
||
logging.info(f"{found_count} Zeilen mit ReEval-Flag 'x' gefunden.")
|
||
if found_count == 0: return logging.info("Keine Zeilen zur Re-Evaluation markiert.")
|
||
|
||
|
||
processed_count = 0
|
||
updates_clear_flag = []
|
||
rows_actually_processed = []
|
||
|
||
for task in rows_to_process:
|
||
if row_limit is not None and processed_count >= row_limit:
|
||
logging.info(f"Zeilenlimit ({row_limit}) für Re-Evaluation erreicht. Breche weitere Verarbeitung ab.")
|
||
break
|
||
|
||
row_num = task['row_num']
|
||
row_data = task['data']
|
||
try:
|
||
# RUFE _process_single_row MIT DEN NEUEN PARAMETERN AUF:
|
||
self._process_single_row(
|
||
row_num_in_sheet = row_num,
|
||
row_data = row_data,
|
||
process_wiki = process_wiki_steps, # <<< ÜBERGIBT DIE STEUERUNG
|
||
process_chatgpt = process_chatgpt_steps, # <<< ÜBERGIBT DIE STEUERUNG
|
||
process_website = process_website_steps, # <<< ÜBERGIBT DIE STEUERUNG
|
||
force_reeval = True # <<< BLEIBT HIER TRUE FÜR RE-EVAL MODUS
|
||
)
|
||
processed_count += 1
|
||
rows_actually_processed.append(row_num)
|
||
|
||
# Vorbereiten des Updates zum Löschen des 'x'-Flags (wie gehabt)
|
||
if clear_flag:
|
||
flag_col_letter = self.sheet_handler._get_col_letter(reeval_col_idx + 1)
|
||
if flag_col_letter: updates_clear_flag.append({'range': f'{flag_col_letter}{row_num}', 'values': [['']]})
|
||
else: logging.error(f"Fehler: Konnte Spaltenbuchstaben für 'ReEval Flag' ({reeval_col_idx+1}) nicht ermitteln.")
|
||
|
||
except Exception as e_proc:
|
||
logging.exception(f"FEHLER bei Re-Evaluation von Zeile {row_num}: {e_proc}")
|
||
|
||
# Lösche Flags am Ende (wie gehabt)
|
||
if clear_flag and updates_clear_flag:
|
||
logging.info(f"Lösche ReEval-Flags für {len(updates_clear_flag)} erfolgreich verarbeitete Zeilen ({rows_actually_processed})...")
|
||
success = self.sheet_handler.batch_update_cells(updates_clear_flag)
|
||
if success: logging.info("ReEval-Flags erfolgreich gelöscht.")
|
||
else: logging.error("FEHLER beim Löschen der ReEval-Flags.")
|
||
|
||
logging.info(f"Re-Evaluierung abgeschlossen. {processed_count} Zeilen verarbeitet (Limit war: {row_limit}, Gefunden: {found_count}).")
|
||
|
||
|
||
# --- Methode für SERP API Website Lookup ---
|
||
# Diese Methode gehört in die Klasse
|
||
def process_serp_website_lookup_for_empty(self):
|
||
"""
|
||
Sucht fehlende Websites (Spalte D ist leer oder "k.A.") via SERP API
|
||
(Google Search) und trägt gefundene URLs in Spalte D ein.
|
||
"""
|
||
logging.info("Starte Modus: SERP API Website Lookup für leere Zellen in Spalte D.")
|
||
# Daten neu laden
|
||
if not self.sheet_handler.load_data():
|
||
logging.error("Fehler beim Laden der Daten für Website Lookup.")
|
||
return
|
||
|
||
data_rows = self.sheet_handler.get_data() # Datenzeilen ohne Header
|
||
header_rows = 5 # Annahme
|
||
total_rows_in_sheet = len(self.sheet_handler.get_all_data_with_headers()) # Gesamtzahl Zeilen
|
||
|
||
rows_processed_count = 0 # Zählt Zeilen, wo ein Lookup versucht wurde
|
||
updates = []
|
||
# Definiere die Spaltenindizes innerhalb der Methode
|
||
try:
|
||
website_col_idx = COLUMN_MAP["CRM Website"]
|
||
name_col_idx = COLUMN_MAP["CRM Name"]
|
||
website_col_letter = self.sheet_handler._get_col_letter(website_col_idx + 1)
|
||
except KeyError as e:
|
||
logging.critical(f"FEHLER: Benötigte Spalte '{e}' für Modus 'website_lookup' nicht in COLUMN_MAP.")
|
||
return
|
||
except Exception as e:
|
||
logging.critical(f"FEHLER beim Holen der Spaltenbuchstaben für 'website_lookup': {e}")
|
||
return
|
||
|
||
# Iteriere über die Datenzeilen (ab der ersten möglichen Zeile, standard 7)
|
||
# min_start_row = 7 # Ggf. als Parameter übergeben
|
||
# search_start_data_index = max(0, min_start_row - header_rows - 1)
|
||
|
||
# Annahme: Wir wollen alle Zeilen prüfen, nicht nur ab einer bestimmten
|
||
for i, row in enumerate(data_rows):
|
||
row_num_in_sheet = i + header_rows + 1 # 1-basierte Zeilennummer im Sheet
|
||
|
||
# Sicherstellen, dass die Zeile lang genug ist, um auf die benötigten Spalten zuzugreifen
|
||
if len(row) <= max(website_col_idx, name_col_idx):
|
||
logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz für benötigte Spalten).")
|
||
continue
|
||
|
||
current_website = row[website_col_idx] if len(row) > website_col_idx else ""
|
||
|
||
# Prüfe, ob die Website-Spalte (D) leer, "k.A." oder nur Whitespace ist
|
||
if not current_website or str(current_website).strip().lower() == "k.a.":
|
||
company_name = row[name_col_idx] if len(row) > name_col_idx else ""
|
||
if not company_name or str(company_name).strip() == "":
|
||
logging.warning(f"Zeile {row_num_in_sheet}: Übersprungen (kein Firmenname in Spalte B für Lookup vorhanden).")
|
||
continue
|
||
|
||
logging.info(f"Zeile {row_num_in_sheet}: Suche Website für '{company_name}' in Spalte {self.sheet_handler._get_col_letter(name_col_idx+1)}...")
|
||
# Annahme: serp_website_lookup existiert und nutzt logging/retry
|
||
new_website = serp_website_lookup(company_name)
|
||
rows_processed_count += 1 # Zähle jede Zeile, für die ein Lookup versucht wurde
|
||
|
||
if new_website != "k.A.":
|
||
# Füge Update für Spalte D hinzu
|
||
updates.append({'range': f'{website_col_letter}{row_num_in_sheet}', 'values': [[new_website]]})
|
||
logging.info(f"Zeile {row_num_in_sheet}: Neue Website '{new_website}' gefunden und zum Update hinzugefügt.")
|
||
else:
|
||
# Optional: Markiere, dass kein Ergebnis gefunden wurde, falls nötig
|
||
# updates.append({'range': f'{website_col_letter}{row_num_in_sheet}', 'values': [['k.A. (kein SERP Ergebnis)']}) # Beispiel
|
||
logging.info(f"Zeile {row_num_in_sheet}: Keine Website via SERP gefunden.")
|
||
|
||
# Kleine Pause nach jedem SERP-Aufruf
|
||
time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.3)
|
||
|
||
|
||
# Sende gesammelte Updates in einem Batch
|
||
if updates:
|
||
logging.info(f"Sende Batch-Update für {len(updates)} Zellen ({rows_processed_count} Zeilen geprüft)...")
|
||
# Annahme: sheet_handler.batch_update_cells existiert und nutzt logging/retry
|
||
success = self.sheet_handler.batch_update_cells(updates)
|
||
if success:
|
||
logging.info("Batch-Update für 'website_lookup' erfolgreich.")
|
||
# Der Fehlerfall wird von batch_update_cells geloggt
|
||
else:
|
||
logging.info("Keine fehlenden Websites gefunden oder keine Updates nötig.")
|
||
|
||
logging.info(f"Modus 'website_lookup' abgeschlossen. {rows_processed_count} Zeilen geprüft.")
|
||
|
||
|
||
# --- Methode für experimentelle Website Details ---
|
||
# Diese Methode gehört in die Klasse
|
||
def process_website_details_for_marked_rows(self):
|
||
"""
|
||
EXPERIMENTELL: Extrahiert Website-Details für Zeilen, die mit 'x' in Spalte A markiert sind.
|
||
Schreibt die Details in eine definierte Spalte (Website Details oder AR als Fallback).
|
||
Löscht NICHT das 'x'-Flag.
|
||
"""
|
||
logging.info("Starte Modus (EXPERIMENTELL): Website Detail Extraction für Zeilen mit 'x' in Spalte A.")
|
||
|
||
# Daten neu laden
|
||
if not self.sheet_handler.load_data():
|
||
logging.error("Fehler beim Laden der Daten für Website Details Extraction.")
|
||
return
|
||
|
||
data_rows = self.sheet_handler.get_data() # Datenzeilen ohne Header
|
||
header_rows = 5 # Annahme
|
||
total_rows_in_sheet = len(self.sheet_handler.get_all_data_with_headers()) # Gesamtzahl Zeilen
|
||
|
||
rows_processed_count = 0 # Zählt Zeilen, wo eine Extraktion versucht wurde
|
||
updates = []
|
||
|
||
# Definiere die Spaltenindizes
|
||
try:
|
||
reeval_col_idx = COLUMN_MAP["ReEval Flag"]
|
||
website_col_idx = COLUMN_MAP["CRM Website"]
|
||
# Versuche zuerst die dedizierte Spalte 'Website Details'
|
||
details_col_idx = COLUMN_MAP.get("Website Details", None)
|
||
if details_col_idx is None:
|
||
# Fallback auf 'Website Rohtext' (AR) wenn 'Website Details' nicht in COLUMN_MAP
|
||
details_col_idx = COLUMN_MAP.get("Website Rohtext")
|
||
if details_col_idx is None:
|
||
logging.critical("FEHLER: Weder 'Website Details' noch 'Website Rohtext' Spaltenindex in COLUMN_MAP gefunden.")
|
||
return
|
||
logging.warning("Keine Spalte 'Website Details' in COLUMN_MAP, nutze 'Website Rohtext' (AR) als Fallback.")
|
||
|
||
details_col_letter = self.sheet_handler._get_col_letter(details_col_idx + 1)
|
||
|
||
except KeyError as e:
|
||
logging.critical(f"FEHLER: Benötigte Spalte '{e}' für Modus 'website_details' nicht in COLUMN_MAP.")
|
||
return
|
||
except Exception as e:
|
||
logging.critical(f"FEHLER beim Holen der Spaltenbuchstaben für 'website_details': {e}")
|
||
return
|
||
|
||
|
||
# Iteriere über die Datenzeilen (ab der ersten möglichen Zeile, standard 7)
|
||
for i, row in enumerate(data_rows):
|
||
row_num_in_sheet = i + header_rows + 1 # 1-basierte Zeilennummer im Sheet
|
||
|
||
# Prüfen, ob die Zeile mit 'x' in Spalte A markiert ist
|
||
# Stelle sicher, dass die Zeile lang genug ist für Spalte A
|
||
if len(row) <= reeval_col_idx or str(row[reeval_col_idx]).strip().lower() != "x":
|
||
# Logging kann hier sehr laut sein, nur bei Bedarf aktivieren
|
||
# logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Kein 'x' in Spalte A).")
|
||
continue
|
||
|
||
# Prüfen, ob eine gültige Website-URL vorhanden ist
|
||
website_url = ""
|
||
if len(row) > website_col_idx: website_url = row[website_col_idx]
|
||
|
||
if not website_url or str(website_url).strip().lower() == "k.a.":
|
||
logging.warning(f"Zeile {row_num_in_sheet}: Übersprungen (keine gültige Website in Spalte {self.sheet_handler._get_col_letter(website_col_idx+1)} vorhanden).")
|
||
continue
|
||
|
||
logging.info(f"Zeile {row_num_in_sheet}: Extrahiere Website Details von {website_url}...")
|
||
rows_processed_count += 1 # Zähle jede Zeile, für die Extraktion versucht wird
|
||
|
||
try:
|
||
# Annahme: Funktion scrape_website_details existiert
|
||
# Diese Funktion MUSS außerhalb der Klasse definiert sein,
|
||
# es sei denn, sie wird auch als Methode des DataProcessors gesehen.
|
||
# Angenommen, sie ist eine unabhängige Helper-Funktion.
|
||
details = scrape_website_details(website_url)
|
||
except NameError:
|
||
logging.critical("FEHLER: Funktion 'scrape_website_details' ist nicht definiert! Kann Details nicht extrahieren.")
|
||
details = "FEHLER: Funktion 'scrape_website_details' nicht definiert"
|
||
# Fehler hier abfangen, damit der Prozess nicht abstürzt, aber trotzdem loggen
|
||
except Exception as e_detail:
|
||
logging.exception(f"Fehler bei scrape_website_details für {website_url}: {e_detail}")
|
||
details = f"FEHLER: {e_detail}"
|
||
|
||
|
||
# Füge Update für die Details-Spalte hinzu
|
||
# Stelle sicher, dass der Wert in einen String konvertiert wird, falls scrape_website_details z.B. ein Dict zurückgibt
|
||
updates.append({'range': f'{details_col_letter}{row_num_in_sheet}', 'values': [[str(details)]]})
|
||
logging.info(f"Zeile {row_num_in_sheet}: Details extrahiert und zum Update für Spalte {details_col_letter} hinzugefügt.")
|
||
|
||
|
||
# Kleine Pause nach jeder Extraktion
|
||
time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.2)
|
||
|
||
|
||
# Sende gesammelte Updates in einem Batch
|
||
if updates:
|
||
logging.info(f"Sende Batch-Update für {len(updates)} Zellen ({rows_processed_count} Zeilen geprüft)...")
|
||
# Annahme: sheet_handler.batch_update_cells existiert und nutzt logging/retry
|
||
success = self.sheet_handler.batch_update_cells(updates)
|
||
if success:
|
||
logging.info("Batch-Update für 'website_details' erfolgreich.")
|
||
# Der Fehlerfall wird von batch_update_cells geloggt
|
||
else:
|
||
logging.info("Keine mit 'x' markierten Zeilen gefunden oder keine Updates nötig.")
|
||
|
||
logging.info(f"Modus 'website_details' abgeschlossen. {rows_processed_count} Zeilen geprüft.")
|
||
|
||
|
||
# --- Methode zur Datenvorbereitung für ML ---
|
||
# Diese Methode gehört in die Klasse
|
||
def prepare_data_for_modeling(self):
|
||
"""
|
||
Lädt Daten aus dem Google Sheet über den sheet_handler,
|
||
bereitet sie für das Decision Tree Modell vor:
|
||
- Wählt relevante Spalten aus.
|
||
- Konsolidiert Umsatz/Mitarbeiter (Wiki > CRM Priorität).
|
||
- Filert nach gültiger Technikerzahl (> 0).
|
||
- Erstellt die Zielvariable (Techniker-Bucket).
|
||
- Bereitet Features auf (One-Hot Encoding für Branche).
|
||
- Behält NaNs in numerischen Features für spätere Imputation.
|
||
|
||
Args:
|
||
# Kein sheet_handler Parameter mehr nötig, da es eine Methode ist und self.sheet_handler nutzt
|
||
|
||
Returns:
|
||
pandas.DataFrame: Vorbereiteter DataFrame für Training/Test-Split,
|
||
oder None bei Fehlern.
|
||
"""
|
||
logging.info("Starte Datenvorbereitung für Modellierung...")
|
||
# Nutze den self.sheet_handler der Klasse
|
||
if not self.sheet_handler or not self.sheet_handler.sheet_values:
|
||
logging.error("Fehler: Sheet Handler nicht initialisiert oder keine Daten geladen für prepare_data_for_modeling.")
|
||
# Versuche die Daten einmalig innerhalb dieser Methode zu laden, falls sie fehlen
|
||
if not self.sheet_handler.load_data():
|
||
logging.critical("Konnte Daten auch nach erneutem Versuch nicht laden. Abbruch der Datenvorbereitung.")
|
||
return None
|
||
|
||
all_data = self.sheet_handler.get_all_data_with_headers() # Nutze die im Handler geladenen Daten
|
||
# Prüfe auf ausreichende Zeilenzahl (Header + mindestens eine Datenzeile)
|
||
min_required_rows = 6 # 5 Header + 1 Datenzeile
|
||
if not all_data or len(all_data) < min_required_rows:
|
||
logging.error(f"Fehler: Nicht genügend Datenzeilen ({len(all_data)}) im Sheet gefunden für Modellierung (mindestens {min_required_rows} benötigt).")
|
||
return None
|
||
|
||
try:
|
||
# Die erste Zeile sollte die Spaltennamen enthalten
|
||
headers = all_data[0]
|
||
# Stelle sicher, dass die Header-Zeile auch die erwartete Mindestlänge hat,
|
||
# um die Spaltenindizes aus COLUMN_MAP zu finden
|
||
if len(headers) <= max(COLUMN_MAP.values()):
|
||
logging.critical(f"FEHLER: Header-Zeile ({len(headers)} Spalten) ist kürzer als der höchste Index in COLUMN_MAP ({max(COLUMN_MAP.values())}). COLUMN_MAP passt nicht zum Sheet.")
|
||
return None
|
||
|
||
except IndexError:
|
||
logging.critical("FEHLER: Sheet scheint leer zu sein oder hat keine erste Zeile, keine Header gefunden.")
|
||
return None
|
||
except ValueError as e:
|
||
logging.critical(f"FEHLER: Ungültiger Wert in COLUMN_MAP. Kann max Index nicht ermitteln: {e}")
|
||
return None
|
||
except Exception as e:
|
||
logging.critical(f"FEHLER beim Zugriff auf Header oder Prüfen der Spaltenlänge: {e}")
|
||
return None
|
||
|
||
|
||
data_rows = all_data[5:] # Annahme: Die ersten 5 Zeilen sind Header
|
||
|
||
# Erstelle DataFrame
|
||
df = pd.DataFrame(data_rows, columns=headers)
|
||
logging.info(f"Initialen DataFrame für Modellierung erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.")
|
||
|
||
# --- Spaltenauswahl und Umbenennung ---
|
||
# Definiere die notwendigen Spalten und ihre gewünschten Namen im DataFrame
|
||
col_keys = {
|
||
"name": "CRM Name", # Zur Identifikation, wird später entfernt
|
||
"branche": "CRM Branche", # Für One-Hot Encoding
|
||
"umsatz_crm": "CRM Umsatz", # Für Konsolidierung
|
||
"umsatz_wiki": "Wiki Umsatz", # Für Konsolidierung
|
||
"ma_crm": "CRM Anzahl Mitarbeiter", # Für Konsolidierung
|
||
"ma_wiki": "Wiki Mitarbeiter", # Für Konsolidierung
|
||
"techniker": "CRM Anzahl Techniker" # DIE ZIELVARIABLE (Bekannte Technikerzahl)
|
||
}
|
||
|
||
# Überprüfe, ob alle benötigten Spalten in der COLUMN_MAP vorhanden sind
|
||
missing_keys = [key for key in col_keys.values() if key not in COLUMN_MAP]
|
||
if missing_keys:
|
||
logging.critical(f"FEHLER: Folgende benötigte Spalten-Schlüssel fehlen in COLUMN_MAP für prepare_data_for_modeling: {missing_keys}.")
|
||
return None
|
||
|
||
# Erstelle das Mapping von Header-Namen zu internen Schlüsseln
|
||
header_to_internal_key = {headers[COLUMN_MAP[key]]: internal_key for internal_key, key in col_keys.items()}
|
||
|
||
# Wähle nur die benötigten Spalten im DataFrame aus
|
||
# Verwende die tatsächlichen Header-Namen aus dem Sheet für die Auswahl
|
||
cols_to_select_by_header = [headers[COLUMN_MAP[key]] for key in col_keys.values()]
|
||
|
||
try:
|
||
df_subset = df[cols_to_select_by_header].copy() # Kopie erstellen
|
||
# Benenne die Spalten um
|
||
df_subset.rename(columns=header_to_internal_key, inplace=True)
|
||
except KeyError as e:
|
||
# Dieser Fehler sollte eigentlich durch die obige Prüfung abgefangen werden,
|
||
# aber zur Sicherheit ein weiterer Check
|
||
logging.critical(f"FEHLER beim Auswählen/Umbenennen der Spalten (KeyError: {e}). Verfügbare Header im DF: {list(df.columns)}")
|
||
return None
|
||
except Exception as e:
|
||
logging.critical(f"Unerwarteter FEHLER beim Auswählen/Umbenennen der Spalten: {e}")
|
||
return None
|
||
|
||
|
||
logging.info(f"Benötigte Spalten für Modellierung ausgewählt und umbenannt: {list(df_subset.columns)}")
|
||
|
||
# --- Features konsolidieren (Umsatz, Mitarbeiter) ---
|
||
# Annahme: extract_numeric_value existiert und kann pd.Series verarbeiten (oder wird per apply genutzt)
|
||
# (Ihre Implementierung nutzt apply, was korrekt ist)
|
||
def get_valid_numeric(value_str):
|
||
"""Hilfsfunktion zur sicheren Konvertierung mit Fehlerbehandlung."""
|
||
if value_str is None or pd.isna(value_str) or str(value_str).strip() == '': return np.nan
|
||
# Annahme: extract_numeric_value existiert und gibt string 'k.A.' oder Zahl-String zurück
|
||
# Wir brauchen aber einen numerischen Wert oder np.nan
|
||
try:
|
||
# Versuche direkt die logik aus extract_numeric_value hier zu verwenden
|
||
raw_value_str = str(value_str)
|
||
processed_value = clean_text(raw_value_str) # Annahme: clean_text existiert
|
||
if processed_value == "k.A.": return np.nan
|
||
|
||
# Anpassung hier: Entferne auch Apostroph (tausendertrenner)
|
||
processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|über|unter|mehr als|weniger als|bis zu)\s+', '', processed_value)
|
||
processed_value = re.sub(r'[€$£¥]', '', processed_value).strip()
|
||
# Split bei Bindestrich (Umsatzspanne), nur ersten Teil nehmen
|
||
processed_value = re.split(r'\s*(-|–|bis)\s*', processed_value, 1)[0].strip()
|
||
processed_value = processed_value.replace('.', '').replace("'", "") # Entferne Punkte UND Apostrophe als Tausendertrenner
|
||
processed_value = processed_value.replace(',', '.') # Ersetze Komma durch Punkt für Dezimaltrennung
|
||
|
||
match = re.search(r'([\d.]+)', processed_value)
|
||
if not match: return np.nan # Keine numerischen Zeichen gefunden
|
||
|
||
num_str = match.group(1)
|
||
# Zusätzliche Prüfung: String darf nicht nur ein Punkt sein
|
||
if not num_str or num_str == '.': return np.nan
|
||
|
||
num = float(num_str) # Konvertiere zum float
|
||
|
||
# --- Einheiten-Multiplikatoren (Mrd, Mio, Tsd) ---
|
||
multiplier = 1.0
|
||
original_lower = raw_value_str.lower() # Nutze den Originalstring für Einheiten
|
||
if "mrd" in original_lower or "milliarden" in original_lower or "billion" in original_lower: multiplier = 1000000000.0
|
||
elif "mio" in original_lower or "millionen" in original_lower or "mill." in original_lower: multiplier = 1000000.0
|
||
elif "tsd" in original_lower or "tausend" in original_lower: multiplier = 1000.0
|
||
|
||
num = num * multiplier
|
||
|
||
# Optional: Runden auf ganze Zahlen für Mitarbeiter, Umsatz in Mio.
|
||
# Die extract_numeric_value Funktion hat das gemacht. Hier brauchen wir rohe Zahlen für Imputation.
|
||
# Also einfach den num zurückgeben
|
||
return num if num > 0 else np.nan # Nur positive Werte sind gültig
|
||
|
||
except (ValueError, TypeError) as e:
|
||
# Logge auf DEBUG, da dies oft vorkommt
|
||
# logging.debug(f"Konntze Wert '{str(value_str)[:50]}...' nicht als gültige Zahl parsen: {e}")
|
||
return np.nan
|
||
except Exception as e:
|
||
logging.warning(f"Unerwarteter Fehler in get_valid_numeric für Wert '{str(value_str)[:50]}...': {e}")
|
||
return np.nan
|
||
|
||
|
||
cols_to_process = {
|
||
'Umsatz': ('umsatz_wiki', 'umsatz_crm', 'Finaler_Umsatz'),
|
||
'Mitarbeiter': ('ma_wiki', 'ma_crm', 'Finaler_Mitarbeiter')
|
||
}
|
||
|
||
for base_name, (wiki_col, crm_col, final_col) in cols_to_process.items():
|
||
logging.info(f"Verarbeite und konsolidiere '{base_name}' (Priorität: Wiki > CRM)...")
|
||
# Sicherstellen, dass Spalten existieren (get_valid_numeric behandelt None)
|
||
wiki_series = df_subset[wiki_col].apply(get_valid_numeric) if wiki_col in df_subset.columns else pd.Series(np.nan, index=df_subset.index)
|
||
crm_series = df_subset[crm_col].apply(get_valid_numeric) if crm_col in df_subset.columns else pd.Series(np.nan, index=df_subset.index)
|
||
|
||
# np.where wählt den ersten Wert, wenn er nicht NaN ist, sonst den zweiten
|
||
df_subset[final_col] = np.where(
|
||
wiki_series.notna(),
|
||
wiki_series,
|
||
crm_series
|
||
)
|
||
# Info-Log über Ergebnis
|
||
logging.info(f" -> {df_subset[final_col].notna().sum()} gültige '{final_col}' Werte erstellt (von {len(df_subset)} Zeilen).")
|
||
|
||
# --- Zielvariable vorbereiten (Technikerzahl) ---
|
||
techniker_col = "techniker" # Interne Spaltenname nach Umbenennung
|
||
logging.info(f"Verarbeite Zielvariable '{techniker_col}'...")
|
||
|
||
# Konvertiere zu Numerisch (Fehler -> NaN)
|
||
# Sicherstellen, dass die Spalte existiert
|
||
if techniker_col not in df_subset.columns:
|
||
logging.critical(f"FEHLER: Zielvariable '{techniker_col}' (CRM Anzahl Techniker) nicht im DataFrame gefunden nach Umbenennung.")
|
||
return None
|
||
|
||
df_subset['Anzahl_Servicetechniker_Numeric'] = pd.to_numeric(df_subset[techniker_col], errors='coerce')
|
||
|
||
# Filtere Zeilen: Behalte nur die mit gültiger, positiver Technikerzahl
|
||
initial_rows = len(df_subset)
|
||
df_filtered = df_subset[
|
||
df_subset['Anzahl_Servicetechniker_Numeric'].notna() &
|
||
(df_subset['Anzahl_Servicetechniker_Numeric'] > 0)
|
||
].copy() # WICHTIG: .copy() um SettingWithCopyWarning zu vermeiden
|
||
filtered_rows = len(df_filtered)
|
||
removed_rows = initial_rows - filtered_rows
|
||
# Info, wenn Zeilen entfernt wurden
|
||
if removed_rows > 0:
|
||
logging.info(f"{removed_rows} Zeilen entfernt aufgrund fehlender/ungültiger Technikerzahl (Wert <= 0 oder nicht numerisch).")
|
||
logging.info(f"Verbleibende Zeilen für Modellierung (mit gültiger Technikerzahl > 0): {filtered_rows}")
|
||
|
||
if filtered_rows == 0:
|
||
logging.error("FEHLER: Keine Zeilen mit gültiger Technikerzahl (>0) übrig für Modellierung!")
|
||
return None
|
||
|
||
# --- Techniker-Buckets erstellen ---
|
||
# Die Bins und Labels müssen die gefilterten Daten widerspiegeln (die jetzt alle > 0 sind)
|
||
# Wenn die Buckets 0 beinhalten, muss die Bin-Definition angepasst werden.
|
||
# Aktuelle Definition: [-1, 0, 19, 49, 99, 249, 499, float('inf')]
|
||
# labels = ['Bucket_1_(0)', 'Bucket_2_(<20)', 'Bucket_3_(<50)', 'Bucket_4_(<100)', 'Bucket_5_(<250)', 'Bucket_6_(<500)', 'Bucket_7_(>499)']
|
||
# Da wir auf > 0 filtern, wird Bucket_1_(0) nie erreicht.
|
||
# Bins und Labels anpassen, wenn 0 ignoriert wird?
|
||
# Nein, die Labels repräsentieren Bereiche, auch wenn ein Bereich im Trainingsset nicht vorkommt.
|
||
# Wichtig ist, dass die Bins Sinn ergeben. -1 bis 0 fängt 0, 0 bis 19 fängt 1-19 etc.
|
||
# Wenn wir auf >0 filtern, wird alles < 19 in den 2. Bucket fallen, alles >=1 und <20.
|
||
# Die Bin-Definition [-1, 0, 19, 49, ...] bedeutet eigentlich:
|
||
# (-1, 0] -> <= 0
|
||
# (0, 19] -> >0 und <= 19
|
||
# (19, 49] -> >19 und <= 49
|
||
# ...
|
||
# Passt zur Filterung > 0.
|
||
bins = [-1, 0, 19, 49, 99, 249, 499, float('inf')]
|
||
labels = ['Bucket_1_(0)', 'Bucket_2_(<20)', 'Bucket_3_(<50)', 'Bucket_4_(<100)', 'Bucket_5_(<250)', 'Bucket_6_(<500)', 'Bucket_7_(>499)']
|
||
# Ensure labels match expected categories if buckets are used differently
|
||
# For DecisionTree classification, the target should be discrete labels.
|
||
# Let's assume the labels are the desired outcome categories.
|
||
df_filtered['Techniker_Bucket'] = pd.cut(
|
||
df_filtered['Anzahl_Servicetechniker_Numeric'],
|
||
bins=bins,
|
||
labels=labels,
|
||
right=True, # Das Intervall ist (linker, rechter]. also (0, 19]
|
||
include_lowest=True # Wenn bins mit -1 starten, inkludiere den niedrigsten Wert (nicht relevant bei >0 Filterung)
|
||
)
|
||
logging.info("Techniker-Buckets erstellt.")
|
||
# Verteilung als Info-Log
|
||
logging.info(f"Verteilung der Techniker-Buckets im Trainingsdatensatz:\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True).round(3)}")
|
||
# Prüfe, ob NaNs in Buckets erstellt wurden (sollte bei >0 Filterung nicht passieren)
|
||
if df_filtered['Techniker_Bucket'].isna().any():
|
||
logging.warning("WARNUNG: NaNs in Techniker-Buckets erstellt. Überprüfen Sie die bins/labels und die Filterung.")
|
||
# Optional: Zeilen mit NaN im Bucket entfernen
|
||
df_filtered.dropna(subset=['Techniker_Bucket'], inplace=True)
|
||
logging.info(f"Nach Entfernung von NaN Buckets: {len(df_filtered)} Zeilen verbleiben.")
|
||
if len(df_filtered) == 0:
|
||
logging.error("FEHLER: Keine Zeilen übrig nach Entfernung von NaN Buckets.")
|
||
return None
|
||
|
||
|
||
# --- Kategoriale Features vorbereiten (Branche) ---
|
||
branche_col = "branche" # Interne Spaltenname
|
||
logging.info(f"Verarbeite kategoriales Feature '{branche_col}' für One-Hot Encoding...")
|
||
|
||
# Stelle sicher, dass die Spalte String ist und fülle evtl. NaNs mit 'Unbekannt'
|
||
if branche_col not in df_filtered.columns:
|
||
logging.warning(f"Spalte '{branche_col}' nicht im DataFrame, One-Hot Encoding wird übersprungen.")
|
||
# Erstelle eine leere Spalte oder überspringe die One-Hot Encoding
|
||
# Lassen Sie es hier abstürzen, da Branche ein wichtiges Feature ist.
|
||
logging.critical(f"FEHLER: Spalte '{branche_col}' nicht im DataFrame für One-Hot Encoding gefunden.")
|
||
return None
|
||
|
||
df_filtered[branche_col] = df_filtered[branche_col].astype(str).fillna('Unbekannt').str.strip() # .str.strip() hinzugefügt
|
||
|
||
# One-Hot Encoding
|
||
# dummy_na=False, da wir NaNs gefüllt haben.
|
||
# prefix='Branche' ist gut.
|
||
df_encoded = pd.get_dummies(df_filtered, columns=[branche_col], prefix='Branche', dummy_na=False)
|
||
logging.info(f"One-Hot Encoding für '{branche_col}' durchgeführt. Neue Spaltenanzahl: {len(df_encoded.columns)}")
|
||
|
||
# --- Finale Auswahl der Features für das Modell ---
|
||
# Merke dir die Feature-Spalten, die tatsächlich für das Training verwendet werden sollen
|
||
feature_columns = [col for col in df_encoded.columns if col.startswith('Branche_')] # Alle One-Hot Branch-Spalten
|
||
feature_columns.extend(['Finaler_Umsatz', 'Finaler_Mitarbeiter']) # Hinzufügen der numerischen Features
|
||
|
||
# Prüfe, ob die Final-Spalten existieren (sollten sie, wurden oben erstellt)
|
||
if not all(col in df_encoded.columns for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter']):
|
||
logging.critical("FEHLER: Konsolidierte numerische Spalten 'Finaler_Umsatz' oder 'Finaler_Mitarbeiter' fehlen im DataFrame.")
|
||
return None
|
||
|
||
|
||
target_column = 'Techniker_Bucket' # Zielvariable
|
||
|
||
# Erstelle den finalen DataFrame mit den Features, dem Target und Identifikationsspalten
|
||
# Behalte Originaldaten (Name, tatsächliche Technikerzahl) für spätere Analyse / Zuordnung
|
||
original_data_cols = ['name', 'Anzahl_Servicetechniker_Numeric'] # 'name' nach Umbenennung
|
||
|
||
# Stelle sicher, dass die original_data_cols auch existieren
|
||
if not all(col in df_encoded.columns for col in original_data_cols):
|
||
logging.critical(f"FEHLER: Originaldaten-Spalten {original_data_cols} fehlen im DataFrame.")
|
||
return None
|
||
|
||
|
||
df_model_ready = df_encoded[original_data_cols + feature_columns + [target_column]].copy()
|
||
|
||
# Optional: Konvertiere numerische Spalten explizit zu Float64
|
||
for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter', 'Anzahl_Servicetechniker_Numeric']:
|
||
if col in df_model_ready.columns: # Sicherheitscheck
|
||
df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce') # errors='coerce' wandelt Fehler in NaN
|
||
|
||
# Reset Index für saubere Verarbeitung im nächsten Schritt (z.B. Train/Test-Split)
|
||
df_model_ready = df_model_ready.reset_index(drop=True)
|
||
|
||
logging.info("Datenvorbereitung für Modellierung abgeschlossen.")
|
||
logging.info(f"Finaler DataFrame für Modellierung hat {len(df_model_ready)} Zeilen und {len(df_model_ready.columns)} Spalten.")
|
||
# Logge die Anzahl der Feature-Spalten, nicht die Liste
|
||
logging.info(f"Anzahl Feature-Spalten: {len(feature_columns)}")
|
||
logging.info(f"Ziel-Spalte: {target_column}")
|
||
|
||
# WICHTIG: Info über fehlende Werte in den finalen numerischen Features vor Imputation
|
||
nan_counts = df_model_ready[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].isna().sum()
|
||
logging.info(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}")
|
||
# Logge auch, wie viele Zeilen *mindestens* einen NaN haben
|
||
rows_with_nan = df_model_ready[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].isna().any(axis=1).sum()
|
||
logging.info(f"Anzahl Zeilen mit mindestens einem fehlenden numerischen Feature: {rows_with_nan}")
|
||
|
||
|
||
return df_model_ready
|
||
|
||
# --- Methode für sequenzielle Verarbeitung (full_run) ---
|
||
# Diese Methode gehört in die Klasse
|
||
def process_rows_sequentially(self, start_data_index, num_to_process,
|
||
process_wiki=True, process_chatgpt=True, process_website=True):
|
||
"""
|
||
Verarbeitet eine feste Anzahl von Zeilen beginnend bei einem bestimmten Datenindex
|
||
sequenziell, eine nach der anderen, unter Verwendung von _process_single_row.
|
||
Prüft KEINE Timestamps oder ReEval-Flags intern, _process_single_row tut dies.
|
||
|
||
Args:
|
||
start_data_index (int): Der 0-basierte Index in der Datenliste (ohne Header).
|
||
num_to_process (int): Die maximale Anzahl der zu verarbeitenden Zeilen.
|
||
process_wiki (bool, optional): Soll Wiki-Verarbeitung durchgeführt werden?. Defaults to True.
|
||
process_chatgpt (bool, optional): Sollen ChatGPT-Evaluationen durchgeführt werden?. Defaults to True.
|
||
process_website (bool, optional): Soll Website-Verarbeitung durchgeführt werden?. Defaults to True.
|
||
"""
|
||
header_rows = 5 # Annahme
|
||
|
||
logging.info(f"Starte sequenzielle Verarbeitung von {num_to_process} Zeilen ab Daten-Index {start_data_index}...")
|
||
|
||
# Lade Daten einmalig vor der Verarbeitung
|
||
if not self.sheet_handler.load_data():
|
||
logging.error("Fehler beim Laden der Daten für sequenzielle Verarbeitung.")
|
||
return
|
||
|
||
all_data = self.sheet_handler.get_all_data_with_headers()
|
||
total_data_rows = len(all_data) - header_rows
|
||
|
||
if start_data_index >= total_data_rows:
|
||
logging.warning(f"Start-Datenindex {start_data_index} liegt außerhalb der verfügbaren Daten ({total_data_rows} Datenzeilen). Keine Verarbeitung.")
|
||
return
|
||
|
||
# Berechne den tatsächlichen End-Datenindex (exklusiv)
|
||
end_data_index = min(start_data_index + num_to_process, total_data_rows)
|
||
|
||
logging.info(f"Sequenzielle Verarbeitung: Daten-Index Bereich [{start_data_index}, {end_data_index})")
|
||
# Übersetze in Sheet-Zeilennummern für Logging
|
||
start_sheet_row = start_data_index + header_rows + 1
|
||
end_sheet_row_inclusive = end_data_index + header_rows # Das Ende ist exklusiv, also ist die letzte Zeile am Index end_data_index-1
|
||
|
||
logging.info(f"Entsprechende Sheet-Zeilen (1-basiert): {start_sheet_row} bis {end_sheet_row_inclusive}")
|
||
|
||
|
||
processed_count = 0
|
||
# Iteriere über die Datenzeilen im angegebenen Bereich
|
||
for i in range(start_data_index, end_data_index):
|
||
row_num_in_sheet = i + header_rows + 1 # 1-basierte Zeilennummer
|
||
row_data = all_data[i + header_rows] # Tatsächliche Zeilendaten aus der Gesamtliste
|
||
|
||
try:
|
||
# Rufe die Methode zur Verarbeitung einer einzelnen Zeile auf
|
||
# _process_single_row wird intern die Timestamps prüfen (außer force_reeval)
|
||
self._process_single_row(row_num_in_sheet, row_data,
|
||
process_wiki=process_wiki,
|
||
process_chatgpt=process_chatgpt,
|
||
process_website=process_website,
|
||
force_reeval=False) # Im full_run Modus normalerweise KEIN Re-Eval erzwingen
|
||
|
||
processed_count += 1
|
||
|
||
except Exception as e_proc:
|
||
# Logge den spezifischen Fehler für diese Zeile, fahre aber fort
|
||
logging.exception(f"FEHLER bei sequenzieller Verarbeitung von Zeile {row_num_in_sheet}: {e_proc}")
|
||
# Optional: Hier könnte man ein Flag in der Zeile setzen, um den Fehler zu markieren
|
||
|
||
logging.info(f"Sequenzielle Verarbeitung abgeschlossen. {processed_count} Zeilen verarbeitet im Bereich [{start_data_index}, {end_data_index}).")
|
||
|
||
|
||
# ==================== MAIN FUNCTION ====================
|
||
# ==================== MAIN FUNCTION ====================
|
||
# Diese Funktion ist der Haupteinstiegspunkt des Skripts.
|
||
|
||
def main():
|
||
# WICHTIG: Global LOG_FILE wird benötigt, aber erst nach Arg-Parsing gesetzt.
|
||
global LOG_FILE
|
||
|
||
# --- Initial Logging Setup (Konfiguration von Level und Format) ---
|
||
# Diese Konfiguration wird wirksam, sobald die Handler hinzugefügt werden.
|
||
import logging
|
||
log_level = logging.DEBUG # Explizit DEBUG setzen für detaillierte Logs
|
||
log_format = '%(asctime)s - %(levelname)-8s - %(name)-15s - %(message)s' # Angepasstes Format
|
||
|
||
# Root-Logger konfigurieren (noch ohne File Handler)
|
||
# handlers=[] verhindert default Console Handler, wir fügen ihn manuell hinzu
|
||
logging.basicConfig(level=log_level, format=log_format, handlers=[])
|
||
|
||
# Console Handler explizit hinzufügen
|
||
console_handler = logging.StreamHandler()
|
||
console_handler.setLevel(log_level) # Nimm das globale Level
|
||
console_handler.setFormatter(logging.Formatter(log_format))
|
||
logging.getLogger('').addHandler(console_handler) # Füge zum Root-Logger hinzu
|
||
# --- Ende Initial Logging Setup ---
|
||
|
||
# Testnachricht (geht nur an Konsole, da File Handler noch fehlt)
|
||
logging.debug("DEBUG Logging initial konfiguriert (nur Konsole).")
|
||
logging.info("INFO Logging initial konfiguriert (nur Konsole).")
|
||
|
||
# --- Initialisierung (Argument Parser etc.) ---
|
||
# Version hier (sollte mit Config.VERSION übereinstimmen)
|
||
current_script_version = "v1.6.6" # <-- ANPASSEN, wenn Config.VERSION geändert wird
|
||
|
||
parser = argparse.ArgumentParser(description=f"Firmen-Datenanreicherungs-Skript {current_script_version}")
|
||
# Liste der gültigen Modi (basierend auf Ihrer aktuellen v1.6.6 + dem neuen Modus)
|
||
valid_modes = [
|
||
"combined", "wiki", "website", "branch", "summarize", "reeval",
|
||
"website_lookup", "website_details", "contacts", "full_run",
|
||
"alignment", "train_technician_model", "update_wiki",
|
||
"find_wiki_serp", "wiki_reextract" # <<< NEUER MODUS HIER HINZUGEFÜGT
|
||
]
|
||
# Stellen Sie sicher, dass diese Liste mit denelif-Zweigen unten übereinstimmt.
|
||
|
||
parser.add_argument("--mode", type=str, help=f"Betriebsmodus ({', '.join(valid_modes)})")
|
||
parser.add_argument("--limit", type=int, help="Maximale Anzahl zu verarbeitender Zeilen", default=None)
|
||
# start_row wird primär für full_run verwendet, kann aber generell hilfreich sein
|
||
parser.add_argument("--start_row", type=int, help="Startzeile im Sheet (1-basiert) für sequenzielle Modi", default=None)
|
||
|
||
# NEUES ARGUMENT für den Re-Eval Modus zur Auswahl der Schritte
|
||
# Standard ist "wiki,chat,web", um das bisherige Verhalten zu imitieren
|
||
# Mögliche Werte für die Schritte: 'wiki', 'chat', 'web' (entsprechend den Parametern in _process_single_row)
|
||
parser.add_argument("--steps", type=str, help="Komma-getrennte Liste der Schritte im 'reeval' Modus (z.B. 'wiki,chat,web'). Mögliche Schritte: wiki, chat, web.", default="wiki,chat,web")
|
||
|
||
# Argumente für find_wiki_serp (falls über CLI gesteuert)
|
||
parser.add_argument("--min_umsatz", type=int, help="Mindestumsatz in Mio € für find_wiki_serp", default=200)
|
||
parser.add_argument("--min_employees", type=int, help="Mindestmitarbeiterzahl für find_wiki_serp", default=500)
|
||
|
||
# Argumente für train_technician_model
|
||
parser.add_argument("--model_out", type=str, default=MODEL_FILE, help=f"Pfad für Modell (.pkl)")
|
||
parser.add_argument("--imputer_out", type=str, default=IMPUTER_FILE, help=f"Pfad für Imputer (.pkl)")
|
||
parser.add_argument("--patterns_out", type=str, default=PATTERNS_FILE_TXT, help=f"Pfad für Regeln (.txt)")
|
||
|
||
# TODO: Fügen Sie hier weitere CLI-Argumente hinzu, falls andere Modi Parameter benötigen (z.B. für Kriterien-Modus)
|
||
|
||
args = parser.parse_args()
|
||
|
||
|
||
# Lade API Keys direkt am Anfang
|
||
Config.load_api_keys() # Nutzt jetzt logging intern
|
||
|
||
# --- Logdatei-Konfiguration abschließen ---
|
||
# Bestimmen Sie den Log-Modus Namen basierend auf CLI oder Interaktion
|
||
# Wir nutzen den CLI Modus Namen, wenn er gesetzt ist, sonst einen Platzhalter.
|
||
# Der tatsächliche Modus wird unten ermittelt und geloggt.
|
||
log_mode_name = args.mode if args.mode else "interactive"
|
||
LOG_FILE = create_log_filename(log_mode_name) # Annahme: create_log_filename ist global
|
||
|
||
try:
|
||
file_handler = logging.FileHandler(LOG_FILE, mode='a', encoding='utf-8')
|
||
file_handler.setLevel(log_level) # Nimm das globale Level
|
||
file_handler.setFormatter(logging.Formatter(log_format))
|
||
# Füge FileHandler zum Root-Logger hinzu
|
||
logging.getLogger('').addHandler(file_handler)
|
||
logging.info(f"Logging wird jetzt auch in Datei geschrieben: {LOG_FILE}")
|
||
except Exception as e:
|
||
# Logge Fehler nur auf Konsole, da FileHandler fehlgeschlagen ist
|
||
print(f"[ERROR] Konnte FileHandler für Logdatei '{LOG_FILE}' nicht erstellen: {e}")
|
||
logging.getLogger('').handlers = [h for h in logging.getLogger('').handlers if not isinstance(h, logging.FileHandler)] # Entferne evtl. defekten Handler
|
||
logging.error(f"Konnte FileHandler für Logdatei '{LOG_FILE}' nicht erstellen: {e}")
|
||
|
||
|
||
# --- JETZT die Startmeldungen loggen (gehen jetzt in Konsole UND Datei) ---
|
||
logging.info(f"===== Skript gestartet =====")
|
||
logging.info(f"Version: {Config.VERSION}") # Sollte jetzt v1.6.6 sein
|
||
# Der Modus wird später vom Dispatcher geloggt
|
||
logging.info(f"Logdatei: {LOG_FILE}")
|
||
# Loggen Sie auch die Re-Eval Schritte, wenn das Argument gesetzt ist (unabhängig vom gewählten Modus, zur Info)
|
||
if 'steps' in args and args.steps:
|
||
logging.info(f"CLI Argument --steps: '{args.steps}' (relevant für 'reeval' Modus)")
|
||
if 'min_umsatz' in args: logging.info(f"CLI Argument --min_umsatz: {args.min_umsatz}")
|
||
if 'min_employees' in args: logging.info(f"CLI Argument --min_employees: {args.min_employees}")
|
||
if 'model_out' in args: logging.info(f"CLI Argument --model_out: '{args.model_out}'")
|
||
# ... loggen Sie weitere relevante CLI Argumente
|
||
|
||
|
||
# --- Vorbereitung (Schema, Sheet Handler etc.) ---
|
||
load_target_schema() # Annahme: load_target_schema ist global definiert
|
||
|
||
try:
|
||
sheet_handler = GoogleSheetHandler() # Annahme: GoogleSheetHandler ist global definiert
|
||
except Exception as e:
|
||
logging.critical(f"FATAL: Initialisierung des GoogleSheetHandlers fehlgeschlagen: {e}")
|
||
logging.critical(f"Bitte Logdatei prüfen: {LOG_FILE}")
|
||
return # Beende Skript, wenn Sheet nicht geladen werden kann
|
||
|
||
try:
|
||
# Initialisiere WikipediaScraper hier, da er an DataProcessor übergeben werden muss
|
||
wiki_scraper = WikipediaScraper() # Annahme: WikipediaScraper ist global definiert und benötigt keine Parameter oder nutzt Config
|
||
except Exception as e:
|
||
logging.critical(f"FATAL: Initialisierung des WikipediaScrapers fehlgeschlagen: {e}")
|
||
logging.critical(f"Bitte Logdatei prüfen: {LOG_FILE}")
|
||
# Das Skript kann ohne Wiki Scraper nicht sinnvoll laufen
|
||
return
|
||
|
||
# Initialisiere DataProcessor Instanz mit Handlern
|
||
# PASSEN SIE DIESEN AUFRUF AN DIE TATSÄCHLICHE __init__ SIGNATUR IHRER DataProcessor Klasse an
|
||
# In v1.6.6 nahm sie nur sheet_handler entgegen. Für den Refactoring-Plan soll sie wiki_scraper auch nehmen.
|
||
# Für diese Übergangsversion halten wir uns an die v1.6.6 Signatur (nur sheet_handler)
|
||
# ABER: Methoden IN DataProcessor (wie _process_single_row) brauchen den wiki_scraper!
|
||
# Das bedeutet, wiki_scraper muss in __init__ übergeben und als self.wiki_scraper gespeichert werden.
|
||
# KORRIGIEREN SIE DataProcessor.__init__ ZU: def __init__(self, sheet_handler, wiki_scraper):
|
||
data_processor = DataProcessor(sheet_handler, wiki_scraper) # <<< KORRIGIERTER AUFRUF
|
||
|
||
# --- Modusauswahl und Ausführung ---
|
||
start_time = time.time()
|
||
logging.info(f"Starte Verarbeitung um {datetime.now().strftime('%H:%M:%S')}...")
|
||
|
||
mode = None # Wird aus CLI oder Interaktion ermittelt
|
||
|
||
# --- Ermitteln des zu führenden Modus (CLI hat Priorität) ---
|
||
if args.mode:
|
||
mode = args.mode.lower()
|
||
if mode not in valid_modes:
|
||
logging.error(f"Ungültiger Modus '{args.mode}' über Kommandozeile angegeben. Gültige Modi: {', '.join(valid_modes)}")
|
||
print(f"Fehler: Ungültiger Modus '{args.mode}'. Siehe --help.")
|
||
return # Skript beenden
|
||
logging.info(f"Betriebsmodus (CLI gewählt): {mode}")
|
||
else:
|
||
# --- Interaktive Modusauswahl ---
|
||
print("\nBitte wählen Sie den Betriebsmodus:")
|
||
# Zeigen Sie die Liste der validen Modi an
|
||
for i, m in enumerate(valid_modes):
|
||
print(f" {i+1}: {m}")
|
||
|
||
while mode is None: # Schleife, bis ein gültiger Modus gewählt wurde
|
||
try:
|
||
mode_input = input(f"Geben Sie den Modusnamen oder die Zahl ein: ").strip().lower()
|
||
try:
|
||
mode_index = int(mode_input)
|
||
if 1 <= mode_index <= len(valid_modes): mode = valid_modes[mode_index - 1]
|
||
else: print("Ungültige Zahl.")
|
||
except ValueError:
|
||
if mode_input in valid_modes: mode = mode_input
|
||
else: print("Ungültige Eingabe.")
|
||
|
||
if mode: logging.info(f"Betriebsmodus (interaktiv gewählt): {mode}")
|
||
# Wenn mode None bleibt, Schleife läuft weiter
|
||
|
||
except Exception as e:
|
||
logging.error(f"Fehler bei interaktiver Modus-Eingabe: {e}"); return # Skript beenden
|
||
print(f"Fehler Modus-Eingabe ({e}).")
|
||
|
||
|
||
# --- Ausführung des gewählten Modus ---
|
||
try:
|
||
# Rufen Sie die entsprechenden Funktionen/Methoden auf basierend auf dem gewählten 'mode'
|
||
# Die Aufrufe hier werden auf die 'data_processor' Instanz umgestellt,
|
||
# da die Funktionen jetzt Methoden dieser Klasse sind (oder es sein sollten).
|
||
|
||
if mode == "combined":
|
||
# Der combined Mode war ein globaler run_dispatcher Aufruf.
|
||
# run_dispatcher sollte eine Methode in DataProcessor sein.
|
||
data_processor.run_batch_dispatcher(mode="combined", limit=args.limit) # Annahme: run_batch_dispatcher existiert in DataProcessor
|
||
|
||
elif mode == "wiki": # Entspricht dem Batch-Modus Wiki Verifizierung (AX)
|
||
# process_verification_only sollte jetzt data_processor.process_verification_batch sein
|
||
data_processor.process_verification_batch(limit=args.limit)
|
||
|
||
elif mode == "website": # Entspricht dem Batch-Modus Website Scraping (AT)
|
||
# process_website_batch sollte jetzt data_processor.process_website_batch sein
|
||
data_processor.process_website_batch(limit=args.limit)
|
||
|
||
elif mode == "summarize": # Entspricht dem Batch-Modus Website Summarization (AS)
|
||
# process_website_summarization_batch sollte jetzt data_processor.process_summarization_batch sein
|
||
data_processor.process_summarization_batch(limit=args.limit)
|
||
|
||
elif mode == "branch": # Entspricht dem Batch-Modus Branchen-Einstufung (AO)
|
||
# process_branch_batch sollte jetzt data_processor.process_branch_batch sein
|
||
data_processor.process_branch_batch(limit=args.limit)
|
||
|
||
elif mode == "reeval": # process_reevaluation_rows
|
||
if args.limit is not None and args.limit <= 0:
|
||
logging.info(f"Limit {args.limit} angegeben im Re-Eval Modus. Überspringe Verarbeitung.")
|
||
else:
|
||
# Parse das neue --steps Argument
|
||
steps_list = [step.strip().lower() for step in args.steps.split(',')]
|
||
# Mappen Sie die CLI-Schrittnamen auf die Parameter von process_reevaluation_rows
|
||
# Die Parameter in process_reevaluation_rows (v1.6.6 Anpassung) sind:
|
||
# process_wiki_steps, process_chatgpt_steps, process_website_steps
|
||
process_wiki_flag = 'wiki' in steps_list
|
||
process_chatgpt_flag = 'chat' in steps_list
|
||
process_website_flag = 'web' in steps_list
|
||
# Wenn Ihre process_reevaluation_rows weitere boolsche Flags akzeptiert, mappen Sie die entsprechenden CLI-Namen hier.
|
||
|
||
# Rufen Sie process_reevaluation_rows mit den ausgelesenen Flags auf
|
||
# process_reevaluation_rows ist eine Methode in DataProcessor.
|
||
data_processor.process_reevaluation_rows(
|
||
row_limit=args.limit,
|
||
clear_flag=True, # Standardmäßig Flag 'x' löschen
|
||
process_wiki_steps=process_wiki_flag, # <<< ÜBERGIBT DIE STEUERUNG
|
||
process_chatgpt_steps=process_chatgpt_flag, # <<< ÜBERGIBT DIE STEUERUNG
|
||
process_website_steps=process_website_flag
|
||
# Wenn Ihre process_reevaluation_rows weitere Parameter hat, übergeben Sie diese hier
|
||
)
|
||
|
||
elif mode == "website_lookup":
|
||
# process_serp_website_lookup_for_empty sollte jetzt data_processor.process_serp_website_lookup sein
|
||
data_processor.process_serp_website_lookup(limit=args.limit) # Fügen Sie hier den Limit Parameter hinzu, falls gewünscht/unterstützt
|
||
|
||
elif mode == "website_details":
|
||
# process_website_details_for_marked_rows sollte jetzt data_processor.process_website_details sein
|
||
data_processor.process_website_details(limit=args.limit) # Fügen Sie hier den Limit Parameter hinzu, falls gewünscht/unterstützt
|
||
|
||
elif mode == "contacts":
|
||
# process_contact_research sollte jetzt data_processor.process_contact_research sein
|
||
data_processor.process_contact_research(limit=args.limit) # Fügen Sie hier den Limit Parameter hinzu, falls gewünscht/unterstützt
|
||
|
||
elif mode == "full_run": # process_rows_sequentially
|
||
# process_rows_sequentially ist eine Methode in DataProcessor.
|
||
# Der Aufruf muss hier implementiert werden (Startindex Logic etc., wie im alten main Block).
|
||
logging.warning("Modus 'full_run' benötigt noch die Implementierung des Aufrufs von process_sequential.")
|
||
# Beispielaufruf (wenn process_sequential eine Methode ist):
|
||
# # start_data_index logic (wie im alten main block)
|
||
# header_rows = 5 # Annahme
|
||
# start_data_index = 0 # Default
|
||
# if args.start_row is not None:
|
||
# start_data_index = args.start_row - 1 # 0-based
|
||
# if start_data_index < header_rows: logging.warning(f"Manuelle Startzeile {args.start_row} liegt innerhalb der Header."); start_data_index = header_rows
|
||
# else:
|
||
# # Automatische Ermittlung der Startzeile (z.B. erste Zeile ohne AO)
|
||
# logging.info("Automatische Ermittlung der Startzeile für sequenzielle Verarbeitung (erste Zeile ohne AO)...")
|
||
# # get_start_row_index gibt 0-basierter Index in Daten (ohne Header) zurück
|
||
# start_data_index_no_header = sheet_handler.get_start_row_index(check_column_key="Timestamp letzte Prüfung")
|
||
# if start_data_index_no_header == -1: logging.error("FEHLER bei automatischer Ermittlung der Startzeile."); return
|
||
# start_data_index = start_data_index_no_header + header_rows # 0-based index in all_data
|
||
#
|
||
# # Berechne num_to_process
|
||
# if not sheet_handler.load_data(): logging.error("Fehler beim Laden der Daten."); return
|
||
# total_rows = len(sheet_handler.get_all_data_with_headers())
|
||
# num_available = total_rows - start_data_index # Anzahl Zeilen ab Startindex
|
||
# num_to_process = num_available
|
||
# if args.limit is not None and args.limit >= 0:
|
||
# num_to_process = min(num_available, args.limit)
|
||
#
|
||
# if num_to_process > 0:
|
||
# logging.info(f"'full_run': Verarbeite {num_to_process} Zeilen ab Sheet-Zeile {start_data_index + 1}.")
|
||
# # Hier müssten Sie auch die Flags für die Schritte abfragen/übergeben
|
||
# # Für full_run würden Sie wahrscheinlich alle Schritte wählen (oder über neues Argument steuern)
|
||
# data_processor.process_sequential(
|
||
# start_sheet_row = start_data_index + 1, # 1-basierte Startzeile
|
||
# num_to_process = num_to_process,
|
||
# process_wiki=True, # Beispiel: Alle Schritte
|
||
# process_chatgpt=True,
|
||
# process_website=True
|
||
# # Wenn process_sequential granularere Flags nimmt, übergeben Sie diese hier
|
||
# )
|
||
# else: logging.info("Keine Zeilen für 'full_run' zu verarbeiten.")
|
||
|
||
|
||
elif mode == "alignment":
|
||
# alignment_demo ist global und braucht sheet_handler.sheet
|
||
alignment_demo(sheet_handler.sheet) # Stellen Sie sicher, dass alignment_demo global bleibt
|
||
|
||
elif mode == "train_technician_model":
|
||
# train_technician_model sollte jetzt data_processor.train_technician_model sein
|
||
data_processor.train_technician_model(model_out=args.model_out, imputer_out=args.imputer_out, patterns_out=args.patterns_out) # Argumente übergeben
|
||
|
||
elif mode == "update_wiki":
|
||
# process_wiki_updates_from_chatgpt sollte jetzt data_processor.process_wiki_updates_from_chatgpt sein
|
||
data_processor.process_wiki_updates_from_chatgpt(row_limit=args.limit) # row_limit Parameter hinzufügen
|
||
|
||
elif mode == "find_wiki_serp":
|
||
# process_find_wiki_with_serp sollte jetzt data_processor.process_find_wiki_serp sein
|
||
data_processor.process_find_wiki_serp(row_limit=args.limit, min_employees=args.min_employees, min_umsatz=args.min_umsatz) # min_employees und min_umsatz hinzufügen
|
||
|
||
|
||
elif mode == "wiki_reextract": # <<< NEUER MODUS RUFT NEUE FUNKTION AUF
|
||
# Rufe die neu erstellte globale Funktion auf, die sheet_handler und data_processor benötigt
|
||
# Diese Funktion implementiert die Kriterien-Logik "M gefüllt & AN leer" und ruft dann _process_single_row
|
||
# mit den spezifischen Flags (nur Wiki) und force_reeval=True auf.
|
||
process_wiki_reextract_missing_an(sheet_handler, data_processor, limit=args.limit) # Annahme: process_wiki_reextract_missing_an ist global definiert
|
||
|
||
|
||
else:
|
||
# Dies sollte nicht passieren, wenn die Validierung oben korrekt ist
|
||
logging.error(f"Unerwarteter Modus '{mode}' erreicht das Ausführungsende.")
|
||
|
||
|
||
except KeyboardInterrupt:
|
||
logging.warning("Skript durch Benutzer unterbrochen (KeyboardInterrupt).")
|
||
print("\n! Skript wurde manuell beendet.")
|
||
except Exception as e:
|
||
# Dieser Block fängt Fehler ab, die in den aufgerufenen Funktionen/Methoden passieren
|
||
logging.critical(f"FATAL: Unerwarteter Fehler während der Ausführung von Modus '{mode}': {e}")
|
||
logging.exception("Traceback des kritischen Fehlers:")
|
||
|
||
# --- Abschluss ---
|
||
end_time = time.time()
|
||
duration = end_time - start_time
|
||
logging.info(f"Verarbeitung abgeschlossen um {datetime.now().strftime('%H:%M:%S')}.")
|
||
logging.info(f"Gesamtdauer: {duration:.2f} Sekunden.")
|
||
logging.info(f"===== Skript beendet =====")
|
||
|
||
# Schließe Logging Handler explizit
|
||
logging.shutdown()
|
||
|
||
# Logfile Pfad für den Nutzer ausgeben
|
||
print(f"\nVerarbeitung abgeschlossen. Logfile: {LOG_FILE}")
|
||
|
||
|
||
# Führt die main-Funktion aus, wenn das Skript direkt gestartet wird
|
||
if __name__ == '__main__':
|
||
# --- Sicherstellen, dass alle globalen Imports hier sind ---
|
||
# ... (alle Imports wie am Anfang des Skripts) ...
|
||
|
||
# --- Sicherstellen, dass alle globalen Helfer-Funktionen hier oder importiert sind ---
|
||
# ... (Alle Ihre globalen Helfer-Funktionen: clean_text, normalize_company_name,
|
||
# extract_numeric_value, get_numeric_filter_value, call_openai_chat, serp_wikipedia_lookup,
|
||
# serp_website_lookup, search_linkedin_contacts, get_gender, get_email_address,
|
||
# fuzzy_similarity, is_valid_wikipedia_article_url, evaluate_branche_chatgpt,
|
||
# summarize_website_content, load_target_schema, map_external_branch, alignment_demo,
|
||
# retry_on_failure, create_log_filename, debug_print, _process_batch (falls global)) ...
|
||
|
||
# NEU: Die Kriterien-Funktion und die Funktion, die den neuen Modus steuert, müssen hier global sein
|
||
# Kopieren Sie die Definitionen von criteria_m_filled_an_empty und process_wiki_reextract_missing_an hierher.
|
||
|
||
# --- Sicherstellen, dass alle Klassen hier definiert sind ---
|
||
# ... (Config, GoogleSheetHandler, WikipediaScraper) ...
|
||
# KORRIGIERTE DataProcessor Klasse Definition (mit __init__(self, sheet_handler, wiki_scraper))
|
||
# und allen Methoden, die Sie bis jetzt hatten, IN DER KLASSE eingerückt.
|
||
|
||
# Die main Funktion aufrufen
|
||
main() |