From 901925651b2ab0a5ca5cf7f0e0ebdb119ec512c1 Mon Sep 17 00:00:00 2001 From: Floke Date: Fri, 20 Jun 2025 16:16:15 +0000 Subject: [PATCH] =?UTF-8?q?Refactor:=20Reset=20auf=20v1.7.8=20&=20Implemen?= =?UTF-8?q?tierung=20von=20Branchen-Gruppen=20f=C3=BCr=20ML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Code-Basis zurückgesetzt: Der Code wurde auf einen stabilen Stand (entsprechend der Diskussion um v1.7.8) zurückgesetzt, um persistente Initialisierungsfehler zu beheben und eine saubere Grundlage für weitere Verbesserungen zu schaffen. - Branchen-Clustering implementiert: Um die Qualität der Features für das ML-Modell zu verbessern, wird nun nicht mehr die detaillierte Einzelbranche, sondern eine übergeordnete Branchen-Gruppe als Feature verwendet. - Hartcodiertes Branchen-Mapping: Anstatt die Gruppen aus einer externen CSV-Datei zu laden (was zu Fehlern führte), ist das Mapping von Detail-Branche zu Branchen-Gruppe jetzt als statisches Dictionary direkt in der `Config`-Klasse hinterlegt. Dies eliminiert eine externe Abhängigkeit und erhöht die Stabilität. - Angepasste Datenvorbereitung: Die Funktion `prepare_data_for_modeling` wurde überarbeitet. Sie führt nun das Mapping auf die Branchen-Gruppen durch und verwendet das Ergebnis für das One-Hot-Encoding. Die finale Feature-Liste wurde entsprechend angepasst. - Code-Vereinfachung: Die Funktion `load_branch_mapping()` und zugehörige Logik wurden entfernt, da sie durch das hartcodierte Mapping obsolet geworden sind. --- brancheneinstufung.py | 1383 ++++++++++++++++++++++++++--------------- 1 file changed, 874 insertions(+), 509 deletions(-) diff --git a/brancheneinstufung.py b/brancheneinstufung.py index 2ff097c17..f6e381f75 100644 --- a/brancheneinstufung.py +++ b/brancheneinstufung.py @@ -1,11 +1,27 @@ #!/usr/bin/env python3 """ -Automatisiertes Unternehmensbewertungs-Skript - v1.7.9 +Automatisiertes Unternehmensbewertungs-Skript - Refactoring v1.7.8 +Basierend auf v1.6.x - Umstrukturierung in modulare Klassen und flexibles UI. + +Dieses Skript dient der automatisierten Anreicherung, Validierung und Standardisierung +von Unternehmensdaten, primär aus einem Google Sheet, ergänzt durch Web Scraping, +Wikipedia, OpenAI (ChatGPT) und SerpAPI (Google Search, LinkedIn). + +Autor: Christian Godelmann +Version: v1.7.8 + +Hinweis zur Struktur: +Dieser Code wird in logischen Bloecken uebermittelt. Fuegen Sie die Bloecke +nacheinander in diese einzige Datei ein, achten Sie sorgfaeltig auf die +Einrueckung. Jeder Block muss auf oberster Ebene eingefuegt werden (keine Einrueckung). +Die Kommentare wie '# ==================================================' +markieren den Beginn neuer logischer Sektionen oder Klassen. """ # ============================================================================== # 1. IMPORTS # ============================================================================== +# Standardbibliotheken import os import time import re @@ -14,19 +30,28 @@ import json import pickle import threading import traceback -import logging +import logging # logging Modul importieren import argparse -import random +import random # Fuer Jitter im Retry Decorator from datetime import datetime from urllib.parse import urlparse, urlencode, unquote -import openai +import openai # Sicherstellen, dass openai global importiert wird +# Externe Bibliotheken import gspread +# Stellen Sie sicher, dass gspread >= 5.0.0 installiert ist, da APIError anders behandelt wird +# (Unser Code sollte mit den neueren Versionen kompatibel sein) import wikipedia -import requests -from bs4 import BeautifulSoup -from oauth2client.service_account import ServiceAccountCredentials -from difflib import SequenceMatcher -import unicodedata +# Stellen Sie sicher, dass wikipedia-api nicht gleichzeitig installiert ist (Konflikt). +import requests # Fuer HTTP-Anfragen +# Stellen Sie sicher, dass requests >= 2.0.0 installiert ist +from bs4 import BeautifulSoup # Fuer HTML-Parsing +# Stellen Sie sicher, dass lxml oder html5lib installiert ist (empfohlen statt html.parser) +# z.B. pip install lxml +from oauth2client.service_account import ServiceAccountCredentials # gspread dependency +from difflib import SequenceMatcher # Fuer String-Aehnlichkeit +import unicodedata # Fuer Text-Normalisierung + +# Bibliotheken fuer Datenanalyse und ML import pandas as pd import numpy as np from imblearn.over_sampling import SMOTE @@ -35,61 +60,86 @@ from sklearn.model_selection import train_test_split, GridSearchCV from sklearn.impute import SimpleImputer from sklearn.tree import DecisionTreeClassifier, export_text from sklearn.metrics import accuracy_score, classification_report, confusion_matrix -import concurrent.futures -from imblearn.pipeline import Pipeline as ImbPipeline +import concurrent.futures # Fuer parallele Verarbeitung +from sklearn.model_selection import GridSearchCV +from imblearn.pipeline import Pipeline as ImbPipeline # Alias, um Kollision mit sklearn.pipeline zu vermeiden +# Spezifische externe Tools try: - import gender_guesser.detector as gender + import gender_guesser.detector as gender # Fuer Geschlechtserkennung + # Initialisieren Sie den Detector einmal global (wird im Helper benutzt) gender_detector = gender.Detector() print("gender_guesser.Detector initialisiert.") except ImportError: - gender = None; gender_detector = None - print("gender_guesser nicht gefunden.") + gender = None + gender_detector = None + print("gender_guesser Bibliothek nicht gefunden. Geschlechtserkennung deaktiviert.") except Exception as e: - gender = None; gender_detector = None - print(f"Fehler bei Initialisierung von gender_guesser: {e}.") + gender = None + gender_detector = None + print(f"Fehler bei Initialisierung von gender_guesser: {e}. Geschlechtserkennung deaktiviert.") + +# Optional: tiktoken fuer Token-Zaehlung (Modus 8) try: import tiktoken - print("tiktoken importiert.") + print("tiktoken importiert.") # Debugging-Ausgabe (geht nur an Konsole vor Logger Setup) except ImportError: tiktoken = None - print("tiktoken nicht gefunden.") + print("tiktoken nicht gefunden. Token-Zaehlung wird geschaetzt.") # Debugging-Ausgabe -# ============================================================================== -# 2. ZENTRALE KONFIGURATIONSKLASSE -# ============================================================================== + # ============================================================================== + # 2. GLOBALE KONSTANTEN UND KONFIGURATION + # (Logisch 'config.py') + # ============================================================================== + + # --- Dateipfade --- +CREDENTIALS_FILE = "service_account.json" +API_KEY_FILE = "api_key.txt" # OpenAI +SERP_API_KEY_FILE = "serpApiKey.txt" +GENDERIZE_API_KEY_FILE = "genderize_API_Key.txt" +BRANCH_MAPPING_FILE = "ziel_Branchenschema.csv" # Enthält Zielschema +LOG_DIR = "Log" + +# --- ML Modell Artefakte --- +MODEL_FILE = "technician_decision_tree_model.pkl" +IMPUTER_FILE = "median_imputer.pkl" +PATTERNS_FILE_TXT = "technician_patterns.txt" # Alt (Optional beibehalten) +PATTERNS_FILE_JSON = "technician_patterns.json" # Neu (Empfohlen) + +# --- Globale Konfiguration Klasse --- class Config: """Zentrale Konfigurationseinstellungen.""" - VERSION = "v1.7.9" - LANG = "de" - DEBUG = True - HTML_PARSER = "html.parser" - USER_AGENT = 'Mozilla/5.0 (compatible; UnternehmenSkript/1.0; +https://www.example.com/bot)' - SHEET_ID = "1u_gHr9JUfmV1-iviRzbSe3575QEp7KLhK5jFV_gJcgo" - SERVICE_ACCOUNT_FILE = "service_account.json" - TOKEN_FILE = "token.json" - API_KEY_FILE = "api_key.txt" - SERP_API_KEY_FILE = "serpApiKey.txt" - GENDERIZE_API_KEY_FILE = "genderize_API_Key.txt" - SCHEMA_FILE = "ziel_Branchenschema.csv" - BRANCH_MAPPING_FILE = "Branchen.csv" - LOG_DIR = "Log" - MODEL_FILE = "technician_decision_tree_model.pkl" - IMPUTER_FILE = "median_imputer.pkl" - PATTERNS_FILE_JSON = "technician_patterns.json" - TOKEN_MODEL = "gpt-3.5-turbo" - MAX_RETRIES = 5 - RETRY_DELAY = 10 - REQUEST_TIMEOUT = 20 - SERPAPI_DELAY = 1.5 - MAX_SCRAPING_WORKERS = 10 - MAX_BRANCH_WORKERS = 10 - OPENAI_CONCURRENCY_LIMIT = 3 - UPDATE_BATCH_ROW_LIMIT = 50 - PLAUSI_UMSATZ_MIN_WARNUNG = 50000 - PLAUSI_UMSATZ_MAX_WARNUNG = 200000000000 + VERSION = "v1.7.8" + LANG = "de" # Sprache fuer Wikipedia etc. + # ACHTUNG: SHEET_URL ist hier ein Platzhalter. Ersetzen Sie ihn durch Ihre tatsaechliche URL. + SHEET_URL = "https://docs.google.com/spreadsheets/d/1u_gHr9JUfmV1-iviRzbSe3575QEp7KLhK5jFV_gJcgo" # <<< ERSETZEN SIE DIES! + MAX_RETRIES = 5 # Anzahl der Versuche (nicht Wiederholungen nach dem ersten Fehler) fuer wiederholbare Fehler + RETRY_DELAY = 10 # Basiswartezeit (Sekunden) fuer Retries (exponentieller Backoff wird im Decorator angewendet) + REQUEST_TIMEOUT = 20 # Timeout (Sekunden) fuer externe HTTP/API Anfragen (Requests) + SIMILARITY_THRESHOLD = 0.65 # Schwelle fuer Namensaaehnlichkeit bei Wikipedia Validierung + DEBUG = True # Detailliertes Logging aktivieren/deaktivieren + WIKIPEDIA_SEARCH_RESULTS = 5 # Anzahl Ergebnisse bei Wikipedia Suche ueber Bibliothek + HTML_PARSER = "html.parser" # Parser fuer BeautifulSoup ('lxml' ist schneller, erfordert aber Installation) + TOKEN_MODEL = "gpt-3.5-turbo" # OpenAI Modell fuer Token-Zaehlung/Chat (Standard fuer die meisten Calls) + USER_AGENT = 'Mozilla/5.0 (compatible; UnternehmenSkript/1.0; +https://www.example.com/bot)' # User-Agent fuer Web Scraping/Requests (Beispiel URL anpassen) + + + # --- Konfiguration fuer Batching & Parallelisierung --- + # Passen Sie diese Werte an die Leistung Ihres Systems und die API-Limits an. + PROCESSING_BATCH_SIZE = 20 # Anzahl Zeilen pro Verarbeitungs-Batch (fuer _process_single_row in Batches) + OPENAI_BATCH_SIZE_LIMIT = 4 # Max. Texte pro OpenAI Call fuer Zusammenfassung (nur fuer summarize_batch_openai) + MAX_SCRAPING_WORKERS = 10 # Threads fuer paralleles Website-Scraping + UPDATE_BATCH_ROW_LIMIT = 50 # Zeilen sammeln fuer gebuendelte Sheet Updates (effizienter) + MAX_BRANCH_WORKERS = 10 # Threads fuer parallele Branchenbewertung + OPENAI_CONCURRENCY_LIMIT = 3 # Max. gleichzeitige OpenAI Calls (Semaphore fuer Branch Evaluation) + PROCESSING_BRANCH_BATCH_SIZE = 20 # Batch-Groesse fuer Branch-Evaluierung + SERPAPI_DELAY = 1.5 # Pause zwischen einzelnen SerpAPI-Aufrufen (Sekunden) + + + PLAUSI_UMSATZ_MIN_WARNUNG = 50000 + PLAUSI_UMSATZ_MAX_WARNUNG = 200000000000 PLAUSI_MA_MIN_WARNUNG_ABS = 1 PLAUSI_MA_MIN_WARNUNG_BEI_UMSATZ = 3 PLAUSI_UMSATZ_MIN_SCHWELLE_FUER_MA_CHECK = 1000000 @@ -97,156 +147,113 @@ class Config: PLAUSI_RATIO_UMSATZ_PRO_MA_MIN = 25000 PLAUSI_RATIO_UMSATZ_PRO_MA_MAX = 1500000 PLAUSI_ABWEICHUNG_CRM_WIKI_PROZENT = 30 + + # Hartcodiertes Mapping von Detail-Branche zu Branchen-Gruppe + # Key: Normalisierte Detail-Branche (wie sie von der KI kommt) + # Value: Die gewünschte Branchen-Gruppe + BRANCH_GROUP_MAPPING = { + normalize_for_mapping("Baustoffhandel"): "Baubranche", + normalize_for_mapping("Bauunternehmen"): "Baubranche", + normalize_for_mapping("Versicherungsgutachten"): "Gutachter / Versicherungen", + normalize_for_mapping("Technische Gutachten"): "Gutachter / Versicherungen", + normalize_for_mapping("Baugutachter"): "Gutachter / Versicherungen", + normalize_for_mapping("Medizinische Gutachten"): "Gutachter / Versicherungen", + normalize_for_mapping("Energie (Brennstoffe)"): "Handel", + normalize_for_mapping("Großhandel"): "Handel", + normalize_for_mapping("Einzelhandel"): "Handel", + normalize_for_mapping("Automaten (Vending / Slot)"): "Hersteller / Produzenten", + normalize_for_mapping("Anlagenbau"): "Hersteller / Produzenten", + normalize_for_mapping("IT / Telekommunikation"): "Hersteller / Produzenten", # Anmerkung: Diese Kategorie existiert doppelt, prüfen Sie ob das gewollt ist. + normalize_for_mapping("Maschinenbau"): "Hersteller / Produzenten", + normalize_for_mapping("Chemie & Pharma"): "Hersteller / Produzenten", + normalize_for_mapping("Medizintechnik"): "Hersteller / Produzenten", + normalize_for_mapping("Agrar / Pellets"): "Hersteller / Produzenten", + normalize_for_mapping("Elektrotechnik"): "Hersteller / Produzenten", + normalize_for_mapping("Gebäudetechnik Allgemein"): "Hersteller / Produzenten", # Wird zu Gebäudetechnik-Gruppe gemappt + normalize_for_mapping("Fenster / Glas"): "Hersteller / Produzenten", + normalize_for_mapping("Lebensmittelproduktion"): "Hersteller / Produzenten", + normalize_for_mapping("Automobil"): "Hersteller / Produzenten", + normalize_for_mapping("Gebäudetechnik Heizung / Lüftung / Klima"): "Hersteller / Produzenten", # Wird zu Gebäudetechnik-Gruppe gemappt + normalize_for_mapping("Braune & Weiße Ware"): "Hersteller / Produzenten", + normalize_for_mapping("Bürotechnik"): "Hersteller / Produzenten", + normalize_for_mapping("Möbel"): "Hersteller / Produzenten", + normalize_for_mapping("Getränke"): "Hersteller / Produzenten", + normalize_for_mapping("Sozialbau Unternehmen"): "Housing", + normalize_for_mapping("Renovierungsunternehmen"): "Housing", + normalize_for_mapping("Anbieter für Soziales Wohnen"): "Housing", + normalize_for_mapping("Logistik / Sonstige"): "Logistik", + normalize_for_mapping("Auslieferdienste"): "Logistik", + normalize_for_mapping("Logistik"): "Logistik", + normalize_for_mapping("Facility Management"): "Service provider (Dienstleister)", + normalize_for_mapping("Servicedienstleister / Reparatur ohne Produktion"): "Service provider (Dienstleister)", + normalize_for_mapping("Feuer- und Sicherheitssysteme"): "Service provider (Dienstleister)", + normalize_for_mapping("Healthcare/Pflegedienste"): "Service provider (Dienstleister)", + normalize_for_mapping("Schädlingsbekämpfung"): "Service provider (Dienstleister)", + normalize_for_mapping("Entsorgung"): "Service provider (Dienstleister)", + normalize_for_mapping("Personentransport"): "Service provider (Dienstleister)", + normalize_for_mapping("Messdienstleister"): "Service provider (Dienstleister)", + normalize_for_mapping("Aufzüge und Rolltreppen"): "Service provider (Dienstleister)", + normalize_for_mapping("Catering Services"): "Service provider (Dienstleister)", + normalize_for_mapping("Sonstige"): "Sonstige", + normalize_for_mapping("IT Beratung"): "Sonstige", + normalize_for_mapping("Unternehmensberatung"): "Sonstige", + normalize_for_mapping("Sonstiger Service"): "Sonstige", + normalize_for_mapping("Öffentliche Verwaltung"): "Sonstige", + normalize_for_mapping("Engineering"): "Sonstige", + normalize_for_mapping("Telekommunikation"): "Versorger", + normalize_for_mapping("Verteilnetzbetreiber"): "Versorger", + normalize_for_mapping("Stadtwerke"): "Versorger", + normalize_for_mapping("Gase & Mineralöl"): "Versorger", + } + + # --- API Schluessel Speicherung (werden in main() geladen) --- API_KEYS = {} @classmethod def load_api_keys(cls): - logger = logging.getLogger(cls.__name__) - logger.info("Lade API-Schluessel...") - cls.API_KEYS['openai'] = cls._load_key_from_file(cls.API_KEY_FILE) - cls.API_KEYS['serpapi'] = cls._load_key_from_file(cls.SERP_API_KEY_FILE) - cls.API_KEYS['genderize'] = cls._load_key_from_file(cls.GENDERIZE_API_KEY_FILE) + """Laedt API-Schluessel aus den definierten Dateien.""" + print("Lade API-Schluessel...") + cls.API_KEYS['openai'] = cls._load_key_from_file(API_KEY_FILE) + cls.API_KEYS['serpapi'] = cls._load_key_from_file(SERP_API_KEY_FILE) + cls.API_KEYS['genderize'] = cls._load_key_from_file(GENDERIZE_API_KEY_FILE) + + # import openai # <--- DIESER IMPORT IST NUN ENTFERNT + if cls.API_KEYS.get('openai'): + # Stelle sicher, dass das 'openai' Modul hier im Scope ist, + # indem wir auf den globalen Import zugreifen. + # Da 'openai' schon global importiert wurde (ganz oben im Skript), + # ist es hier direkt verfügbar. openai.api_key = cls.API_KEYS['openai'] - logger.info("OpenAI API Key erfolgreich geladen.") + print("OpenAI API Key erfolgreich geladen.") else: - logger.warning("WARNUNG: OpenAI API Key konnte nicht geladen werden.") + print("WARNUNG: OpenAI API Key konnte nicht geladen werden (Datei fehlt oder ist leer?). OpenAI-Funktionen sind deaktiviert.") + + if not cls.API_KEYS.get('serpapi'): + print("WARNUNG: SerpAPI Key konnte nicht geladen werden (Datei fehlt oder ist leer?). Bestimmte Suchfunktionen sind deaktiviert.") + if not cls.API_KEYS.get('genderize'): + print("WARNUNG: Genderize API Key konnte nicht geladen werden (Datei fehlt oder ist leer?). Geschlechtserkennung ist eingeschraenkt.") + @staticmethod def _load_key_from_file(filepath): - logger = logging.getLogger(Config.__name__) + """Hilfsfunktion zum Laden eines Schluessels aus einer Datei.""" try: + # Verwenden Sie "r" fuer Textmodus und geben Sie das Encoding an with open(filepath, "r", encoding="utf-8") as f: key = f.read().strip() - if key: return key - return None + if key: + return key + else: + print(f"WARNUNG: Datei '{filepath}' ist leer.") + return None except FileNotFoundError: - logger.debug(f"INFO: API-Schluesseldatei '{filepath}' nicht gefunden.") + print(f"INFO: API-Schluesseldatei '{filepath}' nicht gefunden.") return None except Exception as e: - logger.error(f"FEHLER beim Lesen der Schluesseldatei '{filepath}': {e}") + print(f"FEHLER beim Lesen der Schluesseldatei '{filepath}': {e}") return None -# ============================================================================== -# 3. GLOBALE HILFSFUNKTIONEN -# ============================================================================== - -TARGET_SCHEMA_STRING = "" -FOCUS_BRANCHES_PROMPT_PART = "" - -def normalize_for_mapping(text): - if not isinstance(text, str): return "" - text = text.lower().strip() - return re.sub(r'[^a-z0-9]', '', text) - -def load_branch_mapping(file_path=Config.BRANCH_MAPPING_FILE): - logger = logging.getLogger(__name__) - absolute_path = os.path.abspath(file_path) - logger.info(f"Lade Branchen-Mapping von: '{absolute_path}'") - if not os.path.exists(file_path): - logger.error(f"DATEI NICHT GEFUNDEN: '{absolute_path}'.") - return {} - try: - df_mapping = pd.read_csv(file_path, sep=';', encoding='utf-8-sig') - df_mapping.columns = [str(col).strip() for col in df_mapping.columns] - expected_cols = ['Branch Group', 'Branch'] - if not all(col in df_mapping.columns for col in expected_cols): - logger.error(f"FEHLER: Spalten {expected_cols} in '{file_path}' nicht gefunden. Gefunden: {list(df_mapping.columns)}") - return {} - df_mapping['normalized_keys'] = df_mapping['Branch'].apply(normalize_for_mapping) - if df_mapping['normalized_keys'].duplicated().any(): - duplicates = df_mapping[df_mapping['normalized_keys'].duplicated()]['normalized_keys'] - logger.warning(f"WARNUNG: Duplikate in normalisierten Branchen-Keys: {list(duplicates)}") - branch_map_dict = pd.Series( - df_mapping['Branch Group'].str.strip().values, - index=df_mapping['normalized_keys'] - ).to_dict() - logger.info(f"Branchen-Mapping aus '{file_path}' erfolgreich geladen ({len(branch_map_dict)} Einträge).") - return branch_map_dict - except Exception: - logger.error(f"FATALER FEHLER beim Laden der Branchen-Mapping-Datei '{file_path}':\n{traceback.format_exc()}") - return {} - -def load_target_schema(csv_filepath=Config.SCHEMA_FILE): - logger = logging.getLogger(__name__) - global TARGET_SCHEMA_STRING, FOCUS_BRANCHES_PROMPT_PART - logger.info(f"Lade Ziel-Schema und Fokus-Branchen aus '{csv_filepath}'...") - if not os.path.exists(csv_filepath): - logger.error(f"DATEI NICHT GEFUNDEN: '{os.path.abspath(csv_filepath)}'.") - return [], [] - try: - allowed_branches_set, focus_branches_set = set(), set() - with open(csv_filepath, "r", encoding="utf-8-sig") as f: - reader = csv.reader(f, delimiter=';') - next(reader) - for row in reader: - if row and len(row) >= 1 and row[0].strip(): - target_branch = row[0].strip() - allowed_branches_set.add(target_branch) - if len(row) >= 2 and row[1].strip().upper() in ["X", "FOKUS", "JA", "TRUE", "1"]: - focus_branches_set.add(target_branch) - ALLOWED_TARGET_BRANCHES = sorted(list(allowed_branches_set)) - FOCUS_TARGET_BRANCHES = sorted(list(focus_branches_set)) - logger.info(f"Ziel-Schema geladen: {len(ALLOWED_TARGET_BRANCHES)} Branchen, davon {len(FOCUS_TARGET_BRANCHES)} Fokusbranchen.") - if ALLOWED_TARGET_BRANCHES: - # Hier Ihre Logik zum Erstellen der Prompt-Strings - schema_lines = ["..."] # Platzhalter für Ihre Logik - TARGET_SCHEMA_STRING = "\n".join(schema_lines) - if FOCUS_TARGET_BRANCHES: - focus_prompt_lines = ["..."] # Platzhalter - FOCUS_BRANCHES_PROMPT_PART = "\n".join(focus_prompt_lines) - else: - FOCUS_BRANCHES_PROMPT_PART = "" - return ALLOWED_TARGET_BRANCHES, FOCUS_TARGET_BRANCHES - except Exception: - logger.error(f"FEHLER beim Laden der Schema-Datei '{csv_filepath}':\n{traceback.format_exc()}") - return [], [] - -def parse_arguments(): - """Parst und validiert Kommandozeilen-Argumente. Völlig eigenständig.""" - parser = argparse.ArgumentParser( - description="Unternehmensbewertung v1.7.9", - formatter_class=argparse.RawTextHelpFormatter - ) - - mode_categories = { - "Sequentielle Verarbeitung (Zeilenweise)": ["full_run"], - "Re-Evaluate Markierte Zeilen (Spalte A='x')": ["reeval"], - "Einzelne Dienstprogramme / Suchen": [ - "find_wiki_serp", "website_lookup", "check_urls", "contacts", "update_wiki_suggestions", - "wiki_reextract_missing_an", "website_details", "train_technician_model", "alignment", - "reparatur_sitz", "plausi_check_data", "branch_eval", "suggest_parents", "analyze_ml_by_branch" - ], - "Kombinierte Laeufe (Vordefiniert)": ["combined_all"] - } - valid_modes = [mode for modes in mode_categories.values() for mode in modes] - mode_help_text = "Betriebsmodus. Waehlen Sie einen der folgenden:\n" - for category, modes in mode_categories.items(): - mode_help_text += f"\n{category}:\n" - for mode in modes: - mode_help_text += f" - {mode}\n" - parser.add_argument("--mode", type=str, help=mode_help_text) - - parser.add_argument("--limit", type=int, help="Maximale Anzahl zu verarbeitender Zeilen.", default=None) - parser.add_argument("--start_sheet_row", type=int, help="Startzeile im Sheet (1-basiert).", default=None) - parser.add_argument("--end_sheet_row", type=int, help="Endzeile im Sheet (1-basiert).", default=None) - - valid_single_row_steps = ['wiki', 'chat', 'web', 'ml_predict'] - default_steps_arg = ','.join(valid_single_row_steps) - parser.add_argument("--steps", type=str, help=f"Schritte im 'reeval'/'full_run' Modus.", default=default_steps_arg) - - parser.add_argument("--min_umsatz", type=float, help="Mindestumsatz in MIO € für find_wiki_serp.", default=200.0) - parser.add_argument("--min_employees", type=int, help="Mindestmitarbeiterzahl für find_wiki_serp.", default=500) - - # HIER IST DIE KORREKTUR: Die 'default' Werte sind auf None gesetzt. - parser.add_argument("--model_out", type=str, default=None, help="Ausgabepfad für das trainierte ML-Modell.") - parser.add_argument("--imputer_out", type=str, default=None, help="Ausgabepfad für den trainierten Imputer.") - parser.add_argument("--patterns_out", type=str, default=None, help="Ausgabepfad für die Feature-Patterns (JSON).") - - return parser.parse_args() - -# --- Globale Spalten-Mapping (WICHTIG: MUSS ZU IHREM SHEET PASSEN!) --- - # --- Globale Spalten-Mapping (WICHTIG: MUSS ZU IHREM SHEET PASSEN!) --- # --- Globale Spalten-Mapping (WICHTIG: MUSS ZU IHREM SHEET PASSEN!) --- @@ -516,20 +523,6 @@ def token_count(text, model=None): # --- Logging Helpers --- LOG_FILE = None # Initialisierung -def normalize_for_mapping(text): - """ - Normalisiert einen String aggressiv für Mapping-Zwecke. - Entfernt Leerzeichen, macht alles klein, ersetzt gängige Sonderzeichen. - """ - if not isinstance(text, str): - return "" - text = text.lower() - text = text.strip() - # Ersetze alle Nicht-Alphanumerischen Zeichen durch nichts - text = re.sub(r'[^a-z0-9]', '', text) - return text - - def create_log_filename(mode): """Erstellt einen zeitgestempelten Logdateinamen im LOG_DIR.""" logger = logging.getLogger(__name__) # Logger-Instanz holen @@ -1203,7 +1196,7 @@ TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar." # String-Repraese ALLOWED_TARGET_BRANCHES = [] # Liste der erlaubten Kurzformen -def load_target_schema(csv_filepath=Config.SCHEMA_FILE): +def load_target_schema(csv_filepath=BRANCH_MAPPING_FILE): logger = logging.getLogger(__name__) global ALLOWED_TARGET_BRANCHES, FOCUS_TARGET_BRANCHES, TARGET_SCHEMA_STRING, FOCUS_BRANCHES_PROMPT_PART @@ -1225,7 +1218,7 @@ def load_target_schema(csv_filepath=Config.SCHEMA_FILE): logger.warning(f"Schema-Datei '{csv_filepath}' ist leer oder hat keinen Header.") TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar (Datei leer)." FOCUS_BRANCHES_PROMPT_PART = "" - return [], [] + return for row_num, row in enumerate(reader, 1): # Starte Zählung bei 1 für Zeilennummern nach Header line_count = row_num @@ -1244,13 +1237,12 @@ def load_target_schema(csv_filepath=Config.SCHEMA_FILE): logger.critical(f"FEHLER: Schema-Datei '{csv_filepath}' nicht gefunden.") TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar (Datei nicht gefunden)." FOCUS_BRANCHES_PROMPT_PART = "" - # Gib ein Tupel mit zwei leeren Listen zurück, um den TypeError zu vermeiden - return [], [] + return except Exception as e: logger.critical(f"FEHLER beim Laden des Ziel-Schemas aus '{csv_filepath}' (Zeile {line_count}): {e}") TARGET_SCHEMA_STRING = "Ziel-Branchenschema nicht verfuegbar (Fehler beim Lesen)." FOCUS_BRANCHES_PROMPT_PART = "" - return [], [] + return ALLOWED_TARGET_BRANCHES = sorted(list(allowed_branches_set), key=str.lower) FOCUS_TARGET_BRANCHES = sorted(list(focus_branches_set), key=str.lower) @@ -1282,14 +1274,6 @@ def load_target_schema(csv_filepath=Config.SCHEMA_FILE): FOCUS_BRANCHES_PROMPT_PART = "" logger.warning("Keine gueltigen Zielbranchen im Schema gefunden. Branchenbewertung ist nicht moeglich.") - # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - # +++ HIER DIE FEHLENDE RETURN-ANWEISUNG HINZUFÜGEN ++++++++++++++++++++++ - # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - # Auch im Erfolgsfall müssen die Werte zurückgegeben werden. - return ALLOWED_TARGET_BRANCHES, FOCUS_TARGET_BRANCHES - # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - # +++ ENDE HINZUFÜGUNG ++++++++++++++++++++++++++++++++++++++++++++++++++++ - # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # map_external_branch ist in dieser Version nicht mehr notwendig, # da die Branchenevaluation ueber ChatGPT (evaluate_branche_chatgpt) @@ -4284,23 +4268,6 @@ class DataProcessor: # Spezifischere Batch-Modi (Block 26-32) haben oft ihre eigene Zeilenauswahl-Logik, # die nicht unbedingt diese Methoden verwendet. - def __init__(self, sheet_handler, wiki_scraper, ziel_schema, fokus_branchen, branch_mapping): # Neue Parameter - self.logger = logging.getLogger(self.__class__.__name__) - self.logger.info("Initialisiere DataProcessor...") - self.sheet_handler = sheet_handler - self.wiki_scraper = wiki_scraper - - # +++ NEUE INSTANZATTRIBUTE +++ - self.ziel_schema = ziel_schema - self.fokus_branchen = fokus_branchen - self.branch_mapping = branch_mapping - - # Laden von Modell/Imputer bleibt wie es ist - self.model = None - self.imputer = None - self._expected_features = None - - self.logger.info("DataProcessor initialisiert mit Handlern und Konfigurationsdaten.") def _needs_website_processing(self, row_data, force_reeval): """ @@ -9189,92 +9156,29 @@ class DataProcessor: ) self.logger.info(f"Verteilung der neuen Techniker-Buckets:\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True, dropna=False).sort_index().round(3)}") - # --- Kategoriale Features vorbereiten (Branche) --- - branche_col_internal = "branche_ki" # Name der Spalte mit den Detail-Branchen + # --- Kategoriale Features vorbereiten (Branchen-Gruppen) --- + branche_col_internal = "branche_ki" # Dies ist die Spalte mit den Detail-Branchen self.logger.info(f"Verarbeite kategoriales Feature '{branche_col_internal}' und mappe es zu 'Branchen_Gruppe'...") - # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - # +++ FINALER DEBUGGING-BLOCK ZUR PRÜFUNG DES MAPPING-DICTIONARIES ++++++ - # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - self.logger.info(f"PRÜFE MAPPING-DICT: Das self.branch_mapping Dictionary hat {len(self.branch_mapping)} Einträge.") - if len(self.branch_mapping) < 1: - self.logger.error("ALARM: Das self.branch_mapping Dictionary ist leer!") - else: - # Zeige die ersten 5 Schlüssel-Wert-Paare aus dem Dictionary, um zu sehen, ob es korrekt ist. - self.logger.info("PRÜFE MAPPING-DICT: Die ersten 5 Einträge sind:") - for i, (key, value) in enumerate(BRANCH_MAPPING.items()): - if i >= 5: break - self.logger.info(f" -> Key: '{key}' -> Value: '{value}'") - # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - # +++ ENDE FINALER DEBUGGING-BLOCK ++++++++++++++++++++++++++++++++++++++ - # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - - - normalized_sheet_branches = df_filtered[branche_col_internal].apply(normalize_for_mapping) - if branche_col_internal not in df_filtered.columns: - self.logger.critical(f"FEHLER: Spalte '{branche_col_internal}' nicht im DataFrame gefunden.") + self.logger.critical(f"FEHLER: Spalte '{branche_col_internal}' (aus 'Chat Vorschlag Branche') nicht im DataFrame gefunden.") return None - # Bereinige die Branchennamen in der Spalte für das Mapping - # Wichtig: .str.strip().str.title() oder .str.lower() anwenden, je nachdem wie das Mapping-Dict aufgebaut ist - cleaned_branches = df_filtered[branche_col_internal].astype(str).str.strip() + # Normalisiere die Branchennamen aus dem Sheet für das Mapping + normalized_sheet_branches = df_filtered[branche_col_internal].apply(normalize_for_mapping) - # Erstelle das Mapping-Dictionary ebenfalls bereinigt (idealerweise schon in load_branch_mapping) - # Für jetzt gehen wir davon aus, BRANCH_MAPPING hat bereinigte Keys - - # Wende das Mapping an. - # .map() ist case-sensitive. Um das zu umgehen, können wir die Keys des Mappings und die Werte in der Spalte normalisieren, - # z.B. alles in Kleinbuchstaben. - - # Sicherere Methode: - # Erstelle eine normalisierte Version des Mapping-Dictionaries - mapping_lower = {str(k).strip().lower(): v for k, v in self.branch_mapping.items()} # self.branch_mapping verwenden - # Wende dieses normalisierte Mapping auf die normalisierte Branchenspalte an - df_filtered.loc[:, 'Branchen_Gruppe'] = normalized_sheet_branches.map(BRANCH_MAPPING).fillna('Sonstige') + # Wende das hartcodierte Mapping aus der Config-Klasse an + df_filtered.loc[:, 'Branchen_Gruppe'] = normalized_sheet_branches.map(Config.BRANCH_GROUP_MAPPING).fillna('Sonstige') self.logger.info("Mapping zu 'Branchen_Gruppe' durchgeführt.") - - # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - # +++ NEUER, ERWEITERTER DEBUGGING-BLOCK ++++++++++++++++++++++++++++++++ - # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - # Finde heraus, welche Branchen nicht gemappt werden konnten - unmapped_df = df_filtered[df_filtered['Branchen_Gruppe'] == 'Sonstige'] - - if not unmapped_df.empty: - unmapped_branches = unmapped_df[branche_col_internal].value_counts() - self.logger.warning(f"KONNTE MAPPING NICHT DURCHFÜHREN! {len(unmapped_branches)} verschiedene Detail-Branchen wurden als 'Sonstige' klassifiziert.") - self.logger.warning("Überprüfen Sie auf Unterschiede in Schreibweise, Leerzeichen oder Sonderzeichen.") - - # Zeige die Top 10 nicht gemappten Branchen mit ihrer Häufigkeit - self.logger.warning("Top 10 nicht gemappte Branchen aus dem Google Sheet:") - for branch_name, count in unmapped_branches.head(10).items(): - # Zeige den Wert exakt so an, wie er in der Spalte steht, in Anführungszeichen - self.logger.warning(f" -> '{branch_name}' (kam {count} mal vor)") - - # Zeige einige Beispiele der normalisierten Schlüssel aus dem Mapping-File zum Vergleich - if mapping_lower: - self.logger.warning("Beispiele für normalisierte Schlüssel aus der Branchen.csv:") - # .keys() ist eine Ansicht, konvertiere zu Liste für Slicing - example_keys = list(mapping_lower.keys()) - for i in range(min(10, len(example_keys))): - self.logger.warning(f" -> '{example_keys[i]}'") - # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - # +++ ENDE DEBUGGING-BLOCK ++++++++++++++++++++++++++++++++++++++++++++++++ - # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - self.logger.debug(f"Verteilung der Branchen-Gruppen:\n{df_filtered['Branchen_Gruppe'].value_counts(normalize=True).sort_index().round(3)}") - # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - # +++ ENDE NEUER BLOCK ++++++++++++++++++++++++++++++++++++++++++++++++++++ - # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - - # One-Hot Encoding auf der neuen 'Branchen_Gruppe'-Spalte durchführen - df_encoded = pd.get_dummies(df_filtered, columns=['Branchen_Gruppe'], prefix='Gruppe', dummy_na=False) # << KORRIGIERT - self.logger.info(f"One-Hot Encoding fuer 'Branchen_Gruppe' durchgefuehrt...") - - # --- Finale Auswahl der Features fuer das Modell --- - # Passe die Feature-Auswahl an, um die neuen Gruppen-Features zu verwenden - feature_columns_ml = [col for col in df_encoded.columns if col.startswith('Gruppe_')] # << KORRIGIERT + + # One-Hot Encoding wird jetzt auf der neuen 'Branchen_Gruppe'-Spalte durchgeführt + df_encoded = pd.get_dummies(df_filtered, columns=['Branchen_Gruppe'], prefix='Gruppe', dummy_na=False) + self.logger.info(f"One-Hot Encoding fuer 'Branchen_Gruppe' durchgefuehrt.") + +# --- Finale Auswahl der Features fuer das Modell --- + feature_columns_ml = [col for col in df_encoded.columns if col.startswith('Gruppe_')] feature_columns_ml.extend([ 'Log_Finaler_Umsatz_ML', 'Log_Finaler_Mitarbeiter_ML', @@ -9328,7 +9232,7 @@ class DataProcessor: # logger, pickle, json, os, # train_test_split, SimpleImputer, DecisionTreeClassifier, # accuracy_score, classification_report, confusion_matrix, export_text (sklearn). - def train_technician_model(self, model_out=Config.MODEL_FILE, imputer_out=Config.IMPUTER_FILE, patterns_out=Config.PATTERNS_FILE_JSON): + def train_technician_model(self, model_out=MODEL_FILE, imputer_out=IMPUTER_FILE, patterns_out=PATTERNS_FILE_JSON): self.logger.info("Starte Training des Servicetechniker Decision Tree Modells...") # 1. Daten vorbereiten @@ -10276,230 +10180,691 @@ class DataProcessor: # Der globale Root Logger wird in main() konfiguriert # logger = logging.getLogger(__name__) # Diesen Logger gibt es schon, keine Neudefinition hier -def parse_arguments(): - """ - Parst und validiert Kommandozeilen-Argumente. - """ - parser = argparse.ArgumentParser(description="Automatisierte Unternehmensbewertung v1.7.8") - parser.add_argument( - '--mode', type=str, choices=[ - 'full_run', 'reeval', 'find_wiki_serp', 'website_lookup', 'check_urls', - 'contacts', 'update_wiki_suggestions', 'wiki_reextract_missing_an', - 'website_details', 'train_technician_model', 'alignment', 'reparatur_sitz', - 'plausi_check_data', 'combined_all', 'branch_eval', 'suggest_parents', 'analyze_ml_by_branch' - ], - help="Ausführungsmodus des Skripts." - ) - parser.add_argument('--limit', type=int, help="Maximale Anzahl zu verarbeitender Zeilen.") - parser.add_argument('--start_sheet_row', type=int, help="Startzeile im Google Sheet (1-basiert).") - parser.add_argument('--end_sheet_row', type=int, help="Endzeile im Google Sheet (inklusiv).") - parser.add_argument( - '--steps', type=str, - default='wiki,chat,web,ml_predict', - help="Zu durchlaufende Schritte, kommagetrennt (z.B. 'wiki,chat'). Standard: 'wiki,chat,web,ml_predict'" - ) - # Argumente für find_wiki_serp - parser.add_argument('--min_employees', type=int, default=500, help="Mindestanzahl Mitarbeiter für find_wiki_serp Modus.") - parser.add_argument('--min_umsatz', type=float, default=200.0, help="Mindestumsatz in Mio. € für find_wiki_serp Modus.") - - # Argumente für das Speichern von ML-Modellen - parser.add_argument('--model_out', type=str, default=MODEL_FILE, help="Ausgabepfad für das trainierte ML-Modell.") - parser.add_argument('--imputer_out', type=str, default=IMPUTER_FILE, help="Ausgabepfad für den trainierten Imputer.") - parser.add_argument('--patterns_out', type=str, default=PATTERNS_FILE_JSON, help="Ausgabepfad für die Feature-Patterns (JSON).") - - return parser.parse_args() - - def main(): """ Haupteinstiegspunkt des Skripts. Verarbeitet Kommandozeilen-Argumente, richtet Logging ein, initialisiert Komponenten und dispatchet zu den passenden Modi. """ - # 1. Argumente parsen - args = parse_arguments() - - # 2. Temporäres Logging für interaktive Auswahl einrichten + # WICHTIG: Globale Variable LOG_FILE wird benoetigt (Initialisierung Block 3) global LOG_FILE + logger = logging.getLogger(__name__) # <<< JETZT AN DER RICHTIGEN STELLE + + # --- Initial Logging Setup (Konfiguration von Level und Format) --- + # Diese Konfiguration wird wirksam, sobald die Handler hinzugefuegt werden. + # Standard-Logging Level festlegen (aus Config Block 1) log_level = logging.DEBUG if getattr(Config, 'DEBUG', False) else logging.INFO - log_format = '%(asctime)s - %(levelname)-8s - %(name)-25s - %(message)s' - - temp_console_handler = logging.StreamHandler() - temp_console_handler.setFormatter(logging.Formatter(log_format)) - root_logger = logging.getLogger('') - root_logger.setLevel(log_level) - root_logger.addHandler(temp_console_handler) - logger = logging.getLogger(__name__) + log_format = '%(asctime)s - %(levelname)-8s - %(name)-25s - %(message)s' # <<< DIESE ZEILE HINZUFÜGEN/KORRIGIEREN - # 3. Modusauswahl (interaktiv, falls nicht per CLI gesetzt) - selected_mode = args.mode - if not selected_mode: - mode_categories = { - "Sequentielle Verarbeitung (Zeilenweise)": ["full_run"], - "Re-Evaluate Markierte Zeilen (Spalte A='x')": ["reeval"], - "Einzelne Dienstprogramme / Suchen": [ - "find_wiki_serp", "website_lookup", "check_urls", "contacts", "update_wiki_suggestions", - "wiki_reextract_missing_an", "website_details", "train_technician_model", "alignment", - "reparatur_sitz", "plausi_check_data", "branch_eval", "suggest_parents", "analyze_ml_by_branch" - ], - "Kombinierte Laeufe (Vordefiniert)": ["combined_all"] - } - valid_modes = [mode for modes in mode_categories.values() for mode in modes] - print("\nVerfügbare Betriebsmodi:") - mode_number = 1 - mode_map = {} - for category, modes in mode_categories.items(): - print(f"\n{category}:") - for mode in modes: - print(f" {mode_number}: {mode}") - mode_map[mode_number] = mode - mode_number += 1 - print("\n 0: Abbrechen") - while True: - try: - choice_str = input("\nGeben Sie den Modusnamen oder die Zahl ein: ").strip().lower() - if choice_str in ['0', 'abbrechen']: - print("Vorgang abgebrochen.") - return - if choice_str.isdigit() and int(choice_str) in mode_map: - selected_mode = mode_map[int(choice_str)] - break - elif choice_str in valid_modes: - selected_mode = choice_str - break - else: - print("Ungültige Eingabe. Bitte versuchen Sie es erneut.") - except (KeyboardInterrupt, EOFError): - print("\nVorgang abgebrochen.") - return + # Root-Logger konfigurieren (mit Console Handler, File Handler wird spaeter hinzugefuegt) + # handlers=[] verhindert default Console Handler, wir fuegen ihn manuell hinzu fuer mehr Kontrolle + logging.basicConfig(level=log_level, format=log_format, handlers=[]) # log_format wird hier bereits verwendet - # 4. Finales Logging (mit Datei) einrichten - if args.model_out is None: args.model_out = Config.MODEL_FILE - if args.imputer_out is None: args.imputer_out = Config.IMPUTER_FILE - if args.patterns_out is None: args.patterns_out = Config.PATTERNS_FILE_JSON + # Console Handler explizit hinzufuegen + console_handler = logging.StreamHandler() + console_handler.setLevel(log_level) # Nimm das globale Level + console_handler.setFormatter(logging.Formatter(log_format)) # Jetzt sollte log_format definiert sein + # Pruefen, ob nicht schon ein Console Handler vorhanden ist (z.B. bei wiederholten Aufrufen in Tests) + if not any(isinstance(h, logging.StreamHandler) for h in logging.getLogger('').handlers): + logging.getLogger('').addHandler(console_handler) - root_logger.removeHandler(temp_console_handler) - log_directory = Config.LOG_DIR - if not os.path.exists(log_directory): os.makedirs(log_directory) - timestamp = datetime.now().strftime("%d-%m-%Y_%H-%M") - log_filename = f"{timestamp}_v{Config.VERSION.replace('.', '')}_Modus{selected_mode}.txt" - LOG_FILE = os.path.join(log_directory, log_filename) - final_console_handler = logging.StreamHandler() - final_console_handler.setFormatter(logging.Formatter(log_format)) - file_handler = logging.FileHandler(LOG_FILE, 'w', 'utf-8') - file_handler.setFormatter(logging.Formatter(log_format)) - logging.basicConfig(level=log_level, format=log_format, handlers=[final_console_handler, file_handler]) - logger.info(f"Logging wird jetzt auch in Datei geschrieben: {LOG_FILE}") - logger.info("===== Skript gestartet =====") - logger.info(f"Version: {Config.VERSION}") + # Testnachricht (geht nur an Konsole, da File Handler noch fehlt) + logger.debug("DEBUG Logging initial konfiguriert (nur Konsole).") + logger.info("INFO Logging initial konfiguriert (nur Konsole).") + + + # --- Initialisierung (Argument Parser) --- + current_script_version = getattr(Config, 'VERSION', 'unknown') # Aus Config Block 1 + + parser = argparse.ArgumentParser( + description=f"Firmen-Datenanreicherungs-Skript {current_script_version}. Automatisiert Anreicherung und Validierung aus Google Sheets.", + formatter_class=argparse.RawTextHelpFormatter # Behaelt Formatierung im Help-Text + ) + + # Liste der gueltigen Modi - MUSS mit den elif-Zweigen unten uebereinstimmen! + # Kategorisiert fuer die Menue-Ausgabe + mode_categories = { + "Batch-Verarbeitung (Schritt-Optimiert)": [ + "wiki_verify", # Uebereinstimmend mit process_verification_batch (Block 26) + "website_scraping", # Uebereinstimmend mit process_website_scraping_batch (Block 27) + "summarize_website", # Uebereinstimmend mit process_summarization_batch (Block 28) + "branch_eval", # Uebereinstimmend mit process_branch_batch (Block 29) + "suggest_parents", + ], + "Sequentielle Verarbeitung (Zeilenweise)": [ + "full_run", # Nutzt process_rows_sequentially (Block 24) + ], + "Re-Evaluate Markierte Zeilen (Spalte A='x')": [ + "reeval", # Nutzt process_reevaluation_rows (Block 25) + ], + "Einzelne Dienstprogramme / Suchen": [ + "find_wiki_serp", # Nutzt process_find_wiki_serp (Block 30) + "website_lookup", # Nutzt process_serp_website_lookup (Block 30) + "check_urls", # <<< NEUER MODUS HIER EINFÜGEN + "contacts", # Nutzt process_contact_search (Block 30) + "update_wiki_suggestions", # Nutzt process_wiki_updates_from_chatgpt (Block 32) + "wiki_reextract_missing_an", # Nutzt process_wiki_reextract_missing_an (Block 32) + "website_details", # EXPERIMENTELL - Nutzt process_website_details (Block 32) + "train_technician_model", # Nutzt train_technician_model (Block 31) + "alignment", # Nutzt globale alignment_demo (Block 14) + "reparatur_sitz", + "plausi_check_data" # NEUER MODUS HIER + ], + "Kombinierte Laeufe (Vordefiniert)": [ + "combined_all", # Definiert eine Sequenz von Batch-Modi + ] + } + # Erstellen Sie eine flache Liste aller validen Modi fuer die Validierung + valid_modes = [mode for modes in mode_categories.values() for mode in modes] + + + # Dynamisch generieren des Help-Textes fuer den Modus + mode_help_text = "Betriebsmodus. Waehlen Sie einen der folgenden:\n" + for category, modes in mode_categories.items(): + mode_help_text += f"\n{category}:\n" + for mode in modes: + mode_help_text += f" - {mode}\n" + + parser.add_argument("--mode", type=str, help=mode_help_text) + # Hilfsargument fuer die CLI-basierte Modusauswahl (wenn --mode gesetzt ist) + parser.add_argument("-m", "--cli-mode", dest="mode", action="store_const", const=valid_modes[0] if valid_modes else None, help=argparse.SUPPRESS) # Unterdruecke in --help + + parser.add_argument("--limit", type=int, help="Maximale Anzahl zu verarbeitender Zeilen in den meisten Modi (prueft Zeilen VOR Ueberspringung/Filterung).", default=None) + # start_sheet_row wird primaer fuer full_run verwendet, kann aber auch fuer Bereiche in Batch nuetzlich sein + parser.add_argument("--start_sheet_row", type=int, help="Startzeile im Sheet (1-basiert) fuer 'full_run' und einige Batch-Modi. Standard: Automatische Ermittlung basierend auf Timestamp.", default=None) + # end_sheet_row fuer Bereiche + parser.add_argument("--end_sheet_row", type=int, help="Endzeile im Sheet (1-basiert) fuer 'full_run' und einige Batch-Modi. Standard: Ende des Sheets.", default=None) + + + # Argument fuer den Re-Eval und Full-Run Modus zur Auswahl der Schritte + # Moegliche Werte fuer die Schritte: 'wiki', 'chat', 'web', 'ml_predict', etc. (entsprechend den step_type Schluesseln in _process_single_row Block 19) + # Default ist 'all' fuer alle Schritte, oder eine spezifische Liste + # Dies sind die Schluessel, die _process_single_row (Block 19) in steps_to_run Set erwartet. + valid_single_row_steps = ['wiki', 'chat', 'web', 'ml_predict'] # Fuegen Sie hier weitere Schritt-Schluessel hinzu, die _process_single_row versteht + single_row_steps_help = f"Komma-getrennte Liste der Schritte im 'reeval' und 'full_run' Modus (z.B. 'wiki,chat').\nMögliche Schritte: {', '.join(valid_single_row_steps)}.\nStandard: {'all' if valid_single_row_steps else 'keine'}" # Standard: alle verfuegbaren Schritte + + # Standardwert fuer --steps: Alle gueltigen Single-Row Schritte, wenn es welche gibt + default_steps_arg = ','.join(valid_single_row_steps) if valid_single_row_steps else '' + parser.add_argument("--steps", type=str, help=single_row_steps_help, default=default_steps_arg) + + + # Argumente fuer find_wiki_serp (falls ueber CLI gesteuert) + parser.add_argument("--min_umsatz", type=float, help="Mindestumsatz in MIO € (CRM Spalte J) fuer find_wiki_serp Filter.", default=200.0) # Float fuer Konsistenz + parser.add_argument("--min_employees", type=int, help="Mindestmitarbeiterzahl (CRM Spalte K) fuer find_wiki_serp Filter.", default=500) + + + # Argumente fuer train_technician_model (Pfade fuer Output-Dateien) + parser.add_argument("--model_out", type=str, default=MODEL_FILE, help=f"Pfad fuer das trainierte Modell (.pkl). Standard: {MODEL_FILE}") # Block 1 Konstante + parser.add_argument("--imputer_out", type=str, default=IMPUTER_FILE, help=f"Pfad fuer den trainierten Imputer (.pkl). Standard: {IMPUTER_FILE}") # Block 1 Konstante + parser.add_argument("--patterns_out", type=str, default=PATTERNS_FILE_JSON, help=f"Pfad fuer die Feature-Spaltenliste (.json). Standard: {PATTERNS_FILE_JSON}") # Block 1 Konstante + + # TODO: Fuegen Sie hier weitere CLI-Argumente hinzu, falls andere Modi Parameter benoetigen + + args = parser.parse_args() + + + # --- Konfiguration laden --- + Config.load_api_keys() # Nutzt jetzt logging intern (print am Anfang Block 1) + + + # --- Logdatei-Konfiguration abschliessen --- + # Bestimmen Sie den Log-Modus Namen basierend auf CLI oder Interaktion + # Wir nutzen den CLI Modus Namen, wenn --mode gesetzt ist, sonst "interactive". + log_mode_name = args.mode if args.mode else "interactive" + LOG_FILE = create_log_filename(log_mode_name) # Nutzt globale Funktion (Block 3) + + # Wenn die Logdatei erfolgreich erstellt wurde + if LOG_FILE: + try: + # Erstellen Sie den FileHandler fuer die Logdatei + # mode='a' zum Anhaengen, encoding='utf-8' fuer Unicode + file_handler = logging.FileHandler(LOG_FILE, mode='a', encoding='utf-8') + file_handler.setLevel(log_level) # Nimm das globale Level + # Verwenden Sie denselben Formatter wie fuer den Console Handler + file_handler.setFormatter(logging.Formatter(log_format)) + # Fuege FileHandler zum Root-Logger hinzu + # Pruefen, ob nicht schon ein File Handler mit demselben Pfad vorhanden ist (z.B. bei wiederholten Aufrufen in Tests) + if not any(isinstance(h, logging.FileHandler) and h.baseFilename == os.path.abspath(LOG_FILE) for h in logging.getLogger('').handlers): + logging.getLogger('').addHandler(file_handler) + logger.info(f"Logging wird jetzt auch in Datei geschrieben: {LOG_FILE}") + except Exception as e: + # Logge Fehler nur auf Konsole, da FileHandler fehlgeschlagen ist + # logger.exception loggt auch an die Konsole, wenn kein FileHandler da ist + logger.error(f"Konnte FileHandler fuer Logdatei '{LOG_FILE}' nicht erstellen: {e}") + # Optional: Entfernen Sie evtl. den fehlerhaften Handler aus der Liste + logging.getLogger('').handlers = [h for h in logging.getLogger('').handlers if not isinstance(h, logging.FileHandler) or h.baseFilename == os.path.abspath(LOG_FILE)] # Entferne nur den fehlerhaften Handler + + + # --- JETZT die Startmeldungen loggen (gehen jetzt in Konsole UND Datei) --- + logger.info(f"===== Skript gestartet =====") + logger.info(f"Version: {current_script_version}") + # Logge den tatsaechlichen Pfad der Logdatei oder die Fehlermeldung + logger.info(f"Logdatei: {LOG_FILE if LOG_FILE else 'FEHLER - Keine Logdatei erstellt'}") + # Loggen Sie relevante CLI Argumente zur Dokumentation des Laufs logger.info(f"CLI Argumente: {args}") - # 5. Globale Konfigurationsdaten laden - logger.info("Lade Konfigurationsdateien...") - Config.load_api_keys() - ziel_schema, fokus_branchen = load_target_schema() - branch_mapping_dict = load_branch_mapping() - if not ziel_schema or not branch_mapping_dict: - logger.critical("Fehler beim Laden von Konfigurationsdateien. Skriptabbruch.") - return + # --- Vorbereitung (Schema, Handler etc.) --- + # Laden Sie das Ziel-Branchenschema (Block 6) + # load_target_schema ist mit retry_on_failure dekoriert (Block 2). + load_target_schema() - # 6. Hauptkomponenten initialisieren - logger.info("Initialisiere Hauptkomponenten...") - sheet_handler = GoogleSheetHandler(Config.SHEET_ID, Config.SERVICE_ACCOUNT_FILE, Config.TOKEN_FILE) - wiki_scraper = WikipediaScraper() - data_processor = DataProcessor(sheet_handler, wiki_scraper, ziel_schema, fokus_branchen, branch_mapping_dict) - logger.info("DataProcessor erfolgreich initialisiert.") - - # 7. Modus-Dispatching - start_time = time.time() - logger.info(f"Starte Verarbeitung um {datetime.now().strftime('%H:%M:%S')}...") - + + # Initialisiere GoogleSheetHandler (Block 14) + sheet_handler = None # Initialisiere Variable try: - logger.info(f"Betriebsmodus: {selected_mode}") - final_limit_to_use = args.limit - - skippable_limit_modes = ['alignment', 'train_technician_model', 'analyze_ml_by_branch'] - if final_limit_to_use is None and selected_mode not in skippable_limit_modes: - try: - limit_input = input(f"Maximale Anzahl zu verarbeitender Zeilen für Modus '{selected_mode}' (Enter für Unbegrenzt): ") - if limit_input.strip(): - final_limit_to_use = int(limit_input) - logger.info(f"Limit für Zeilenverarbeitung (interaktiv gesetzt): {final_limit_to_use}") - except (ValueError, KeyboardInterrupt, EOFError): - logger.warning("Kein gültiges Limit eingegeben, wird als Unbegrenzt behandelt.") - final_limit_to_use = None - - # --- Ausführung des gewählten Modus --- - valid_single_row_steps = ['wiki', 'chat', 'web', 'ml_predict'] - steps_to_run_set = {step.strip().lower() for step in args.steps.split(',') if step.strip() and step.strip().lower() in valid_single_row_steps} - if not steps_to_run_set: - steps_to_run_set = set(valid_single_row_steps) # Default auf alle, wenn nichts Gültiges angegeben wurde - - # Dispatching basierend auf dem gewaehlten Modus (selected_mode) - if selected_mode == "combined_all": - data_processor.process_verification_batch(start_sheet_row=args.start_sheet_row, end_sheet_row=args.end_sheet_row, limit=args.limit) - data_processor.process_website_scraping_batch(start_sheet_row=args.start_sheet_row, end_sheet_row=args.end_sheet_row, limit=args.limit) - data_processor.process_summarization_batch(start_sheet_row=args.start_sheet_row, end_sheet_row=args.end_sheet_row, limit=args.limit) - data_processor.process_branch_batch(start_sheet_row=args.start_sheet_row, end_sheet_row=args.end_sheet_row, limit=args.limit) - elif selected_mode == "wiki_verify": - data_processor.process_verification_batch(start_sheet_row=args.start_sheet_row, end_sheet_row=args.end_sheet_row, limit=args.limit) - elif selected_mode == "website_scraping": - data_processor.process_website_scraping_batch(start_sheet_row=args.start_sheet_row, end_sheet_row=args.end_sheet_row, limit=args.limit) - elif selected_mode == "summarize_website": - data_processor.process_summarization_batch(start_sheet_row=args.start_sheet_row, end_sheet_row=args.end_sheet_row, limit=args.limit) - elif selected_mode == "branch_eval": - data_processor.process_branch_batch(start_sheet_row=args.start_sheet_row, end_sheet_row=args.end_sheet_row, limit=args.limit) - elif selected_mode == "suggest_parents": - data_processor.process_parent_suggestion_batch(start_sheet_row=args.start_sheet_row, end_sheet_row=args.end_sheet_row, limit=final_limit_to_use) - elif selected_mode == "full_run": - data_processor.process_rows_sequentially(start_sheet_row=args.start_sheet_row, end_sheet_row=args.end_sheet_row, num_to_process=final_limit_to_use, steps_to_run=steps_to_run_set) - elif selected_mode == "reeval": - data_processor.process_reevaluation_rows(row_limit=final_limit_to_use, steps_to_run=steps_to_run_set) - elif selected_mode == "find_wiki_serp": - data_processor.process_find_wiki_serp(start_sheet_row=args.start_sheet_row, end_sheet_row=args.end_sheet_row, limit=final_limit_to_use, min_employees=args.min_employees, min_umsatz=args.min_umsatz) - elif selected_mode == "website_lookup": - data_processor.process_serp_website_lookup(start_sheet_row=args.start_sheet_row, end_sheet_row=args.end_sheet_row, limit=final_limit_to_use) - elif selected_mode == "check_urls": - data_processor.process_url_check(start_sheet_row=args.start_sheet_row, end_sheet_row=args.end_sheet_row, limit=final_limit_to_use) - elif selected_mode == "contacts": - data_processor.process_contact_search(start_sheet_row=args.start_sheet_row, end_sheet_row=args.end_sheet_row, limit=final_limit_to_use) - elif selected_mode == "update_wiki_suggestions": - data_processor.process_wiki_updates_from_chatgpt(start_sheet_row=args.start_sheet_row, end_sheet_row=args.end_sheet_row, limit=final_limit_to_use) - elif selected_mode == "wiki_reextract_missing_an": - data_processor.process_wiki_reextract_missing_an(start_sheet_row=args.start_sheet_row, end_sheet_row=args.end_sheet_row, limit=final_limit_to_use) - elif selected_mode == "website_details": - data_processor.process_website_details(start_sheet_row=args.start_sheet_row, end_sheet_row=args.end_sheet_row, limit=final_limit_to_use) - elif selected_mode == "train_technician_model": - data_processor.train_technician_model(model_out=args.model_out or Config.MODEL_FILE, imputer_out=args.imputer_out or Config.IMPUTER_FILE, patterns_out=args.patterns_out or Config.PATTERNS_FILE_JSON) - elif selected_mode == "alignment": - alignment_demo(sheet_handler.sheet) - elif selected_mode == "reparatur_sitz": - data_processor.process_repair_sitz_data(start_sheet_row=args.start_sheet_row, end_sheet_row=args.end_sheet_row, limit=final_limit_to_use) - elif selected_mode == "plausi_check_data": - data_processor.run_plausibility_checks_batch(start_sheet_row=args.start_sheet_row, end_sheet_row=args.end_sheet_row, limit=final_limit_to_use) - else: - logger.error(f"Modus '{selected_mode}' ist definiert, hat aber keine Ausführungslogik in main().") - - except KeyboardInterrupt: - logger.warning("Skript durch Benutzer unterbrochen (KeyboardInterrupt).") - print("\n! Skript wurde manuell beendet.") + # Der GoogleSheetHandler Init (_init_ Methode) baut die Verbindung auf und laedt Daten. + # Fehler werden dort gefangen und als ConnectionError erneut geworfen. + sheet_handler = GoogleSheetHandler() #<- Zeile 11362 + logger.info("GoogleSheetHandler erfolgreich initialisiert.") + except ConnectionError as e: + # Wenn die Initialisierung des SheetHandlers fehlschlaegt (Verbindungs-/Ladefehler) + logger.critical(f"FATAL: Initialisierung des GoogleSheetHandlers fehlgeschlagen: {e}") + logger.critical(f"Bitte ueberpruefen Sie Ihre Google Sheets URL, Credentials und Berechtigungen.") + logger.critical(f"Bitte Logdatei pruefen fuer Details: {LOG_FILE}") + return # Beende Skript, wenn Sheet nicht geladen werden kann except Exception as e: - logger.critical(f"FATAL: Unerwarteter Fehler waehrend der Ausfuehrung von Modus '{selected_mode}': {e}") - logger.error(f"Traceback des kritischen Fehlers:\n{traceback.format_exc()}") - if LOG_FILE: - print(f"\n! Ein kritischer Fehler ist aufgetreten: {type(e).__name__} - {e}. Bitte Logdatei pruefen: {LOG_FILE}") + # Fangen Sie andere unerwartete Fehler bei der Initialisierung ab + logger.critical(f"FATAL: Unerwarteter Fehler bei Initialisierung von GoogleSheetHandler: {e}") + logger.debug(traceback.format_exc()) + logger.critical(f"Bitte Logdatei pruefen fuer Details: {LOG_FILE}") + return # Beende Skript + + + # Initialisiere WikipediaScraper (Block 14) + wiki_scraper = None # Initialisiere Variable + try: + # Der WikipediaScraper Init (_init_ Methode) konfiguriert die Bibliothek und Requests. + # Fehler werden dort gefangen und erneut geworfen. + wiki_scraper = WikipediaScraper() + logger.info("WikipediaScraper erfolgreich initialisiert.") + except Exception as e: + # Wenn die Initialisierung des WikipediaScrapers fehlschlaegt + logger.critical(f"FATAL: Initialisierung des WikipediaScrapers fehlgeschlagen: {e}") + logger.debug(traceback.format_exc()) + logger.critical(f"Bitte Logdatei pruefen fuer Details: {LOG_FILE}") + # Das Skript kann ohne Wiki Scraper viele Modi nicht sinnvoll laufen + return # Beende Skript + + + # TODO: Initialisieren Sie hier weitere Worker-Instanzen, falls Sie separate Klassen haben (z.B. OpenAIHandler, SerpAPIHandler) + # openai_handler = OpenAIHandler() + # serpapi_handler = SerpAPIHandler() + + logger.info("===== Spalten-Alignment Check =====") + if sheet_handler and sheet_handler.sheet_values and len(sheet_handler.sheet_values) > 0: + sheet_headers = sheet_handler.sheet_values[0] + logger.info(f"Header aus Google Sheet (erste {len(sheet_headers)} Spalten): {sheet_headers}") + + # Ausgabe der COLUMN_MAP für den Abgleich (gekürzt für Lesbarkeit im Log) + logger.info("Aktuelle COLUMN_MAP (Auszug):") + for i, (key, value) in enumerate(COLUMN_MAP.items()): + if i < 5 or i > len(COLUMN_MAP) - 6 : # Zeige erste 5 und letzte 5 + logger.info(f" '{key}': {value}") + elif i == 5: + logger.info(" ...") + else: + logger.warning("Konnte Header nicht aus Google Sheet laden für Alignment Check.") + logger.info("===================================") + + # Initialisiere DataProcessor Instanz (Block 15) mit Handlern + # Uebergeben Sie alle benoetigten Handler an den DataProcessor. + # Die __init__ Methode des DataProcessor (Block 15) prueft die Typen und wirft Value Error, wenn falsch. + try: + data_processor = DataProcessor(sheet_handler=sheet_handler, wiki_scraper=wiki_scraper) + logger.info("DataProcessor erfolgreich initialisiert.") + except Exception as e: + # Fangen Sie Fehler bei der DataProcessor Initialisierung ab. + logger.critical(f"FATAL: Initialisierung des DataProcessors fehlgeschlagen: {e}") + logger.debug(traceback.format_exc()) + logger.critical(f"Bitte Logdatei pruefen fuer Details: {LOG_FILE}") + return # Beende Skript + + + # --- Modusauswahl und Ausfuehrung --- + start_process_time = time.time() # Zeitmessung fuer die Verarbeitung starten + logger.info(f"Starte Verarbeitung um {datetime.now().strftime('%H:%M:%S')}...") + + + selected_mode = None # Variable fuer den tatsaechlich auszufuehrenden Modus + + # --- Ermitteln des zu fuehrenden Modus (CLI hat Prioritaet vor interaktiver Auswahl) --- + # Wenn das --mode Argument ueber die Kommandozeile gesetzt wurde + if args.mode: + selected_mode = args.mode.lower() # Konvertiere zu Kleinbuchstaben + # Pruefen Sie, ob der gewaehlte Modus in der Liste der validen Modi enthalten ist + if selected_mode not in valid_modes: + # Logge einen Fehler und beende das Skript, wenn der Modus ungueltig ist. + logger.error(f"Ungueltiger Modus '{args.mode}' ueber Kommandozeile angegeben. Gueltige Modi: {', '.join(valid_modes)}") + print(f"Fehler: Ungueltiger Modus '{args.mode}'. Bitte ueberpruefen Sie die Liste der gueltigen Modi (siehe --help).") + return # Skript beenden + logger.info(f"Betriebsmodus (CLI gewaehlt): {selected_mode}") + + # Wenn das --mode Argument NICHT ueber die Kommandozeile gesetzt wurde + else: + # --- Interaktive Modusauswahl ueber die Konsole --- + print("\nBitte waehlen Sie den Betriebsmodus:") + # Zeigen Sie die Liste der validen Modi kategorisiert an, mit Nummern. + mode_options_map = {} # Dictionary zum Abbilden von Zahl/Name auf Modusname + option_counter = 1 # Zaehler fuer die numerischen Optionen + # Iteriere durch die Kategorien und Modi + for category, modes in mode_categories.items(): + print(f"\n{category}:") + for mode in modes: + print(f" {option_counter}: {mode}") + mode_options_map[str(option_counter)] = mode # Bilde die numerische Option auf den Modusnamen ab + mode_options_map[mode] = mode # Bilde den Modusnamen (kleingeschrieben) auf sich selbst ab (fuer direkte Eingabe) + option_counter += 1 # Erhoehe den Zaehler + + + # Fuegen Sie eine Option zum Abbrechen hinzu + print(f"\n 0: Abbrechen") + mode_options_map['0'] = 'exit' # Bilde 0 auf den speziellen 'exit' Modus ab + + + # Schleife, bis ein gueltiger Modus gewaehlt wurde oder der Benutzer abbricht + while selected_mode is None: + try: + # Lesen Sie die Eingabe vom Benutzer + mode_input = input(f"Geben Sie den Modusnamen oder die Zahl ein: ").strip().lower() + + # Pruefen Sie, ob die Eingabe einer Option in der Map entspricht + if mode_input in mode_options_map: + selected_mode = mode_options_map[mode_input] # Setzen Sie den gewaehlten Modusnamen + + # Wenn der 'exit' Modus gewaehlt wurde + if selected_mode == 'exit': + logger.info("Modus 'exit' gewaehlt. Skript wird beendet.") + print("Abgebrochen durch Benutzer.") + return # Beende das Skript + + # Logge den gewaehlten Modus + logger.info(f"Betriebsmodus (interaktiv gewaehlt): {selected_mode}") + + else: + # Wenn die Eingabe keinem gueltigen Modus entspricht + print("Ungueltige Eingabe. Bitte waehlen Sie eine gueltige Option aus der Liste.") + + # Wenn selected_mode immer noch None ist, laeuft die Schleife weiter + + + except EOFError: # Benutzer hat Ctrl+D gedrueckt (End-of-File) + # Fangen Sie das EOFError ab und beenden Sie das Skript sauber. + logger.warning("Interaktive Modus-Eingabe abgebrochen (EOFError). Skript wird beendet.") + print("\nEingabe abgebrochen.") + return # Beende das Skript + except Exception as e: + # Fangen Sie andere unerwartete Fehler bei der Eingabe ab + logger.error(f"Fehler bei interaktiver Modus-Eingabe: {e}") + logger.debug(traceback.format_exc()) + print(f"Ein Fehler ist bei der Modus-Eingabe aufgetreten ({e}). Bitte pruefen Sie die Logdatei.") + return # Beende das Skript bei unerwartetem Fehler + + # ============================================================================== + # === NEUER BLOCK: Interaktive Limit-Abfrage === + # ============================================================================== + limit_arg_cli = args.limit # ursprüngliches Limit vom CLI Argument + final_limit_to_use = limit_arg_cli # Standardmäßig das CLI-Limit verwenden + + # Wenn kein Limit über CLI gesetzt wurde UND der Modus nicht einer der ist, + # bei denen ein Zeilenlimit typischerweise keinen Sinn macht. + # (Für 'alignment' und 'train_technician_model' ist ein Zeilen-Durchlauf-Limit meist nicht relevant) + # 'check_urls' und andere Batch-Modi können aber von einem Limit profitieren. + skippable_limit_modes = ['alignment', 'train_technician_model'] + + if final_limit_to_use is None and selected_mode not in skippable_limit_modes: + while True: + try: + limit_input_str = input(f"Maximale Anzahl zu verarbeitender Zeilen für Modus '{selected_mode}' (Enter für Unbegrenzt, aktuell: Unbegrenzt): ").strip() + if not limit_input_str: # Benutzer drückt Enter + final_limit_to_use = None + logger.info("Kein Limit für Zeilenverarbeitung gesetzt (interaktiv).") + break + + # Versuche, in Integer umzuwandeln + temp_limit = int(limit_input_str) + + if temp_limit <= 0: + logger.warning(f"Ungültiges Limit '{temp_limit}' (<=0) eingegeben, wird als Unbegrenzt behandelt.") + final_limit_to_use = None + else: + final_limit_to_use = temp_limit + logger.info(f"Limit für Zeilenverarbeitung (interaktiv gesetzt): {final_limit_to_use}") + break # Gültige Eingabe oder Entscheidung für "Unbegrenzt" + except ValueError: + print("Ungültige Eingabe. Bitte eine ganze Zahl eingeben oder Enter für Unbegrenzt.") + except EOFError: + logger.warning("Interaktive Limit-Eingabe abgebrochen. Nutze kein Limit (oder CLI-Vorgabe, falls vorhanden).") + # final_limit_to_use behält den Wert von limit_arg_cli (also None, wenn hierher gekommen) + break + except Exception as e_limit_input: + logger.error(f"Unerwarteter Fehler bei interaktiver Limit-Eingabe: {e_limit_input}") + logger.debug(traceback.format_exc()) + print("Ein Fehler ist bei der Limit-Eingabe aufgetreten. Nutze kein Limit.") + final_limit_to_use = None + break + elif final_limit_to_use is not None: # Wenn ein Limit via CLI gesetzt wurde + logger.info(f"Verwende Limit aus CLI-Argument: {final_limit_to_use}") + elif selected_mode in skippable_limit_modes and final_limit_to_use is None: + logger.info(f"Modus '{selected_mode}' benötigt typischerweise kein Zeilenlimit. Limit-Abfrage übersprungen.") + + + # Die Variable `final_limit_to_use` enthält nun das anzuwendende Limit (entweder von CLI, interaktiv oder None) + # ============================================================================== + # === ENDE NEUER BLOCK === + # ============================================================================== + + + # --- Ausfuehrung des gewaehlten Modus --- + try: + # Holen Sie die CLI-Argumente fuer Start/End/Limit/Steps + limit_arg = args.limit + start_row_arg = args.start_sheet_row + end_row_arg = args.end_sheet_row + + # Sonderbehandlung fuer --steps Argument (relevant fuer reeval und full_run) + steps_to_run_set = set() # Initialisiere ein leeres Set + # Pruefen Sie, ob das --steps Argument gesetzt ist und nicht "all" (case-insensitive) + if args.steps and isinstance(args.steps, str) and args.steps.strip().lower() != 'all': + # Teilen Sie den String in Schritte auf und bereinigen Sie Leerzeichen + steps_list = [step.strip().lower() for step in args.steps.split(',') if step.strip()] + # Filtern Sie nur erlaubte Schritte (die von _process_single_row verstanden werden Block 19) + steps_to_run_set = set(step for step in steps_list if step in valid_single_row_steps) # valid_single_row_steps wurde oben definiert + + # Logge eine Warnung, wenn ungueltige Schritte angegeben wurden + if len(steps_to_run_set) != len(steps_list): + invalid_steps = [step for step in steps_list if step not in valid_single_row_steps] + logger.warning(f"Ignoriere ungueltige Schritte im --steps Argument: {invalid_steps}. Fuehre nur {steps_to_run_set} aus.") + + # Wenn nach der Filterung keine gueltigen Schritte uebrig sind + if not steps_to_run_set: + logger.error("Keine gueltigen Schritte im --steps Argument gefunden. Re-Eval/Full-Run kann nicht gestartet werden.") + print("Fehler: Keine gueltigen Schritte fuer den Modus ausgewaehlt. Bitte ueberpruefen Sie das --steps Argument.") + return # Skript beenden, wenn keine Schritte ausgewaehlt sind + + # Wenn das --steps Argument 'all' ist oder nicht gesetzt else: - print(f"\n! Ein kritischer Fehler ist aufgetreten: {type(e).__name__} - {e}.") - finally: - end_time = time.time() - logger.info(f"Verarbeitung abgeschlossen um {datetime.now().strftime('%H:%M:%S')}.") - logger.info(f"Gesamtdauer: {(end_time - start_time):.2f} Sekunden.") - logger.info("===== Skript beendet =====") - logging.shutdown() - if LOG_FILE: - print(f"\nVerarbeitung abgeschlossen. Logfile: {LOG_FILE}") + # Fuhren Sie standardmaessig alle gueltigen Single-Row Schritte aus. + steps_to_run_set = set(valid_single_row_steps) # valid_single_row_steps wurde oben definiert + # Logge, welche Schritte ausgewaehlt wurden, wenn es der Standard ist + if default_steps_arg: # Wenn es ueberhaupt gueltige Schritte gibt + logger.debug(f"--steps Argument 'all' oder nicht gesetzt. Standard Schritte: {steps_to_run_set}.") + + + # Dispatching basierend auf dem gewaehlten Modus (selected_mode) + logger.info(f"Starte Ausfuehrung des Modus: {selected_mode}") + + # ---- Kombinierte LÄUFE ---- + if selected_mode == "combined_all": + # Führt die wichtigsten Batch-Modi nacheinander aus + logger.info("--- Start Kombinierter Modus: wiki_verify ---") + # Rufe die Methode der DataProcessor Instanz auf (Block 26) + data_processor.process_verification_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) + logger.info("--- Start Kombinierter Modus: website_scraping ---") + # Rufe die Methode der DataProcessor Instanz auf (Block 27) + data_processor.process_website_scraping_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) + logger.info("--- Start Kombinierter Modus: summarize_website ---") + # Rufe die Methode der DataProcessor Instanz auf (Block 28) + data_processor.process_summarization_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) + logger.info("--- Start Kombinierter Modus: branch_eval ---") + # Rufe die Methode der DataProcessor Instanz auf (Block 29) + data_processor.process_branch_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) + # TODO: Fuegen Sie hier weitere Batch-Modi hinzu, falls sie im kombinierten Lauf enthalten sein sollen + logger.info("--- Kombinierter Modus abgeschlossen ---") + + + # ---- Batch-VERARBEITUNG (Schritt-Optimiert) ---- + elif selected_mode == "wiki_verify": # Entspricht dem Batch-Modus Wiki Verifizierung (AX) + # Rufe die Methode der DataProcessor Instanz auf (Block 26) + data_processor.process_verification_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) + + elif selected_mode == "website_scraping": # Entspricht dem Batch-Modus Website Scraping (AR, AT, AP) + # Rufe die Methode der DataProcessor Instanz auf (Block 27) + data_processor.process_website_scraping_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) + + elif selected_mode == "summarize_website": # Entspricht dem Batch-Modus Website Summarization (AS, AP) + # Rufe die Methode der DataProcessor Instanz auf (Block 28) + data_processor.process_summarization_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) + + elif selected_mode == "branch_eval": # Entspricht dem Batch-Modus Branchen-Einstufung (W-Y, AO, AP) + # Rufe die Methode der DataProcessor Instanz auf (Block 29) + data_processor.process_branch_batch(start_sheet_row=start_row_arg, end_sheet_row=end_row_arg, limit=limit_arg) + + + # ---- Sequentielle VERARBEITUNG (Zeilenweise) ---- + elif selected_mode == "full_run": # Nutzt process_rows_sequentially (Block 24) + # Full_run verarbeitet sequentiell einen Bereich. + # Startzeile wird vom CLI Argument oder automatisch ermittelt (erste leere AO). + # Endzeile vom CLI Argument oder bis Ende Sheet. + # Limit begrenzt die Anzahl der *verarbeiteten* Zeilen im Bereich. + + calculated_start_sheet_row = start_row_arg # Beginne mit CLI Argument start_sheet_row + # Wenn start_sheet_row nicht ueber CLI gesetzt wurde + if calculated_start_sheet_row is None: + # Automatische Ermittlung der Startzeile (erste Zeile ohne AO) + logger.info("Automatische Ermittlung der Startzeile fuer sequenzielle Verarbeitung (erste Zeile ohne AO)...") + # get_start_row_index (Block 14) gibt 0-basierten Index in Daten (ohne Header) zurueck. + # Prueft auf leeren AO (Block 1 Column Map). + start_data_index_no_header = sheet_handler.get_start_row_index(check_column_key="Timestamp letzte Pruefung", min_sheet_row=7) + + # Wenn get_start_row_index -1 zurueckgibt (Fehler) + if start_data_index_no_header == -1: + logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Kann Full-Run nicht starten.") + return # Beende das Skript + + # Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten Daten-Index + calculated_start_sheet_row = start_data_index_no_header + sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut + + + # Berechnen Sie die tatsaechliche Anzahl der zu verarbeitenden Zeilen im Bereich. + # (basierend auf Endzeile und Limit) + total_sheet_rows = len(sheet_handler.get_all_data_with_headers()) # Block 14 SheetHandler + calculated_end_sheet_row = end_row_arg if end_row_arg is not None else total_sheet_rows + # Stellen Sie sicher, dass die Endzeile nicht vor der Startzeile liegt + calculated_end_sheet_row = max(calculated_start_sheet_row - 1, calculated_end_sheet_row) + + + # Die Anzahl der Zeilen im betrachteten Bereich + rows_in_range = max(0, calculated_end_sheet_row - calculated_start_sheet_row + 1) + + # num_to_process ist das Limit, angewendet auf die Zeilen im Bereich. + num_to_process_calc = rows_in_range # Standard: alle Zeilen im Bereich + # Wenn ein Limit ueber CLI gesetzt wurde und es gueltig ist + if limit_arg is not None and isinstance(limit_arg, int) and limit_arg >= 0: + num_to_process_calc = min(rows_in_range, limit_arg) + + + # Wenn es Zeilen zu verarbeiten gibt + if num_to_process_calc > 0: + logger.info(f"'full_run': Verarbeite {num_to_process_calc} Zeilen im Sheet-Bereich [{calculated_start_sheet_row}, {calculated_end_sheet_row}].") + # Rufe die sequentielle Verarbeitungsmethode auf (Block 24) + # _process_single_row (Block 19) wird intern aufgerufen. + data_processor.process_rows_sequentially( + start_sheet_row = calculated_start_sheet_row, + num_to_process = num_to_process_calc, + # Uebergeben Sie die aus dem --steps Argument ermittelten Flags (steps_to_run_set) + process_wiki_steps='wiki' in steps_to_run_set, + process_chatgpt_steps='chat' in steps_to_run_set, + process_website_steps='web' in steps_to_run_set, + process_ml_steps='ml_predict' in steps_to_run_set + # TODO: Weitere Schritt-Flags hier uebergeben + # force_reeval_in_single_row=False # Normalerweise kein Re-Eval im Full-Run + # clear_x_flag=False # Normalerweise kein X loeschen im Full-Run + ) + else: + # Wenn keine Zeilen zu verarbeiten sind + logger.info(f"Keine Zeilen fuer 'full_run' zu verarbeiten im Bereich [{calculated_start_sheet_row}, {calculated_end_sheet_row}] mit Limit {limit_arg}.") + + + # ---- Re-EVALUATE Markierte Zeilen ---- + elif selected_mode == "reeval": # Nutzt process_reevaluation_rows (Block 25) + # reeval Modus nutzt immer force_reeval=True in _process_single_row. + # Das 'x'-Flag wird von _process_single_row (Block 21) geloescht, wenn clear_flag=True uebergeben wird. + # Das Limit wird direkt an process_reevaluation_rows uebergeben und dort gehandhabt. + if limit_arg is not None and isinstance(limit_arg, int) and limit_arg <= 0: + # Wenn ein Limit von 0 oder weniger angegeben wurde + logger.info(f"Limit {limit_arg} angegeben im Re-Eval Modus. Ueberspringe Verarbeitung.") + else: + # Rufe die Methode der DataProcessor Instanz auf (Block 25) + data_processor.process_reevaluation_rows( + row_limit=limit_arg, # Uebergibt das Limit (kann None sein) + clear_flag=True, # Standardmaessig das 'x'-Flag loeschen + # Uebergeben Sie die aus dem --steps Argument ermittelten Schritte (steps_to_run_set) + process_wiki_steps='wiki' in steps_to_run_set, + process_chatgpt_steps='chat' in steps_to_run_set, + process_website_steps='web' in steps_to_run_set, + process_ml_steps='ml_predict' in steps_to_run_set + # TODO: Weitere Schritt-Flags hier uebergeben + ) + + + # ---- Einzelne DIENSTPROGRAMME / SUCHEN ---- + elif selected_mode == "find_wiki_serp": # Nutzt process_find_wiki_serp (Block 30) + # find_wiki_serp sucht leere AY mit Groessenfilter. Nutzt limit, min_employees, min_umsatz. + # Start/Endzeile koennen manuell gesetzt werden oder werden automatisch ermittelt (erste leere AY). + data_processor.process_find_wiki_serp( + start_sheet_row=start_row_arg, # Kann manuell gesetzt werden + end_sheet_row=end_row_arg, # Kann manuell gesetzt werden + limit=limit_arg, # Kann manuell gesetzt werden + min_employees=args.min_employees, # Aus CLI Argument + min_umsatz=args.min_umsatz # Aus CLI Argument + ) + + elif selected_mode == "website_lookup": # Nutzt process_serp_website_lookup (Block 30) + # website_lookup sucht leere D. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden. + data_processor.process_serp_website_lookup( + start_sheet_row=start_row_arg, # Kann manuell gesetzt werden + end_sheet_row=end_row_arg, # Kann manuell gesetzt werden + limit=limit_arg # Kann manuell gesetzt werden + ) + + elif selected_mode == "check_urls": + data_processor.process_url_check( + start_sheet_row=start_row_arg, + end_sheet_row=end_row_arg, + limit=limit_arg + ) + + elif selected_mode == "contacts": # Nutzt process_contact_search (Block 30) + # contacts sucht leere AM. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden. + data_processor.process_contact_search( + start_sheet_row=start_row_arg, # Kann manuell gesetzt werden + end_sheet_row=end_row_arg, # Kann manuell gesetzt werden + limit=limit_arg # Kann manuell gesetzt werden + ) + + elif selected_mode == "update_wiki_suggestions": # Nutzt process_wiki_updates_from_chatgpt (Block 32) + # update_wiki_suggestions prueft Status S. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden. + data_processor.process_wiki_updates_from_chatgpt( + start_sheet_row=start_row_arg, # Kann manuell gesetzt werden + end_sheet_row=end_row_arg, # Kann manuell gesetzt werden + limit=limit_arg # Kann manuell gesetzt werden + ) + + elif selected_mode == "wiki_reextract_missing_an": # Nutzt process_wiki_reextract_missing_an (Block 32) + # wiki_reextract_missing_an sucht M gefuellt & AN leer. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden. + # Ruft intern _process_single_row mit steps={'wiki'} und force_reeval=True auf. + data_processor.process_wiki_reextract_missing_an( + start_sheet_row=start_row_arg, # Kann manuell gesetzt werden + end_sheet_row=end_row_arg, # Kann manuell gesetzt werden + limit=limit_arg # Kann manuell gesetzt werden + ) + + + elif selected_mode == "website_details": # EXPERIMENTELL - Nutzt process_website_details (Block 32) + # website_details sucht 'x' in A. Nutzt limit. Start/Endzeile koennen manuell gesetzt werden. + data_processor.process_website_details( + start_sheet_row=start_row_arg, # Kann manuell gesetzt werden + end_sheet_row=end_row_arg, # Kann manuell gesetzt werden + limit=limit_arg # Kann manuell gesetzt werden + ) + + + elif selected_mode == "train_technician_model": # Nutzt train_technician_model (Block 31) + # training braucht keine Zeilenlimits im Sinne eines Bereichs oder der Anzahl zu verarbeitender Zeilen im Sheet. + # Es nutzt prepare_data_for_modeling (Block 31), die alle relevanten Zeilen filtert. + # Die output-Pfade werden aus CLI Argumenten genommen (args). + data_processor.train_technician_model( + model_out=args.model_out, # Aus CLI Argument + imputer_out=args.imputer_out, # Aus CLI Argument + patterns_out=args.patterns_out # Aus CLI Argument (JSON Datei) + ) + + elif selected_mode == "alignment": # Nutzt globale alignment_demo (Block 14) + # alignment_demo ist eine globale Funktion, die das sheet Objekt braucht. + # Sie braucht keine Zeilenlimits oder Start/Ende. + if sheet_handler and sheet_handler.sheet: + alignment_demo(sheet_handler.sheet) + else: + logger.error("Sheet-Handler oder Sheet-Objekt nicht verfuegbar fuer Alignment-Demo.") + + elif selected_mode == "reparatur_sitz": # NEUER BLOCK + # Hier können Sie Start, Ende und Limit aus args verwenden, falls Sie dafür CLI-Optionen hinzufügen möchten + # oder feste Werte / interaktive Abfragen für diesen Modus implementieren. + # Für den Anfang ein kompletter Durchlauf (ab Datenstart): + data_processor.process_repair_sitz_data( + start_sheet_row=None, # Beginnt nach den Headern + end_sheet_row=None, # Bis zum Ende des Sheets + limit=final_limit_to_use # Verwendet das global ermittelte Limit + ) + + elif selected_mode == "plausi_check_data": + data_processor.run_plausibility_checks_batch( + start_sheet_row=args.start_sheet_row, # Nimmt CLI-Argumente für Bereich + end_sheet_row=args.end_sheet_row, + limit=final_limit_to_use # VERWENDE das ermittelte Limit + ) + + + elif selected_mode == "suggest_parents": # <<< NEUER ELIF-BLOCK + data_processor.process_parent_suggestion_batch( + start_sheet_row=args.start_sheet_row, + end_sheet_row=args.end_sheet_row, + limit=final_limit_to_use, # Nutzt das ggf. interaktiv abgefragte Limit + re_evaluate_question_mark=True # Beispiel: Standardmäßig Fragezeichen neu bewerten + # Sie können hierfür auch ein CLI Argument hinzufügen + ) + + # ---- Modus nicht gefunden (sollte durch Validierung oben abgefangen werden) ---- + else: + # Dieser Zweig sollte aufgrund der Validierung am Anfang nie erreicht werden. + logger.error(f"Unerwarteter Modus '{selected_mode}' erreichte das Ausfuehrungsende des Dispatchers.") + print(f"Interner Fehler: Unbekannter Modus '{selected_mode}'.") + + + # --- Ausnahmebehandlung fuer den gesamten Ausfuehrungsblock --- + except KeyboardInterrupt: + # Wenn der Benutzer das Skript manuell unterbricht (Ctrl+C) + logger.warning("Skript durch Benutzer unterbrochen (KeyboardInterrupt).") + print("\n! Skript wurde manuell beendet.") + except Exception as e: + # Dieser Block faengt alle unerwarteten Exceptions ab, die in den aufgerufenen + # Funktionen/Methoden passieren und nicht intern gefangen und behandelt werden. + logger.critical(f"FATAL: Unerwarteter Fehler waehrend der Ausfuehrung von Modus '{selected_mode}': {e}") + # exception() loggt den Fehlertyp, die Nachricht und den vollständigen Traceback. + logger.exception("Traceback des kritischen Fehlers:") + # Gebe eine Fehlermeldung an die Konsole aus, die auf das Log verweist. + print(f"\n! Ein kritischer Fehler ist aufgetreten: {type(e).__name__} - {e}") + print(f"Bitte pruefen Sie die Logdatei fuer Details: {LOG_FILE}") + + + # --- Abschluss der Skriptausfuehrung --- + end_process_time = time.time() # Ende der Zeitmessung + duration = end_process_time - start_process_time # Berechne die Gesamtdauer + logger.info(f"Verarbeitung abgeschlossen um {datetime.now().strftime('%H:%M:%S')}.") + logger.info(f"Gesamtdauer: {duration:.2f} Sekunden.") + logger.info(f"===== Skript beendet =====") + + # Schliesse Logging Handler explizit + # Dies stellt sicher, dass alle gepufferten Logmeldungen in die Datei geschrieben werden. + logging.shutdown() + + # Logfile Pfad fuer den Nutzer auf der Konsole ausgeben + if LOG_FILE: + print(f"\nVerarbeitung abgeschlossen. Logfile: {LOG_FILE}") + else: + print("\nVerarbeitung abgeschlossen. Es konnte keine Logdatei erstellt werden.") # ============================================================================== @@ -10512,7 +10877,7 @@ if __name__ == '__main__': # Alle globalen imports und Funktionen MÜSSEN VOR diesem Block definiert sein. # Die Klassen MÜSSEN VOR diesem Block definiert sein. - main() #Zeile 10957 + main() # ==============================================================================