refactor: [30388f42] Finale Komprimierung des Root-Verzeichnisses
- Konsolidiert Dockerfiles in . - Verschiebt Datenbank- und Log-Dateien in . - Organisiert Konfigurations- und Modelldateien in . - Fasst Shell-Skripte in zusammen. - Verschiebt nach . - Verschiebt nach . - Das Verzeichnis wurde in verschoben. - Behält Kern-Dateien (, , , , etc.) im Root-Verzeichnis, um die Lauffähigkeit zu gewährleisten.
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
brancheneinstufung.py - Hauptskript v1.8.0
|
||||
|
||||
Dieses Skript dient als Haupteinstiegspunkt für das Projekt zur automatisierten
|
||||
Anreicherung, Validierung und Standardisierung von Unternehmensdaten. Es parst
|
||||
Kommandozeilen-Argumente, initialisiert die notwendigen Handler und den
|
||||
DataProcessor und startet den ausgewählten Verarbeitungsmodus.
|
||||
|
||||
Autor: Christian Godelmann
|
||||
Version: v1.8.0
|
||||
"""
|
||||
print("--- START ---")
|
||||
import logging
|
||||
print("--- logging importiert ---")
|
||||
import os
|
||||
print("--- os importiert ---")
|
||||
import argparse
|
||||
print("--- argparse importiert ---")
|
||||
import time
|
||||
print("--- time importiert ---")
|
||||
from datetime import datetime
|
||||
print("--- datetime importiert ---")
|
||||
|
||||
from config import Config
|
||||
print("--- config importiert ---")
|
||||
from helpers import create_log_filename, initialize_target_schema, alignment_demo, log_module_versions
|
||||
print("--- helpers importiert ---")
|
||||
from google_sheet_handler import GoogleSheetHandler
|
||||
print("--- google_sheet_handler importiert ---")
|
||||
from wikipedia_scraper import WikipediaScraper
|
||||
print("--- wikipedia_scraper importiert ---")
|
||||
from data_processor import DataProcessor
|
||||
print("--- data_processor importiert ---")
|
||||
from sync_manager import SyncManager
|
||||
print("--- sync_manager importiert ---")
|
||||
|
||||
|
||||
import helpers
|
||||
import google_sheet_handler
|
||||
import wikipedia_scraper
|
||||
import data_processor
|
||||
|
||||
# ==============================================================================
|
||||
# 1. INITIALE KONFIGURATION (wird vor allem anderen ausgeführt)
|
||||
# ==============================================================================
|
||||
|
||||
# Logging sofort konfigurieren, damit es für alle importierten Module greift.
|
||||
LOG_LEVEL = logging.DEBUG if Config.DEBUG else logging.INFO
|
||||
LOG_FORMAT = '%(asctime)s - %(levelname)-8s - %(name)-25s - %(message)s'
|
||||
logging.basicConfig(level=LOG_LEVEL, format=LOG_FORMAT, force=True, handlers=[logging.StreamHandler()])
|
||||
|
||||
# Haupt-Logger für dieses Skript
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ==============================================================================
|
||||
# 2. HAUPTFUNKTION
|
||||
# ==============================================================================
|
||||
|
||||
def main():
|
||||
"""
|
||||
Haupteinstiegspunkt des Skripts.
|
||||
Verarbeitet Kommandozeilen-Argumente, richtet Logging ein,
|
||||
initialisiert Komponenten und dispatchet zu den passenden Modi.
|
||||
"""
|
||||
# --- Importe innerhalb der Funktion, um Abhängigkeiten klar zu halten ---
|
||||
import argparse
|
||||
import time
|
||||
import logging
|
||||
import os
|
||||
|
||||
# KORREKTUR: Die Funktionen kommen aus 'helpers', nicht aus 'config'
|
||||
from config import Config
|
||||
from helpers import log_module_versions, create_log_filename
|
||||
|
||||
from google_sheet_handler import GoogleSheetHandler
|
||||
from wikipedia_scraper import WikipediaScraper
|
||||
from data_processor import DataProcessor
|
||||
from sync_manager import SyncManager
|
||||
import helpers
|
||||
import google_sheet_handler
|
||||
|
||||
# --- Argument Parser ---
|
||||
parser = argparse.ArgumentParser(
|
||||
description=f"Firmen-Datenanreicherungs-Skript {Config.VERSION}.",
|
||||
formatter_class=argparse.RawTextHelpFormatter
|
||||
)
|
||||
mode_categories = {
|
||||
"Daten-Synchronisation": ["sync", "simulate_sync"],
|
||||
"Batch-Verarbeitung": ["wiki_verify", "website_scraping", "summarize_website", "branch_eval", "suggest_parents", "fsm_pitch"],
|
||||
"Sequentielle Verarbeitung": ["full_run"],
|
||||
"Re-Evaluation": ["reeval"],
|
||||
"Dienstprogramme": ["find_wiki_serp", "check_urls", "contacts", "update_wiki_suggestions", "wiki_reextract_missing_an", "website_details", "train_technician_model", "predict_technicians", "alignment", "reparatur_sitz", "plausi_check_data"],
|
||||
"Kombinierte Läufe": ["combined_all"],
|
||||
"Spezial-Modi": ["reclassify_branches"],
|
||||
}
|
||||
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" + "".join([f" - {mode}\n" for mode in modes])
|
||||
|
||||
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_steps = ['wiki', 'chat', 'web', 'ml_predict']
|
||||
parser.add_argument("--steps", type=str, help=f"Schritte für 'reeval'/'full_run' (z.B. 'wiki,chat'). Optionen: {', '.join(valid_steps)}.", default=','.join(valid_steps))
|
||||
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="Mindest-MA für 'find_wiki_serp'.", default=500)
|
||||
parser.add_argument("--debug_id", type=str, help="Eine spezifische CRM ID für eine Tiefenanalyse im 'debug_sync'-Modus.", default=None)
|
||||
parser.add_argument("--sync_file", type=str, help="Pfad zur D365 Excel-Exportdatei für den 'sync'-Modus.", default="d365_export.xlsx")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# --- Modusauswahl (interaktiv, wenn nicht über CLI) ---
|
||||
selected_mode = args.mode.lower() if args.mode else None
|
||||
if not selected_mode:
|
||||
print("\nBitte waehlen Sie den Betriebsmodus:")
|
||||
mode_map = {}
|
||||
counter = 1
|
||||
for category, modes in mode_categories.items():
|
||||
print(f"\n{category}:")
|
||||
for mode in modes:
|
||||
print(f" {counter}: {mode}")
|
||||
mode_map[str(counter)] = mode
|
||||
mode_map[mode] = mode
|
||||
counter += 1
|
||||
print("\n 0: Abbrechen")
|
||||
mode_map['0'] = 'exit'
|
||||
|
||||
while selected_mode is None:
|
||||
try:
|
||||
choice = input("Geben Sie den Modusnamen oder die Zahl ein: ").strip().lower()
|
||||
if choice in mode_map:
|
||||
selected_mode = mode_map[choice]
|
||||
if selected_mode == 'exit':
|
||||
print("Abgebrochen.")
|
||||
return
|
||||
else:
|
||||
print("Ungueltige Eingabe.")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\nAbgebrochen.")
|
||||
return
|
||||
|
||||
# --- Logging Konfiguration ---
|
||||
LOG_LEVEL = logging.DEBUG if Config.DEBUG else logging.INFO
|
||||
LOG_FORMAT = '%(asctime)s - %(levelname)-8s - %(name)-25s - %(message)s'
|
||||
logging.basicConfig(level=LOG_LEVEL, format=LOG_FORMAT)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
log_file_path = create_log_filename(selected_mode)
|
||||
if log_file_path:
|
||||
file_handler = logging.FileHandler(log_file_path, mode='a', encoding='utf-8')
|
||||
file_handler.setLevel(LOG_LEVEL)
|
||||
file_handler.setFormatter(logging.Formatter(LOG_FORMAT))
|
||||
logging.getLogger('').addHandler(file_handler)
|
||||
|
||||
logger.info(f"===== Skript gestartet: Modus '{selected_mode}' =====")
|
||||
logger.info(f"Projekt-Version (Config): {Config.VERSION}")
|
||||
logger.info(f"Logdatei: {log_file_path or 'FEHLER - Keine Logdatei'}")
|
||||
logger.info(f"CLI Argumente: {args}")
|
||||
|
||||
# --- Hauptlogik ---
|
||||
try:
|
||||
Config.load_api_keys()
|
||||
sheet_handler = GoogleSheetHandler()
|
||||
|
||||
# --- Modus-Dispatching ---
|
||||
start_time = time.time()
|
||||
|
||||
if selected_mode == "simulate_sync":
|
||||
logger.info("Führe Initialisierung für Sync-Simulations-Modus durch...")
|
||||
if not sheet_handler.load_data():
|
||||
logger.critical("Konnte initiale Daten aus dem Google Sheet nicht laden. Simulation wird abgebrochen.")
|
||||
return
|
||||
|
||||
d365_file_path = args.sync_file
|
||||
if not os.path.exists(d365_file_path):
|
||||
logger.critical(f"Export-Datei nicht gefunden: {d365_file_path}")
|
||||
else:
|
||||
sync_manager = SyncManager(sheet_handler, d365_file_path)
|
||||
sync_manager.simulate_sync() # Aufruf der neuen Simulations-Funktion
|
||||
|
||||
# Der elif-Block für den regulären Sync
|
||||
elif selected_mode == "sync":
|
||||
logger.info("Führe Initialisierung für Sync-Modus durch...")
|
||||
if not sheet_handler.load_data():
|
||||
logger.critical("Konnte initiale Daten aus dem Google Sheet nicht laden. Sync-Prozess wird abgebrochen.")
|
||||
return
|
||||
|
||||
d365_file_path = args.sync_file
|
||||
if not os.path.exists(d365_file_path):
|
||||
logger.critical(f"Export-Datei nicht gefunden: {d365_file_path}")
|
||||
else:
|
||||
sync_manager = SyncManager(sheet_handler, d365_file_path)
|
||||
sync_manager.run_sync()
|
||||
|
||||
# Ab hier beginnt die bisherige Logik für alle anderen Modi
|
||||
else:
|
||||
wiki_scraper = WikipediaScraper()
|
||||
data_processor = DataProcessor(sheet_handler=sheet_handler, wiki_scraper=wiki_scraper)
|
||||
|
||||
# --- Modul-Versionen loggen (NACH der Initialisierung) ---
|
||||
modules_to_log = {
|
||||
"DataProcessor": data_processor,
|
||||
"GoogleSheetHandler": google_sheet_handler,
|
||||
"WikipediaScraper": wikipedia_scraper,
|
||||
"Helpers": helpers
|
||||
}
|
||||
log_module_versions(modules_to_log)
|
||||
# --- Ende Version-Logging ---
|
||||
|
||||
# Expliziter Setup-Aufruf, nachdem alle Konfigurationen geladen sind.
|
||||
if not data_processor.setup():
|
||||
logger.critical("Setup des DataProcessors fehlgeschlagen. Das Skript wird beendet.")
|
||||
return
|
||||
|
||||
# --- Modus-Dispatching für die restlichen Modi ---
|
||||
steps_to_run_set = set(step.strip().lower() for step in args.steps.split(',') if step.strip() in valid_steps) if args.steps else set(valid_steps)
|
||||
|
||||
if selected_mode == "full_run":
|
||||
start_row = args.start_sheet_row or sheet_handler.get_start_row_index("Timestamp letzte Pruefung") + sheet_handler._header_rows + 1
|
||||
num_to_process = args.limit or (len(sheet_handler.get_all_data_with_headers()) - start_row + 1)
|
||||
data_processor.process_rows_sequentially(
|
||||
start_sheet_row=start_row, num_to_process=num_to_process,
|
||||
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
|
||||
)
|
||||
elif selected_mode == "reeval":
|
||||
data_processor.process_reevaluation_rows(
|
||||
row_limit=args.limit, clear_flag=True,
|
||||
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
|
||||
)
|
||||
elif selected_mode == "reclassify_branches":
|
||||
data_processor.reclassify_all_branches(start_sheet_row=args.start_sheet_row, limit=args.limit)
|
||||
elif selected_mode == "alignment":
|
||||
alignment_demo(sheet_handler)
|
||||
elif selected_mode == "train_technician_model":
|
||||
data_processor.train_technician_model()
|
||||
elif selected_mode == "predict_technicians":
|
||||
data_processor.process_predict_technicians(start_sheet_row=args.start_sheet_row, limit=args.limit)
|
||||
elif hasattr(data_processor, f"process_{selected_mode}"):
|
||||
method_to_call = getattr(data_processor, f"process_{selected_mode}")
|
||||
method_args = {}
|
||||
if "limit" in method_to_call.__code__.co_varnames: method_args["limit"] = args.limit
|
||||
if "start_sheet_row" in method_to_call.__code__.co_varnames: method_args["start_sheet_row"] = args.start_sheet_row
|
||||
if "end_sheet_row" in method_to_call.__code__.co_varnames: method_args["end_sheet_row"] = args.end_sheet_row
|
||||
if "min_umsatz" in method_to_call.__code__.co_varnames: method_args["min_umsatz"] = args.min_umsatz
|
||||
if "min_employees" in method_to_call.__code__.co_varnames: method_args["min_employees"] = args.min_employees
|
||||
method_to_call(**method_args)
|
||||
elif hasattr(data_processor, f"run_{selected_mode}"):
|
||||
method_to_call = getattr(data_processor, f"run_{selected_mode}")
|
||||
method_to_call(start_sheet_row=args.start_sheet_row, end_sheet_row=args.end_sheet_row, limit=args.limit)
|
||||
else:
|
||||
logger.error(f"Unbekannter Modus '{selected_mode}' im Dispatcher.")
|
||||
|
||||
duration = time.time() - start_time
|
||||
logger.info(f"Verarbeitung im Modus '{selected_mode}' abgeschlossen. Dauer: {duration:.2f} Sekunden.")
|
||||
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
logger.warning("Skript durch Benutzer unterbrochen.")
|
||||
print("\n! Skript wurde manuell beendet.")
|
||||
except Exception as e:
|
||||
logger.critical(f"FATAL: Unerwarteter Fehler im Hauptprozess: {e}", exc_info=True)
|
||||
print(f"\n! Ein kritischer Fehler ist aufgetreten: {e}")
|
||||
if 'log_file_path' in locals() and log_file_path:
|
||||
print(f"Bitte pruefen Sie die Logdatei fuer Details: {log_file_path}")
|
||||
finally:
|
||||
logger.info(f"===== Skript beendet =====")
|
||||
logging.shutdown()
|
||||
if 'selected_mode' in locals() and selected_mode != 'exit' and 'log_file_path' in locals() and log_file_path:
|
||||
print(f"\nVerarbeitung abgeschlossen. Logfile: {log_file_path}")
|
||||
|
||||
# ==============================================================================
|
||||
# 3. SKRIPT-AUSFÜHRUNG
|
||||
# ==============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,157 @@
|
||||
# build_knowledge_base.py
|
||||
|
||||
import os
|
||||
import yaml
|
||||
import logging
|
||||
import time
|
||||
import openai
|
||||
import argparse
|
||||
from config import Config
|
||||
|
||||
# --- Konfiguration ---
|
||||
OUTPUT_FILE = "marketing_wissen_final.yaml"
|
||||
MODEL_TO_USE = "gpt-4o"
|
||||
DOSSIER_FOLDER = "industries" # Der Ordner für die generierten Branchen-Dossiers
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
def call_openai_with_retry(prompt, is_extraction=False, max_retries=3, delay=5):
|
||||
"""Ruft die OpenAI API auf."""
|
||||
# ... (Diese Funktion bleibt unverändert, ich füge sie hier der Vollständigkeit halber ein) ...
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
logging.info(f"Sende Prompt an OpenAI (Länge: {len(prompt)} Zeichen)...")
|
||||
response_format = {"type": "json_object"} if is_extraction else {"type": "text"}
|
||||
response = openai.ChatCompletion.create(
|
||||
model=MODEL_TO_USE,
|
||||
response_format=response_format,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.3,
|
||||
max_tokens=2048
|
||||
)
|
||||
content = response.choices[0].message['content'].strip()
|
||||
return content
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler bei OpenAI-API-Aufruf: {e}")
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(delay)
|
||||
else:
|
||||
return None
|
||||
|
||||
def generate_research_prompt(branch_name, branch_info):
|
||||
"""Erstellt den Prompt, um ein Branchen-Dossier zu erstellen, basierend auf dem reichen Kontext."""
|
||||
|
||||
context_parts = [f"Branche: '{branch_name}'"]
|
||||
if branch_info.get("definition"):
|
||||
context_parts.append(f"Fokus / Abgrenzung: {branch_info['definition']}")
|
||||
if branch_info.get("beispiele"):
|
||||
context_parts.append(f"Beispielunternehmen: {branch_info['beispiele']}")
|
||||
context_str = "\n".join(context_parts)
|
||||
|
||||
return (
|
||||
f"Erstelle ein prägnantes Branchen-Dossier (ca. 300-400 Wörter) für die folgende, spezifische Branche:\n\n"
|
||||
f"--- BRanchen-Kontext ---\n{context_str}\n\n"
|
||||
"Struktur des Dossiers:\n"
|
||||
"1. **Geschäftsmodelle & Field Service:** Beschreibe die typischen Geschäftsmodelle und die Rolle des Außendienstes, basierend auf dem oben genannten Fokus.\n"
|
||||
"2. **Herausforderungen & Trends:** Nenne die wichtigsten Herausforderungen und Trends für den Service-Bereich in diesem spezifischen Segment.\n"
|
||||
"3. **Branchenspezifisches Wording:** Liste typische Fachbegriffe auf, die in diesem Kontext üblich sind."
|
||||
)
|
||||
|
||||
def generate_extraction_prompt(dossier_content):
|
||||
"""Erstellt den Prompt, um die strukturierten Daten aus dem Dossier zu extrahieren."""
|
||||
return (
|
||||
"Du bist ein Branchenanalyst mit dem Spezialgebiet Field Service Management. Deine Aufgabe ist es, aus einem Branchen-Dossier die Kernaussagen zu extrahieren.\n"
|
||||
"Gib das Ergebnis ausschließlich als sauberes JSON-Objekt mit den Schlüsseln 'summary', 'pain_points' (eine Liste von 5 operativen Schmerzpunkten des Außendienstes) und 'key_terms' (eine Liste von 5-7 Begriffen) aus.\n\n"
|
||||
"WICHTIGE REGELN FÜR 'pain_points':\n"
|
||||
"- Extrahiere 5 **operative Schmerzpunkte, die direkt den technischen Außendienst betreffen**.\n"
|
||||
"- Formuliere sie als konkrete Probleme, die ein Service-Leiter lösen muss (z.B. 'Sicherstellung der Anlagenverfügbarkeit', 'Lückenlose Dokumentation für Audits').\n"
|
||||
"- Vermeide allgemeine Management-Themen wie 'Komplexität der Geschäftsmodelle' oder reine HR-Themen wie 'Fachkräftemangel'.\n\n"
|
||||
"--- DOSSIER ---\n"
|
||||
f"{dossier_content}"
|
||||
)
|
||||
|
||||
def main(branches_to_process=None):
|
||||
"""Baut die komplette Wissensbasis auf, basierend auf den Definitionen in config.py."""
|
||||
logging.info("Starte den Aufbau der vollständigen Wissensbasis...")
|
||||
|
||||
Config.load_api_keys()
|
||||
openai.api_key = Config.API_KEYS.get('openai')
|
||||
if not openai.api_key:
|
||||
logging.critical("OpenAI API Key nicht gefunden.")
|
||||
return
|
||||
|
||||
# Die finale Wissensbasis wird von Grund auf neu erstellt
|
||||
knowledge_base = {
|
||||
'Positionen': {
|
||||
'Field Service Management': {'name_DE': 'Leiter Kundenservice / Field Service', 'pains_DE': ['Das Team ist zu klein, überlastet und gestresst, was zu hoher Fluktuation führen kann.', 'Zu viele Anrufe und ungeplante Einsätze mit zu wenigen verfügbaren Ressourcen.', 'Ineffiziente, undurchsichtige und komplexe Prozesse bei der Einsatzplanung.']},
|
||||
'IT': {'name_DE': 'IT-Leiter', 'pains_DE': ['Hoher Implementierungsaufwand und unklare Gesamtkosten (TCO) bei neuen Systemen.', 'Sicherheitsbedenken und die nahtlose Integration in die bestehende IT-Infrastruktur.', 'Mangelhafte Dokumentation oder unzureichende APIs neuer Softwarelösungen.']},
|
||||
'Management / GF / C-Level': {'name_DE': 'Geschäftsführer / C-Level', 'pains_DE': ['Die richtigen, zukunftssicheren Investitionsentscheidungen treffen, um wettbewerbsfähig zu bleiben.', 'Den Überblick über die operative Effizienz behalten, um Wachstum und Profitabilität zu steuern.', 'Im "War for Talents" gute Mitarbeiter finden und durch moderne Werkzeuge langfristig halten.']},
|
||||
'Procurement / Einkauf': {'name_DE': 'Einkaufsleiter', 'pains_DE': ['Unklare Amortisationszeit (ROI) und versteckte Kosten einer neuen Softwarelösung.', 'Sicherstellen, dass das Preis-Leistungs-Verhältnis das beste auf dem Markt ist.', 'Das Risiko einer Fehlinvestition minimieren und vertragliche Sicherheit gewährleisten.']},
|
||||
'Finanzen': {'name_DE': 'Finanzleiter / CFO', 'pains_DE': ['Schwierigkeit, die Service-Einsätze verursachungsgerecht und präzise abzurechnen.', 'Mangelnde Transparenz über die tatsächliche Profitabilität einzelner Service-Aufträge.', 'Hoher manueller Aufwand bei der Reisekostenabrechnung und Materialbuchung der Techniker.']}
|
||||
},
|
||||
'Branchen': {}
|
||||
}
|
||||
|
||||
all_branches_from_config = Config.BRANCH_GROUP_MAPPING
|
||||
|
||||
if branches_to_process:
|
||||
target_branches = {k: v for k, v in all_branches_from_config.items() if k in branches_to_process}
|
||||
if not target_branches:
|
||||
logging.error("Keine der angegebenen Branchen ist gültig. Bitte prüfen Sie die Schreibweise.")
|
||||
return
|
||||
logging.info(f"Verarbeite die {len(target_branches)} explizit angegebenen Branchen...")
|
||||
else:
|
||||
target_branches = all_branches_from_config
|
||||
logging.info(f"Es werden alle {len(target_branches)} Branchen aus der Config verarbeitet...")
|
||||
|
||||
os.makedirs(DOSSIER_FOLDER, exist_ok=True)
|
||||
|
||||
for branch_name, branch_info in target_branches.items():
|
||||
logging.info(f"\n--- Verarbeite Branche: {branch_name} ---")
|
||||
|
||||
research_prompt = generate_research_prompt(branch_name, branch_info)
|
||||
dossier = call_openai_with_retry(research_prompt)
|
||||
if not dossier: continue
|
||||
|
||||
try:
|
||||
sanitized_branch_name = branch_name.replace('/', '-').replace('\\', '-')
|
||||
dossier_filepath = os.path.join(DOSSIER_FOLDER, f"{sanitized_branch_name}.txt")
|
||||
with open(dossier_filepath, 'w', encoding='utf-8') as f: f.write(dossier)
|
||||
logging.info(f" -> Dossier erfolgreich in '{dossier_filepath}' gespeichert.")
|
||||
except Exception as e:
|
||||
logging.error(f" -> Fehler beim Speichern des Dossiers für {branch_name}: {e}")
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
extraction_prompt = generate_extraction_prompt(dossier)
|
||||
extracted_data_str = call_openai_with_retry(extraction_prompt, is_extraction=True)
|
||||
if not extracted_data_str: continue
|
||||
|
||||
try:
|
||||
if extracted_data_str.startswith("```"):
|
||||
extracted_data_str = extracted_data_str.split('\n', 1)[1].rsplit('```', 1)[0]
|
||||
|
||||
extracted_data = yaml.safe_load(extracted_data_str)
|
||||
# Referenzen direkt aus der Config übernehmen
|
||||
extracted_data['references_DE'] = branch_info.get('beispiele', '[KEINE REFERENZEN IN CONFIG GEFUNDEN]')
|
||||
extracted_data['references_GB'] = '[HIER ENGLISCHE REFERENZKUNDEN EINTRAGEN]'
|
||||
knowledge_base['Branchen'][branch_name] = extracted_data
|
||||
logging.info(f" -> {branch_name} erfolgreich zur Wissensbasis hinzugefügt.")
|
||||
except Exception as e:
|
||||
logging.error(f" Fehler beim Parsen der extrahierten Daten für {branch_name}: {e}")
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
try:
|
||||
with open(OUTPUT_FILE, 'w', encoding='utf-8') as f:
|
||||
yaml.dump(knowledge_base, f, allow_unicode=True, sort_keys=False, width=120)
|
||||
logging.info(f"\nErfolgreich! Die finale Wissensbasis wurde in '{OUTPUT_FILE}' gespeichert.")
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Speichern der finalen YAML-Datei: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Baut die komplette Marketing-Wissensbasis auf.")
|
||||
parser.add_argument("--branches", nargs='+', type=str, help="Eine oder mehrere spezifische Branchen, die verarbeitet werden sollen.")
|
||||
args = parser.parse_args()
|
||||
|
||||
main(branches_to_process=args.branches)
|
||||
@@ -0,0 +1,673 @@
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import logging
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
from collections import Counter
|
||||
from thefuzz import fuzz
|
||||
from helpers import normalize_company_name, simple_normalize_url, serp_website_lookup
|
||||
from config import Config
|
||||
from google_sheet_handler import GoogleSheetHandler
|
||||
|
||||
# duplicate_checker.py v2.15
|
||||
# Quality-first ++: Domain-Gate, Location-Penalties, Smart Blocking (IDF-light),
|
||||
# Serp-Trust, Weak-Threshold, City-Bias-Guard, Prefilter tightened, Metrics
|
||||
# Build timestamp is injected into logfile name.
|
||||
|
||||
# --- Konfiguration ---
|
||||
CRM_SHEET_NAME = "CRM_Accounts"
|
||||
MATCHING_SHEET_NAME = "Matching_Accounts"
|
||||
SCORE_THRESHOLD = 80 # Standard-Schwelle
|
||||
SCORE_THRESHOLD_WEAK= 95 # Schwelle, wenn weder Domain noch (City&Country) matchen
|
||||
MIN_NAME_FOR_DOMAIN = 70 # Domain-Score nur, wenn Name >= 70 ODER Ort+Land matchen
|
||||
CITY_MISMATCH_PENALTY = 30
|
||||
COUNTRY_MISMATCH_PENALTY = 40
|
||||
PREFILTER_MIN_PARTIAL = 70 # (vorher 60)
|
||||
PREFILTER_LIMIT = 30 # (vorher 50)
|
||||
LOG_DIR = "Log"
|
||||
now = datetime.now().strftime('%Y-%m-%d_%H-%M')
|
||||
LOG_FILE = f"{now}_duplicate_check_v2.15.txt"
|
||||
|
||||
# --- Logging Setup ---
|
||||
if not os.path.exists(LOG_DIR):
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
log_path = os.path.join(LOG_DIR, LOG_FILE)
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.DEBUG)
|
||||
for h in list(root.handlers):
|
||||
root.removeHandler(h)
|
||||
formatter = logging.Formatter("%(asctime)s - %(levelname)-8s - %(message)s")
|
||||
ch = logging.StreamHandler(sys.stdout)
|
||||
ch.setLevel(logging.INFO)
|
||||
ch.setFormatter(formatter)
|
||||
root.addHandler(ch)
|
||||
fh = logging.FileHandler(log_path, mode='a', encoding='utf-8')
|
||||
fh.setLevel(logging.DEBUG)
|
||||
fh.setFormatter(formatter)
|
||||
root.addHandler(fh)
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Logging to console and file: {log_path}")
|
||||
logger.info(f"Starting duplicate_checker.py v2.15 | Build: {now}")
|
||||
|
||||
# --- SerpAPI Key laden ---
|
||||
try:
|
||||
Config.load_api_keys()
|
||||
serp_key = Config.API_KEYS.get('serpapi')
|
||||
if not serp_key:
|
||||
logger.warning("SerpAPI Key nicht gefunden; Serp-Fallback deaktiviert.")
|
||||
except Exception as e:
|
||||
logger.warning(f"Fehler beim Laden API-Keys: {e}")
|
||||
serp_key = None
|
||||
|
||||
# --- Stop-/City-Tokens ---
|
||||
STOP_TOKENS_BASE = {
|
||||
'gmbh','mbh','ag','kg','ug','ohg','se','co','kgaa','inc','llc','ltd','sarl',
|
||||
'holding','gruppe','group','international','solutions','solution','service','services',
|
||||
'deutschland','austria','germany','technik','technology','technologies','systems','systeme',
|
||||
'logistik','logistics','industries','industrie','management','consulting','vertrieb','handel',
|
||||
'international','company','gesellschaft','mbh&co','mbhco','werke','werk','renkhoff','sonnenschutztechnik'
|
||||
}
|
||||
CITY_TOKENS = set() # dynamisch befüllt nach Datennormalisierung
|
||||
|
||||
# --- Utilities ---
|
||||
def _tokenize(s: str):
|
||||
if not s:
|
||||
return []
|
||||
return re.split(r"[^a-z0-9]+", str(s).lower())
|
||||
|
||||
def split_tokens(name: str):
|
||||
"""Tokens für Indexing/Scoring (Basis-Stop + dynamische City-Tokens)."""
|
||||
if not name:
|
||||
return []
|
||||
tokens = [t for t in _tokenize(name) if len(t) >= 3]
|
||||
stop_union = STOP_TOKENS_BASE | CITY_TOKENS
|
||||
return [t for t in tokens if t not in stop_union]
|
||||
|
||||
def clean_name_for_scoring(norm_name: str):
|
||||
"""Entfernt Stop- & City-Tokens. Leerer Output => kein sinnvoller Namevergleich."""
|
||||
toks = split_tokens(norm_name)
|
||||
return " ".join(toks), set(toks)
|
||||
|
||||
def assess_serp_trust(company_name: str, url: str) -> str:
|
||||
"""Vertrauen 'hoch/mittel/niedrig' anhand Token-Vorkommen in Domain."""
|
||||
if not url:
|
||||
return 'n/a'
|
||||
host = simple_normalize_url(url) or ''
|
||||
host = host.replace('www.', '')
|
||||
name_toks = [t for t in split_tokens(normalize_company_name(company_name)) if len(t) >= 3]
|
||||
if any(t in host for t in name_toks if len(t) >= 4):
|
||||
return 'hoch'
|
||||
if any(t in host for t in name_toks if len(t) == 3):
|
||||
return 'mittel'
|
||||
return 'niedrig'
|
||||
|
||||
# --- Similarity ---
|
||||
def calculate_similarity(mrec: dict, crec: dict, token_freq: Counter):
|
||||
n1 = mrec.get('normalized_name','')
|
||||
n2 = crec.get('normalized_name','')
|
||||
|
||||
# NEU: Direkte Prämierung für exakten Namens-Match
|
||||
if n1 and n1 == n2:
|
||||
return 300, {'name': 100, 'exact_match': 1}
|
||||
|
||||
# Domain (mit Gate)
|
||||
dom1 = mrec.get('normalized_domain','')
|
||||
dom2 = crec.get('normalized_domain','')
|
||||
m_domain_use = mrec.get('domain_use_flag', 0)
|
||||
domain_flag_raw = 1 if (m_domain_use == 1 and dom1 and dom1 == dom2) else 0
|
||||
|
||||
# Location flags
|
||||
city_match = 1 if (mrec.get('CRM Ort') and crec.get('CRM Ort') and mrec.get('CRM Ort') == crec.get('CRM Ort')) else 0
|
||||
country_match = 1 if (mrec.get('CRM Land') and crec.get('CRM Land') and mrec.get('CRM Land') == crec.get('CRM Land')) else 0
|
||||
|
||||
# Name (nur sinnvolle Tokens)
|
||||
n1 = mrec.get('normalized_name','')
|
||||
n2 = crec.get('normalized_name','')
|
||||
clean1, toks1 = clean_name_for_scoring(n1)
|
||||
clean2, toks2 = clean_name_for_scoring(n2)
|
||||
|
||||
# Overlaps
|
||||
overlap_clean = toks1 & toks2
|
||||
# city-only overlap check (wenn nach Clean nichts übrig, aber Roh-Overlap evtl. Städte; wir cappen Score)
|
||||
raw_overlap = set(_tokenize(n1)) & set(_tokenize(n2))
|
||||
city_only_overlap = (not overlap_clean) and any(t in CITY_TOKENS for t in raw_overlap)
|
||||
|
||||
# Name-Score
|
||||
if clean1 and clean2:
|
||||
ts = fuzz.token_set_ratio(clean1, clean2)
|
||||
pr = fuzz.partial_ratio(clean1, clean2)
|
||||
ss = fuzz.token_sort_ratio(clean1, clean2)
|
||||
name_score = max(ts, pr, ss)
|
||||
else:
|
||||
name_score = 0
|
||||
|
||||
if city_only_overlap and name_score > 70:
|
||||
name_score = 70 # cap
|
||||
|
||||
# Rare-token-overlap (IDF-light): benutze seltensten Token aus mrec
|
||||
rtoks_sorted = sorted(list(toks1), key=lambda t: (token_freq.get(t, 10**9), -len(t)))
|
||||
rare_token = rtoks_sorted[0] if rtoks_sorted else None
|
||||
rare_overlap = 1 if (rare_token and rare_token in toks2) else 0
|
||||
|
||||
# Domain Gate
|
||||
domain_gate_ok = (name_score >= MIN_NAME_FOR_DOMAIN) or (city_match and country_match)
|
||||
domain_used = 1 if (domain_flag_raw and domain_gate_ok) else 0
|
||||
|
||||
# Basisscore
|
||||
total = domain_used*100 + name_score*1.0 + (1 if (city_match and country_match) else 0)*20
|
||||
|
||||
# Penalties
|
||||
penalties = 0
|
||||
if mrec.get('CRM Land') and crec.get('CRM Land') and not country_match:
|
||||
penalties += COUNTRY_MISMATCH_PENALTY
|
||||
if mrec.get('CRM Ort') and crec.get('CRM Ort') and not city_match:
|
||||
penalties += CITY_MISMATCH_PENALTY
|
||||
total -= penalties
|
||||
|
||||
# Bonus für starke Name-only Fälle
|
||||
name_bonus = 1 if (domain_used == 0 and not (city_match and country_match) and name_score >= 85 and rare_overlap==1) else 0
|
||||
if name_bonus:
|
||||
total += 20
|
||||
|
||||
comp = {
|
||||
'domain_raw': domain_flag_raw,
|
||||
'domain_used': domain_used,
|
||||
'domain_gate_ok': int(domain_gate_ok),
|
||||
'name': round(name_score,1),
|
||||
'city_match': city_match,
|
||||
'country_match': country_match,
|
||||
'penalties': penalties,
|
||||
'name_bonus': name_bonus,
|
||||
'rare_overlap': rare_overlap,
|
||||
'city_only_overlap': int(city_only_overlap),
|
||||
'is_parent_child': 0 # Standardwert
|
||||
}
|
||||
|
||||
# Prüfen auf Parent-Child-Beziehung
|
||||
n1_norm = mrec.get('normalized_name','')
|
||||
n2_norm = crec.get('normalized_name','')
|
||||
p1_norm = mrec.get('normalized_parent_name','')
|
||||
p2_norm = crec.get('normalized_parent_name','')
|
||||
|
||||
if (n1_norm and p2_norm and n1_norm == p2_norm) or \
|
||||
(n2_norm and p1_norm and n2_norm == p1_norm):
|
||||
comp['is_parent_child'] = 1
|
||||
# Wenn es eine Parent-Child-Beziehung ist, geben wir einen sehr hohen Score zurück,
|
||||
# aber mit dem Flag, damit es später ignoriert werden kann.
|
||||
return 500, comp # Sehr hoher Score, um es leicht erkennbar zu machen
|
||||
|
||||
return round(total), comp
|
||||
|
||||
# --- Indexe ---
|
||||
def build_indexes(crm_df: pd.DataFrame):
|
||||
records = list(crm_df.to_dict('records'))
|
||||
# Domain-Index
|
||||
domain_index = {}
|
||||
for r in records:
|
||||
d = r.get('normalized_domain')
|
||||
if d:
|
||||
domain_index.setdefault(d, []).append(r)
|
||||
# Token-Frequenzen (auf gereinigten Tokens)
|
||||
token_freq = Counter()
|
||||
for r in records:
|
||||
_, toks = clean_name_for_scoring(r.get('normalized_name',''))
|
||||
for t in set(toks):
|
||||
token_freq[t] += 1
|
||||
# Token-Index
|
||||
token_index = {}
|
||||
for r in records:
|
||||
_, toks = clean_name_for_scoring(r.get('normalized_name',''))
|
||||
for t in set(toks):
|
||||
token_index.setdefault(t, []).append(r)
|
||||
return records, domain_index, token_freq, token_index
|
||||
|
||||
|
||||
def choose_rarest_token(norm_name: str, token_freq: Counter):
|
||||
_, toks = clean_name_for_scoring(norm_name)
|
||||
if not toks:
|
||||
return None
|
||||
lst = sorted(list(toks), key=lambda x: (token_freq.get(x, 10**9), -len(x)))
|
||||
return lst[0] if lst else None
|
||||
|
||||
def build_city_tokens(df1: pd.DataFrame, df2: pd.DataFrame = None):
|
||||
"""Baut dynamisch ein Set von City-Tokens aus den Orts-Spalten."""
|
||||
dfs = [df1]
|
||||
if df2 is not None:
|
||||
dfs.append(df2)
|
||||
cities = set()
|
||||
for s in pd.concat([df['CRM Ort'] for df in dfs], ignore_index=True).dropna().unique():
|
||||
for t in _tokenize(s):
|
||||
if len(t) >= 3:
|
||||
cities.add(t)
|
||||
return cities
|
||||
|
||||
def run_internal_deduplication():
|
||||
"""Führt die interne Deduplizierung auf dem CRM_Accounts-Sheet durch."""
|
||||
logger.info("Modus 'Interne Deduplizierung' gewählt.")
|
||||
try:
|
||||
sheet = GoogleSheetHandler()
|
||||
logger.info("GoogleSheetHandler initialisiert")
|
||||
except Exception as e:
|
||||
logger.critical(f"Init GoogleSheetHandler fehlgeschlagen: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Daten laden
|
||||
crm_df = sheet.get_sheet_as_dataframe(CRM_SHEET_NAME)
|
||||
if crm_df is None or crm_df.empty:
|
||||
logger.critical("CRM-Sheet ist leer. Abbruch.")
|
||||
return
|
||||
|
||||
# Eindeutige ID hinzufügen, um Zeilen zu identifizieren
|
||||
crm_df['unique_id'] = crm_df.index
|
||||
logger.info(f"{len(crm_df)} CRM-Datensätze geladen.")
|
||||
|
||||
# Normalisierung
|
||||
crm_df['normalized_name'] = crm_df['CRM Name'].astype(str).apply(normalize_company_name)
|
||||
crm_df['normalized_domain'] = crm_df['CRM Website'].astype(str).apply(simple_normalize_url)
|
||||
crm_df['CRM Ort'] = crm_df['CRM Ort'].astype(str).str.lower().str.strip()
|
||||
crm_df['CRM Land'] = crm_df['CRM Land'].astype(str).str.lower().str.strip()
|
||||
crm_df['Parent Account'] = crm_df.get('Parent Account', pd.Series(index=crm_df.index, dtype=object)).astype(str).fillna('').str.strip()
|
||||
crm_df['normalized_parent_name'] = crm_df['Parent Account'].apply(normalize_company_name)
|
||||
crm_df['domain_use_flag'] = 1 # CRM-Domain gilt als vertrauenswürdig
|
||||
|
||||
# City-Tokens und Blocking-Indizes
|
||||
global CITY_TOKENS
|
||||
CITY_TOKENS = build_city_tokens(crm_df)
|
||||
logger.info(f"City tokens gesammelt: {len(CITY_TOKENS)}")
|
||||
|
||||
crm_records, domain_index, token_freq, token_index = build_indexes(crm_df)
|
||||
logger.info(f"Blocking: Domains={len(domain_index)} | TokenKeys={len(token_index)}")
|
||||
|
||||
# --- Selbst-Vergleich ---
|
||||
found_pairs = []
|
||||
processed_pairs = set() # Verhindert (A,B) und (B,A)
|
||||
total = len(crm_records)
|
||||
logger.info("Starte internen Abgleich...")
|
||||
|
||||
for i, record1 in enumerate(crm_records):
|
||||
if i % 100 == 0:
|
||||
logger.info(f"Verarbeite Datensatz {i}/{total}...")
|
||||
|
||||
candidate_records = {}
|
||||
# Kandidaten via Domain finden
|
||||
domain = record1.get('normalized_domain')
|
||||
if domain:
|
||||
for record2 in domain_index.get(domain, []):
|
||||
candidate_records[record2['unique_id']] = record2
|
||||
|
||||
# Kandidaten via seltenstem Token finden
|
||||
rtok = choose_rarest_token(record1.get('normalized_name',''), token_freq)
|
||||
if rtok:
|
||||
for record2 in token_index.get(rtok, []):
|
||||
candidate_records[record2['unique_id']] = record2
|
||||
|
||||
if not candidate_records:
|
||||
continue
|
||||
|
||||
for record2 in candidate_records.values():
|
||||
# Vergleiche nicht mit sich selbst
|
||||
if record1['unique_id'] == record2['unique_id']:
|
||||
continue
|
||||
|
||||
# Verhindere doppelte Vergleiche (A,B) vs (B,A)
|
||||
pair_key = tuple(sorted((record1['unique_id'], record2['unique_id'])))
|
||||
if pair_key in processed_pairs:
|
||||
continue
|
||||
processed_pairs.add(pair_key)
|
||||
|
||||
score, comp = calculate_similarity(record1, record2, token_freq)
|
||||
|
||||
# Wenn es eine bekannte Parent-Child-Beziehung ist, ignorieren wir sie.
|
||||
if comp.get('is_parent_child') == 1:
|
||||
logger.debug(f" -> Ignoriere bekannte Parent-Child-Beziehung: '{record1['CRM Name']}' <-> '{record2['CRM Name']}'")
|
||||
continue
|
||||
|
||||
# Akzeptanzlogik (hier könnte man den Threshold anpassen)
|
||||
if score >= SCORE_THRESHOLD:
|
||||
duplicate_hint = ''
|
||||
# Prüfen, ob beide Accounts keinen Parent Account haben
|
||||
if not record1.get('Parent Account') and not record2.get('Parent Account'):
|
||||
duplicate_hint = 'Potenziell fehlende Parent-Account-Beziehung'
|
||||
|
||||
pair_info = {
|
||||
'id1': record1['unique_id'], 'name1': record1['CRM Name'],
|
||||
'id2': record2['unique_id'], 'name2': record2['CRM Name'],
|
||||
'score': score,
|
||||
'details': str(comp),
|
||||
'hint': duplicate_hint
|
||||
}
|
||||
found_pairs.append(pair_info)
|
||||
logger.info(f" -> Potenzielles Duplikat gefunden: '{record1['CRM Name']}' <-> '{record2['CRM Name']}' (Score: {score}, Hint: {duplicate_hint})")
|
||||
|
||||
logger.info("\n===== Interner Abgleich abgeschlossen ====")
|
||||
logger.info(f"Insgesamt {len(found_pairs)} potenzielle Duplikatspaare gefunden.")
|
||||
|
||||
if not found_pairs:
|
||||
logger.info("Keine weiteren Schritte nötig.")
|
||||
return
|
||||
|
||||
groups = group_duplicate_pairs(found_pairs)
|
||||
logger.info(f"{len(groups)} eindeutige Duplikatsgruppen gebildet.")
|
||||
|
||||
if not groups:
|
||||
logger.info("Keine Duplikate gefunden, die geschrieben werden müssen.")
|
||||
return
|
||||
|
||||
# Schritt 4: IDs zuweisen und in Tabelle schreiben
|
||||
crm_df['Duplicate_ID'] = ''
|
||||
crm_df['Duplicate_Hint'] = '' # Neue Spalte für Hinweise
|
||||
dup_counter = 1
|
||||
for group in groups:
|
||||
dup_id = f"Dup_{dup_counter:04d}"
|
||||
dup_counter += 1
|
||||
|
||||
# IDs der Gruppe im DataFrame aktualisieren
|
||||
crm_df.loc[crm_df['unique_id'].isin(group), 'Duplicate_ID'] = dup_id
|
||||
|
||||
# Hinweise für die Gruppe sammeln und setzen
|
||||
group_hints = [p['hint'] for p in found_pairs if p['id1'] in group or p['id2'] in group and p['hint']]
|
||||
if group_hints:
|
||||
# Nur den ersten eindeutigen Hinweis pro Gruppe setzen, oder eine Zusammenfassung
|
||||
unique_hints = list(set(group_hints))
|
||||
crm_df.loc[crm_df['unique_id'].isin(group), 'Duplicate_Hint'] = "; ".join(unique_hints)
|
||||
|
||||
# Namen der Gruppenmitglieder für Log-Ausgabe sammeln
|
||||
member_names = crm_df[crm_df['unique_id'].isin(group)]['CRM Name'].tolist()
|
||||
logger.info(f"Gruppe {dup_id}: {member_names}")
|
||||
|
||||
# Bereinigen der Hilfsspalten vor dem Schreiben
|
||||
crm_df.drop(columns=['unique_id', 'normalized_name', 'normalized_domain', 'domain_use_flag', 'normalized_parent_name'], inplace=True)
|
||||
|
||||
# Ergebnisse zurückschreiben
|
||||
logger.info("Schreibe Ergebnisse mit Duplikats-IDs ins Sheet...")
|
||||
backup_path = os.path.join(LOG_DIR, f"{now}_backup_internal_{CRM_SHEET_NAME}.csv")
|
||||
try:
|
||||
crm_df.to_csv(backup_path, index=False, encoding='utf-8')
|
||||
logger.info(f"Lokales Backup geschrieben: {backup_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Backup fehlgeschlagen: {e}")
|
||||
|
||||
data = [crm_df.columns.tolist()] + crm_df.fillna('').values.tolist()
|
||||
ok = sheet.clear_and_write_data(CRM_SHEET_NAME, data)
|
||||
if ok:
|
||||
logger.info("Ergebnisse erfolgreich ins Google Sheet geschrieben.")
|
||||
else:
|
||||
logger.error("Fehler beim Schreiben der Ergebnisse ins Google Sheet.")
|
||||
|
||||
|
||||
def group_duplicate_pairs(pairs: list) -> list:
|
||||
"""Fasst eine Liste von Duplikatspaaren zu Gruppen zusammen."""
|
||||
groups = []
|
||||
for pair in pairs:
|
||||
id1, id2 = pair['id1'], pair['id2']
|
||||
group1_found = None
|
||||
group2_found = None
|
||||
for group in groups:
|
||||
if id1 in group:
|
||||
group1_found = group
|
||||
if id2 in group:
|
||||
group2_found = group
|
||||
|
||||
if group1_found and group2_found:
|
||||
if group1_found is not group2_found: # Zwei unterschiedliche Gruppen verschmelzen
|
||||
group1_found.update(group2_found)
|
||||
groups.remove(group2_found)
|
||||
elif group1_found: # Zu Gruppe 1 hinzufügen
|
||||
group1_found.add(id2)
|
||||
elif group2_found: # Zu Gruppe 2 hinzufügen
|
||||
group2_found.add(id1)
|
||||
else: # Neue Gruppe erstellen
|
||||
groups.append({id1, id2})
|
||||
|
||||
return [set(g) for g in groups]
|
||||
|
||||
|
||||
def run_external_comparison():
|
||||
"""Führt den Vergleich zwischen CRM_Accounts und Matching_Accounts durch."""
|
||||
logger.info("Modus 'Externer Vergleich' gewählt.")
|
||||
try:
|
||||
sheet = GoogleSheetHandler()
|
||||
logger.info("GoogleSheetHandler initialisiert")
|
||||
except Exception as e:
|
||||
logger.critical(f"Init GoogleSheetHandler fehlgeschlagen: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Daten laden
|
||||
crm_df = sheet.get_sheet_as_dataframe(CRM_SHEET_NAME)
|
||||
match_df = sheet.get_sheet_as_dataframe(MATCHING_SHEET_NAME)
|
||||
logger.info(f"{0 if crm_df is None else len(crm_df)} CRM-Datensätze | {0 if match_df is None else len(match_df)} Matching-Datensätze")
|
||||
if crm_df is None or crm_df.empty or match_df is None or match_df.empty:
|
||||
logger.critical("Leere Daten in einem der Sheets. Abbruch.")
|
||||
return
|
||||
|
||||
# SerpAPI nur für Matching (B und E leer)
|
||||
if serp_key:
|
||||
if 'Gefundene Website' not in match_df.columns:
|
||||
match_df['Gefundene Website'] = ''
|
||||
b_empty = match_df['CRM Website'].fillna('').astype(str).str.strip().str.lower().isin(['','k.a.','k.a','n/a','na'])
|
||||
e_empty = match_df['Gefundene Website'].fillna('').astype(str).str.strip().str.lower().isin(['','k.a.','k.a','n/a','na'])
|
||||
empty_mask = b_empty & e_empty
|
||||
empty_count = int(empty_mask.sum())
|
||||
if empty_count > 0:
|
||||
logger.info(f"Serp-Fallback für Matching: {empty_count} Firmen ohne URL in B/E")
|
||||
found_cnt = 0
|
||||
trust_stats = Counter()
|
||||
for idx, row in match_df[empty_mask].iterrows():
|
||||
company = row['CRM Name']
|
||||
try:
|
||||
url = serp_website_lookup(company)
|
||||
if url and 'k.A.' not in url:
|
||||
if not str(url).startswith(('http://','https://')):
|
||||
url = 'https://' + str(url).lstrip()
|
||||
trust = assess_serp_trust(company, url)
|
||||
match_df.at[idx, 'Gefundene Website'] = url
|
||||
match_df.at[idx, 'Serp Vertrauen'] = trust
|
||||
trust_stats[trust] += 1
|
||||
logger.info(f" ✓ URL gefunden: '{company}' -> {url} (Vertrauen: {trust})")
|
||||
found_cnt += 1
|
||||
else:
|
||||
logger.debug(f" ✗ Keine eindeutige URL: '{company}' -> {url}")
|
||||
except Exception as e:
|
||||
logger.warning(f" ! Serp-Fehler für '{company}': {e}")
|
||||
logger.info(f"Serp-Fallback beendet: {found_cnt}/{empty_count} URLs ergänzt | Trust: {dict(trust_stats)}")
|
||||
else:
|
||||
logger.info("Serp-Fallback übersprungen: B oder E bereits befüllt (keine fehlenden Matching-URLs)")
|
||||
|
||||
# Normalisierung CRM
|
||||
crm_df['normalized_name'] = crm_df['CRM Name'].astype(str).apply(normalize_company_name)
|
||||
crm_df['normalized_domain'] = crm_df['CRM Website'].astype(str).apply(simple_normalize_url)
|
||||
crm_df['CRM Ort'] = crm_df['CRM Ort'].astype(str).str.lower().str.strip()
|
||||
crm_df['CRM Land'] = crm_df['CRM Land'].astype(str).str.lower().str.strip()
|
||||
crm_df['Parent Account'] = crm_df.get('Parent Account', pd.Series(index=crm_df.index, dtype=object)).astype(str).fillna('').str.strip()
|
||||
crm_df['normalized_parent_name'] = crm_df['Parent Account'].apply(normalize_company_name)
|
||||
crm_df['block_key'] = crm_df['normalized_name'].apply(lambda x: x.split()[0] if x else None)
|
||||
crm_df['domain_use_flag'] = 1 # CRM-Domain gilt als vertrauenswürdig
|
||||
|
||||
# Normalisierung Matching
|
||||
match_df['Gefundene Website'] = match_df.get('Gefundene Website', pd.Series(index=match_df.index, dtype=object))
|
||||
match_df['Serp Vertrauen'] = match_df.get('Serp Vertrauen', pd.Series(index=match_df.index, dtype=object))
|
||||
match_df['Effektive Website'] = match_df['CRM Website'].fillna('').astype(str).str.strip()
|
||||
mask_eff = match_df['Effektive Website'] == ''
|
||||
match_df.loc[mask_eff, 'Effektive Website'] = match_df['Gefundene Website'].fillna('').astype(str).str.strip()
|
||||
|
||||
match_df['normalized_name'] = match_df['CRM Name'].astype(str).apply(normalize_company_name)
|
||||
match_df['normalized_domain'] = match_df['Effektive Website'].astype(str).apply(simple_normalize_url)
|
||||
match_df['CRM Ort'] = match_df['CRM Ort'].astype(str).str.lower().str.strip()
|
||||
match_df['CRM Land'] = match_df['CRM Land'].astype(str).str.lower().str.strip()
|
||||
match_df['block_key'] = match_df['normalized_name'].apply(lambda x: x.split()[0] if x else None)
|
||||
|
||||
# Domain-Vertrauen/Use-Flag
|
||||
def _domain_use(row):
|
||||
if str(row.get('CRM Website','')).strip():
|
||||
return 1
|
||||
trust = str(row.get('Serp Vertrauen','')).lower()
|
||||
return 1 if trust == 'hoch' else 0
|
||||
match_df['domain_use_flag'] = match_df.apply(_domain_use, axis=1)
|
||||
|
||||
# City-Tokens dynamisch bauen (nach Normalisierung von Ort)
|
||||
global CITY_TOKENS
|
||||
CITY_TOKENS = build_city_tokens(crm_df, match_df)
|
||||
logger.info(f"City tokens gesammelt: {len(CITY_TOKENS)}")
|
||||
|
||||
# Blocking-Indizes (nachdem CITY_TOKENS gesetzt wurde)
|
||||
crm_records, domain_index, token_freq, token_index = build_indexes(crm_df)
|
||||
logger.info(f"Blocking: Domains={len(domain_index)} | TokenKeys={len(token_index)}")
|
||||
|
||||
# Matching
|
||||
results = []
|
||||
metrics = Counter()
|
||||
total = len(match_df)
|
||||
logger.info("Starte Matching-Prozess…")
|
||||
processed = 0
|
||||
|
||||
for idx, mrow in match_df.to_dict('index').items():
|
||||
processed += 1
|
||||
name_disp = mrow.get('CRM Name','')
|
||||
|
||||
# --- NEUE KANDIDATEN-SAMMELLOGIK ---
|
||||
candidate_records = {} # Dict, um Duplikate zu vermeiden und Records zu speichern
|
||||
used_blocks = []
|
||||
|
||||
# 1. Priorität: Exakter Namens-Match
|
||||
mrec_norm_name = mrow.get('normalized_name')
|
||||
if mrec_norm_name:
|
||||
exact_matches = crm_df[crm_df['normalized_name'] == mrec_norm_name]
|
||||
if not exact_matches.empty:
|
||||
for _, record in exact_matches.to_dict('index').items():
|
||||
candidate_records[record['CRM Name']] = record
|
||||
used_blocks.append('exact_name')
|
||||
|
||||
# 2. Domain-Match
|
||||
if mrow.get('normalized_domain') and mrow.get('domain_use_flag') == 1:
|
||||
domain_cands = domain_index.get(mrow['normalized_domain'], [])
|
||||
if domain_cands:
|
||||
for record in domain_cands:
|
||||
candidate_records[record['CRM Name']] = record
|
||||
used_blocks.append('domain')
|
||||
|
||||
# 3. Rarest-Token-Match
|
||||
rtok = choose_rarest_token(mrow.get('normalized_name',''), token_freq)
|
||||
if rtok:
|
||||
token_cands = token_index.get(rtok, [])
|
||||
if token_cands:
|
||||
for record in token_cands:
|
||||
candidate_records[record['CRM Name']] = record
|
||||
used_blocks.append('token')
|
||||
|
||||
# 4. Prefilter als Fallback, wenn wenige Kandidaten gefunden wurden
|
||||
if len(candidate_records) < PREFILTER_LIMIT:
|
||||
pf = []
|
||||
n1 = mrow.get('normalized_name','')
|
||||
rtok = choose_rarest_token(n1, token_freq)
|
||||
clean1, toks1 = clean_name_for_scoring(n1)
|
||||
if clean1:
|
||||
for r in crm_records:
|
||||
if r['CRM Name'] in candidate_records: continue # Nicht erneut prüfen
|
||||
n2 = r.get('normalized_name','')
|
||||
clean2, toks2 = clean_name_for_scoring(n2)
|
||||
if not clean2 or (rtok and rtok not in toks2):
|
||||
continue
|
||||
pr = fuzz.partial_ratio(clean1, clean2)
|
||||
if pr >= PREFILTER_MIN_PARTIAL:
|
||||
pf.append((pr, r))
|
||||
pf.sort(key=lambda x: x[0], reverse=True)
|
||||
for _, record in pf[:PREFILTER_LIMIT]:
|
||||
candidate_records[record['CRM Name']] = record
|
||||
if pf: used_blocks.append('prefilter')
|
||||
|
||||
candidates = list(candidate_records.values())
|
||||
logger.info(f"Prüfe {processed}/{total}: '{name_disp}' -> {len(candidates)} Kandidaten (Blocks={','.join(used_blocks)})")
|
||||
|
||||
if not candidates:
|
||||
results.append({'Match':'', 'Score':0, 'Match_Grund':'keine Kandidaten'})
|
||||
continue
|
||||
|
||||
scored = []
|
||||
for cr in candidates:
|
||||
score, comp = calculate_similarity(mrow, cr, token_freq)
|
||||
scored.append((cr.get('CRM Name',''), score, comp))
|
||||
scored.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
# Log Top5
|
||||
for cand_name, sc, comp in scored[:5]:
|
||||
logger.debug(f" Kandidat: {cand_name} | Score={sc} | Comp={comp}")
|
||||
|
||||
best_name, best_score, best_comp = scored[0]
|
||||
|
||||
# Akzeptanzlogik (Weak-Threshold + Guard)
|
||||
weak = (best_comp.get('domain_used') == 0 and not (best_comp.get('city_match') and best_comp.get('country_match')))
|
||||
applied_threshold = SCORE_THRESHOLD_WEAK if weak else SCORE_THRESHOLD
|
||||
weak_guard_fail = (weak and best_comp.get('rare_overlap') == 0)
|
||||
|
||||
if not weak_guard_fail and best_score >= applied_threshold:
|
||||
results.append({'Match': best_name, 'Score': best_score, 'Match_Grund': str(best_comp)})
|
||||
metrics['matches_total'] += 1
|
||||
if best_comp.get('domain_used') == 1:
|
||||
metrics['matches_domain'] += 1
|
||||
if best_comp.get('city_match') and best_comp.get('country_match'):
|
||||
metrics['matches_with_loc'] += 1
|
||||
if best_comp.get('domain_used') == 0 and best_comp.get('name') >= 85 and not (best_comp.get('city_match') and best_comp.get('country_match')):
|
||||
metrics['matches_name_only'] += 1
|
||||
logger.info(f" --> Match: '{best_name}' ({best_score}) {best_comp} | TH={applied_threshold}{' weak' if weak else ''}")
|
||||
else:
|
||||
reason = 'weak_guard_no_rare' if weak_guard_fail else 'below_threshold'
|
||||
results.append({'Match':'', 'Score': best_score, 'Match_Grund': f"{best_comp} | {reason} TH={applied_threshold}"})
|
||||
logger.info(f" --> Kein Match (Score={best_score}) {best_comp} | {reason} TH={applied_threshold}")
|
||||
|
||||
# Ergebnisse zurückschreiben (SAFE)
|
||||
logger.info("Schreibe Ergebnisse ins Sheet (SAFE in-place, keine Spaltenverluste)…")
|
||||
res_df = pd.DataFrame(results, index=match_df.index)
|
||||
write_df = match_df.copy()
|
||||
write_df['Match'] = res_df['Match']
|
||||
write_df['Score'] = res_df['Score']
|
||||
write_df['Match_Grund'] = res_df['Match_Grund']
|
||||
|
||||
drop_cols = ['normalized_name','normalized_domain','block_key','Effektive Website','domain_use_flag', 'normalized_parent_name']
|
||||
for c in drop_cols:
|
||||
if c in write_df.columns:
|
||||
write_df.drop(columns=[c], inplace=True)
|
||||
|
||||
backup_path = os.path.join(LOG_DIR, f"{now}_backup_{MATCHING_SHEET_NAME}.csv")
|
||||
try:
|
||||
write_df.to_csv(backup_path, index=False, encoding='utf-8')
|
||||
logger.info(f"Lokales Backup geschrieben: {backup_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Backup fehlgeschlagen: {e}")
|
||||
|
||||
data = [write_df.columns.tolist()] + write_df.fillna('').values.tolist()
|
||||
ok = sheet.clear_and_write_data(MATCHING_SHEET_NAME, data)
|
||||
if ok:
|
||||
logger.info("Ergebnisse erfolgreich geschrieben")
|
||||
else:
|
||||
logger.error("Fehler beim Schreiben ins Google Sheet")
|
||||
|
||||
# Summary
|
||||
serp_counts = Counter((str(x).lower() for x in write_df.get('Serp Vertrauen', [])))
|
||||
logger.info("===== Summary =====")
|
||||
logger.info(f"Matches total: {metrics['matches_total']} | mit Domain: {metrics['matches_domain']} | mit Ort: {metrics['matches_with_loc']} | nur Name: {metrics['matches_name_only']}")
|
||||
logger.info(f"Serp Vertrauen: {dict(serp_counts)}")
|
||||
logger.info(f"Config: TH={SCORE_THRESHOLD}, TH_WEAK={SCORE_THRESHOLD_WEAK}, MIN_NAME_FOR_DOMAIN={MIN_NAME_FOR_DOMAIN}, Penalties(city={CITY_MISMATCH_PENALTY},country={COUNTRY_MISMATCH_PENALTY}), Prefilter(partial>={PREFILTER_MIN_PARTIAL}, limit={PREFILTER_LIMIT})")
|
||||
|
||||
|
||||
# --- Hauptfunktion ---
|
||||
def main():
|
||||
logger.info("Starte Duplikats-Check v3.0")
|
||||
|
||||
while True:
|
||||
print("\nBitte wählen Sie den gewünschten Modus:")
|
||||
print("1: Externer Vergleich (gleicht CRM_Accounts mit Matching_Accounts ab)")
|
||||
print("2: Interne Deduplizierung (findet Duplikate innerhalb von CRM_Accounts)")
|
||||
choice = input("Ihre Wahl (1 oder 2): ")
|
||||
|
||||
if choice == '1':
|
||||
run_external_comparison()
|
||||
break
|
||||
elif choice == '2':
|
||||
run_internal_deduplication()
|
||||
break
|
||||
else:
|
||||
print("Ungültige Eingabe. Bitte geben Sie 1 oder 2 ein.")
|
||||
|
||||
if __name__=='__main__':
|
||||
main()
|
||||
674
ARCHIVE_legacy_scripts/_legacy_gsheets_system/config.py
Normal file
674
ARCHIVE_legacy_scripts/_legacy_gsheets_system/config.py
Normal file
@@ -0,0 +1,674 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
config.py
|
||||
|
||||
Zentrale Konfiguration für das Projekt "Automatisierte Unternehmensbewertung".
|
||||
Enthält Dateipfade, API-Schlüssel-Pfade, die globale Config-Klasse
|
||||
und das Spalten-Mapping für das Google Sheet.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
import logging
|
||||
|
||||
# ==============================================================================
|
||||
# 1. GLOBALE KONSTANTEN UND DATEIPFADE
|
||||
# ==============================================================================
|
||||
|
||||
# --- Dateipfade (NEU: Feste Pfade für Docker-Betrieb) ---
|
||||
# Das Basisverzeichnis ist im Docker-Kontext immer /app.
|
||||
BASE_DIR = "/app"
|
||||
|
||||
CREDENTIALS_FILE = os.path.join(BASE_DIR, "service_account.json")
|
||||
API_KEY_FILE = os.path.join(BASE_DIR, "gemini_api_key.txt")
|
||||
SERP_API_KEY_FILE = os.path.join(BASE_DIR, "serpapikey.txt")
|
||||
GENDERIZE_API_KEY_FILE = os.path.join(BASE_DIR, "genderize_API_Key.txt")
|
||||
BRANCH_MAPPING_FILE = None
|
||||
LOG_DIR = os.path.join(BASE_DIR, "Log_from_docker") # Log in den gemounteten Ordner schreiben
|
||||
|
||||
# --- ML Modell Artefakte ---
|
||||
MODEL_FILE = os.path.join(BASE_DIR, "technician_decision_tree_model.pkl")
|
||||
IMPUTER_FILE = os.path.join(BASE_DIR, "median_imputer.pkl")
|
||||
PATTERNS_FILE_TXT = os.path.join(BASE_DIR, "technician_patterns.txt") # Alt (Optional beibehalten)
|
||||
PATTERNS_FILE_JSON = os.path.join(BASE_DIR, "technician_patterns.json") # Neu (Empfohlen)
|
||||
|
||||
# Marker für URLs, die erneut per SERP gesucht werden sollen
|
||||
URL_CHECK_MARKER = "URL_CHECK_NEEDED"
|
||||
|
||||
# --- User Agents für Rotation ---
|
||||
USER_AGENTS = [
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/109.0',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:108.0) Gecko/20100101 Firefox/108.0',
|
||||
'Mozilla/5.0 (X11; Linux i686; rv:108.0) Gecko/20100101 Firefox/108.0',
|
||||
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:108.0) Gecko/20100101 Firefox/108.0',
|
||||
]
|
||||
|
||||
# ==============================================================================
|
||||
# 2. VORAB-HELPER FUNKTION (wird von Config-Klasse benötigt)
|
||||
# ==============================================================================
|
||||
|
||||
def normalize_for_mapping(text):
|
||||
"""
|
||||
Normalisiert einen String aggressiv für Mapping-Zwecke.
|
||||
Muss VOR der Config-Klasse definiert werden, da sie dort verwendet wird.
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
return ""
|
||||
text = text.lower()
|
||||
text = text.strip()
|
||||
text = re.sub(r'[^a-z0-9]', '', text)
|
||||
return text
|
||||
|
||||
# ==============================================================================
|
||||
# 3. ZENTRALE KONFIGURATIONS-KLASSE
|
||||
# ==============================================================================
|
||||
|
||||
class Config:
|
||||
"""Zentrale Konfigurationseinstellungen."""
|
||||
VERSION = "v2.0.0" # Version hochgezählt nach Refactoring
|
||||
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
|
||||
RETRY_DELAY = 10
|
||||
REQUEST_TIMEOUT = 20
|
||||
SIMILARITY_THRESHOLD = 0.65
|
||||
DEBUG = True
|
||||
WIKIPEDIA_SEARCH_RESULTS = 5
|
||||
HTML_PARSER = "html.parser"
|
||||
TOKEN_MODEL = "gpt-3.5-turbo"
|
||||
USER_AGENT = 'Mozilla/5.0 (compatible; UnternehmenSkript/1.0; +https://www.example.com/bot)'
|
||||
|
||||
# --- Konfiguration fuer Batching & Parallelisierung ---
|
||||
PROCESSING_BATCH_SIZE = 20
|
||||
OPENAI_BATCH_SIZE_LIMIT = 4
|
||||
MAX_SCRAPING_WORKERS = 10
|
||||
UPDATE_BATCH_ROW_LIMIT = 50
|
||||
MAX_BRANCH_WORKERS = 10
|
||||
OPENAI_CONCURRENCY_LIMIT = 3
|
||||
PROCESSING_BRANCH_BATCH_SIZE = 20
|
||||
SERPAPI_DELAY = 1.5
|
||||
|
||||
# --- (NEU) GTM Architect: Stilvorgabe für Bildgenerierung ---
|
||||
CORPORATE_DESIGN_PROMPT = (
|
||||
"cinematic industrial photography, sleek high-tech aesthetic, futuristic but grounded reality, "
|
||||
"volumetric lighting, sharp focus on modern technology, 8k resolution, photorealistic, "
|
||||
"highly detailed textures, cool steel-blue color grading with subtle safety-yellow accents, "
|
||||
"wide angle lens, shallow depth of field."
|
||||
)
|
||||
|
||||
# --- Plausibilitäts-Schwellenwerte ---
|
||||
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
|
||||
PLAUSI_MA_MAX_WARNUNG = 1000000
|
||||
PLAUSI_RATIO_UMSATZ_PRO_MA_MIN = 25000
|
||||
PLAUSI_RATIO_UMSATZ_PRO_MA_MAX = 1500000
|
||||
PLAUSI_ABWEICHUNG_CRM_WIKI_PROZENT = 30
|
||||
|
||||
# --- Mapping für Länder-Codes ---
|
||||
# Übersetzt D365 Country Codes in die im GSheet verwendete Langform.
|
||||
# WICHTIG: Die Schlüssel (Codes) sollten in Kleinbuchstaben sein für einen robusten Vergleich.
|
||||
COUNTRY_CODE_MAP = {
|
||||
'de': 'Deutschland',
|
||||
'gb': 'Vereinigtes Königreich',
|
||||
'ch': 'Schweiz',
|
||||
'at': 'Österreich',
|
||||
'it': 'Italien',
|
||||
'es': 'Spanien',
|
||||
'dk': 'Dänemark',
|
||||
'hu': 'Ungarn',
|
||||
'se': 'Schweden',
|
||||
'fr': 'Frankreich',
|
||||
'us': 'USA',
|
||||
'br': 'Brasilien',
|
||||
'cz': 'Tschechien',
|
||||
'au': 'Australien',
|
||||
'mx': 'Mexiko',
|
||||
'nl': 'Niederlande',
|
||||
'pl': 'Polen',
|
||||
'be': 'Belgien',
|
||||
'sk': 'Slowakei',
|
||||
'nz': 'Neuseeland',
|
||||
'in': 'Indien',
|
||||
'li': 'Liechtenstein',
|
||||
'ae': 'Vereinigte Arabische Emirate',
|
||||
'ru': 'Russland',
|
||||
'jp': 'Japan',
|
||||
'ro': 'Rumänien',
|
||||
'is': 'Island',
|
||||
'lu': 'Luxemburg',
|
||||
'me': 'Montenegro',
|
||||
'ph': 'Philippinen',
|
||||
'fi': 'Finnland',
|
||||
'no': 'Norwegen',
|
||||
'ma': 'Marokko',
|
||||
'hr': 'Kroatien',
|
||||
'ca': 'Kanada',
|
||||
'ua': 'Ukraine',
|
||||
'sb': 'Salomonen',
|
||||
'za': 'Südafrika',
|
||||
'ee': 'Estland',
|
||||
'cn': 'China',
|
||||
'si': 'Slowenien',
|
||||
'lt': 'Litauen',
|
||||
}
|
||||
|
||||
|
||||
# --- Branchen-Gruppen Mapping (v2.0 - Angereichert mit Definitionen & Beispielen) ---
|
||||
# Single Source of Truth für alle Branchen.
|
||||
BRANCH_GROUP_MAPPING = {
|
||||
"Maschinenbau": {
|
||||
"gruppe": "Hersteller / Produzenten",
|
||||
"definition": "Herstellung von zumeist größeren und komplexen Maschinen. Abgrenzung: Keine Anlagen wie z.B. Aufzüge, Rolltreppen oder komplette Produktionsstraßen.",
|
||||
"beispiele": "EBM Papst, Kärcher, Winterhalter, Testo, ZwickRoell, Koch Pac, Uhlmann, BHS, Schlie, Kasto, Chiron",
|
||||
"d365_branch_detail": "Maschinenbau"
|
||||
},
|
||||
"Automobil": {
|
||||
"gruppe": "Hersteller / Produzenten",
|
||||
"definition": "Hersteller von (Spezial)-Fahrzeugen, die meist in ihrer Bewegung eingeschränkt sind (z.B. Mähdrescher, Pistenraupen). Abgrenzung: Keine Autohändler oder Service an PKWs.",
|
||||
"beispiele": "Kässbohrer, Aebi Schmidt, Pesko, Nova, PV Automotive",
|
||||
"d365_branch_detail": "Automobil"
|
||||
},
|
||||
"Anlagenbau": {
|
||||
"gruppe": "Hersteller / Produzenten",
|
||||
"definition": "Hersteller von komplexen Anlagen, die fest beim Kunden installiert werden (z.B. Fertigungsanlagen) und oft der Herstellung nachgelagerter Erzeugnisse dienen. Abgrenzung: Keine Aufzugsanlagen, keine Rolltreppen.",
|
||||
"beispiele": "Yaskawa, Good Mills, Jungheinrich, Abus, BWT",
|
||||
"d365_branch_detail": "Anlagenbau"
|
||||
},
|
||||
"Medizintechnik": {
|
||||
"gruppe": "Hersteller / Produzenten",
|
||||
"definition": "Hersteller von medizinischen Geräten für Krankenhäuser, (Zahn-)Arztpraxen oder den Privatbereich. Abgrenzung: Keine reinen Dienstleister/Pflegedienste.",
|
||||
"beispiele": "Carl Zeiss, MMM, Olympus, Sysmex, Henry Schein, Dental Bauer, Vitalaire",
|
||||
"d365_branch_detail": "Medizintechnik"
|
||||
},
|
||||
"Chemie & Pharma": {
|
||||
"gruppe": "Hersteller / Produzenten",
|
||||
"definition": "Unternehmen, die chemische oder pharmazeutische Erzeugnisse herstellen. Abgrenzung: Keine Lebensmittel.",
|
||||
"beispiele": "Brillux",
|
||||
"d365_branch_detail": "Chemie & Pharma"
|
||||
},
|
||||
"Elektrotechnik": {
|
||||
"gruppe": "Hersteller / Produzenten",
|
||||
"definition": "Hersteller von Maschinen und Geräten, die sich hauptsächlich durch elektrische Komponenten auszeichnen.",
|
||||
"beispiele": "Triathlon, SBS BatterieSystem",
|
||||
"d365_branch_detail": "Elektrotechnik"
|
||||
},
|
||||
"Lebensmittelproduktion": {
|
||||
"gruppe": "Hersteller / Produzenten",
|
||||
"definition": "Unternehmen, die Lebensmittel im industriellen Maßstab produzieren.",
|
||||
"beispiele": "Ferrero, Lohmann, Mars, Fuchs, Teekanne, Frischli",
|
||||
"d365_branch_detail": "Lebensmittelproduktion"
|
||||
},
|
||||
"IT / Telekommunikation": {
|
||||
"gruppe": "Hersteller / Produzenten",
|
||||
"definition": "Hersteller von Telekommunikations-Hardware und -Equipment. Abgrenzung: Keine Telekommunikations-Netzbetreiber.",
|
||||
"beispiele": "NDI Nordisk Daek Import Danmark",
|
||||
"d365_branch_detail": "IT / Telekommunikation"
|
||||
},
|
||||
"Bürotechnik": {
|
||||
"gruppe": "Hersteller / Produzenten",
|
||||
"definition": "Hersteller von Geräten für die Büro-Infrastruktur wie Drucker, Kopierer oder Aktenvernichter.",
|
||||
"beispiele": "Ricoh, Rosskopf",
|
||||
"d365_branch_detail": "Bürotechnik"
|
||||
},
|
||||
"Automaten (Vending / Slot)": {
|
||||
"gruppe": "Hersteller / Produzenten",
|
||||
"definition": "Reine Hersteller von Verkaufs-, Service- oder Spielautomaten, die mitunter einen eigenen Kundenservice haben.",
|
||||
"beispiele": "Coffema, Melitta, Tchibo, Selecta",
|
||||
"d365_branch_detail": "Automaten (Vending, Slot)"
|
||||
},
|
||||
"Gebäudetechnik Heizung / Lüftung / Klima": {
|
||||
"gruppe": "Hersteller / Produzenten",
|
||||
"definition": "Reine Hersteller von Heizungs-, Lüftungs- und Klimaanlagen (HLK), die mitunter einen eigenen Kundenservice haben.",
|
||||
"beispiele": "Wolf, ETA, Fröling, Ochsner, Windhager, DKA",
|
||||
"d365_branch_detail": "Gebäudetechnik Heizung, Lüftung, Klima"
|
||||
},
|
||||
"Gebäudetechnik Allgemein": {
|
||||
"gruppe": "Hersteller / Produzenten",
|
||||
"definition": "Hersteller von Produkten, die fest in Gebäuden installiert werden (z.B. Sicherheitstechnik, Türen, Sonnenschutz).",
|
||||
"beispiele": "Geze, Bothe Hild, Warema, Hagleitner",
|
||||
"d365_branch_detail": "Gebäudetechnik Allgemein"
|
||||
},
|
||||
"Schädlingsbekämpfung": {
|
||||
"gruppe": "Hersteller / Produzenten",
|
||||
"definition": "Hersteller von Systemen und Produkten zur Schädlingsbekämpfung.",
|
||||
"beispiele": "BioTec, RSD Systems",
|
||||
"d365_branch_detail": "Schädlingsbekämpfung"
|
||||
},
|
||||
"Braune & Weiße Ware": {
|
||||
"gruppe": "Hersteller / Produzenten",
|
||||
"definition": "Hersteller von Haushaltsgroßgeräten (Weiße Ware) und Unterhaltungselektronik (Braune Ware).",
|
||||
"beispiele": "BSH",
|
||||
"d365_branch_detail": "Braune & Weiße Ware"
|
||||
},
|
||||
"Fenster / Glas": {
|
||||
"gruppe": "Hersteller / Produzenten",
|
||||
"definition": "Hersteller von Fenstern, Türen oder Glaselementen.",
|
||||
"beispiele": "",
|
||||
"d365_branch_detail": "Fenster / Glas"
|
||||
},
|
||||
"Getränke": {
|
||||
"gruppe": "Hersteller / Produzenten",
|
||||
"definition": "Industrielle Hersteller von Getränken.",
|
||||
"beispiele": "Wesergold, Schlossquelle, Winkels",
|
||||
"d365_branch_detail": "Getränke"
|
||||
},
|
||||
"Möbel": {
|
||||
"gruppe": "Hersteller / Produzenten",
|
||||
"definition": "Industrielle Hersteller von Möbeln.",
|
||||
"beispiele": "mycs",
|
||||
"d365_branch_detail": "Möbel"
|
||||
},
|
||||
"Agrar / Pellets": {
|
||||
"gruppe": "Hersteller / Produzenten",
|
||||
"definition": "Hersteller von landwirtschaftlichen Produkten, Maschinen oder Brennstoffen wie Holzpellets.",
|
||||
"beispiele": "KWB Energiesysteme",
|
||||
"d365_branch_detail": "Agrar, Pellets"
|
||||
},
|
||||
"Stadtwerke": {
|
||||
"gruppe": "Versorger",
|
||||
"definition": "Lokale Stadtwerke, die die lokale Infrastruktur für die Energieversorgung (Strom, Gas, Wasser) betreiben.",
|
||||
"beispiele": "Badenova, Drewag, Stadtwerke Leipzig, Stadtwerke Kiel",
|
||||
"d365_branch_detail": "Stadtwerke"
|
||||
},
|
||||
"Verteilnetzbetreiber": {
|
||||
"gruppe": "Versorger",
|
||||
"definition": "Überregionale Betreiber von Verteilnetzen (Strom, Gas), die oft keine direkten Endkundenversorger sind.",
|
||||
"beispiele": "Rheinenergie, Open Grid, ENBW",
|
||||
"d365_branch_detail": "Verteilnetzbetreiber"
|
||||
},
|
||||
"Telekommunikation": {
|
||||
"gruppe": "Versorger",
|
||||
"definition": "Betreiber von Telekommunikations-Infrastruktur und Netzen (z.B. Telefon, Internet, Mobilfunk).",
|
||||
"beispiele": "M-Net, NetCologne, Thiele, Willy.tel",
|
||||
"d365_branch_detail": "Telekommunikation"
|
||||
},
|
||||
"Gase & Mineralöl": {
|
||||
"gruppe": "Versorger",
|
||||
"definition": "Unternehmen, die Gas- oder Mineralölprodukte an Endkunden oder Unternehmen liefern.",
|
||||
"beispiele": "Westfalen AG, GasCom",
|
||||
"d365_branch_detail": "Gase & Mineralöl"
|
||||
},
|
||||
"Messdienstleister": {
|
||||
"gruppe": "Service provider (Dienstleister)",
|
||||
"definition": "Unternehmen, die sich auf die Ablesung und Abrechnung von Verbrauchszählern (Heizung, Wasser) spezialisiert haben. Abgrenzung: Kein Versorger.",
|
||||
"beispiele": "Brunata, Ista, Telent",
|
||||
"d365_branch_detail": "Messdienstleister"
|
||||
},
|
||||
"Facility Management": {
|
||||
"gruppe": "Service provider (Dienstleister)",
|
||||
"definition": "Anbieter von Dienstleistungen rund um Immobilien, von der technischen Instandhaltung bis zur Reinigung.",
|
||||
"beispiele": "Wisag, Vonovia, Infraserv, Gewofag, B&O, Sprint Sanierungen, BWTS",
|
||||
"d365_branch_detail": "Facility Management"
|
||||
},
|
||||
"Healthcare/Pflegedienste": {
|
||||
"gruppe": "Service provider (Dienstleister)",
|
||||
"definition": "Erbringen von reinen Dienstleistungen an medizinischen Geräten (z.B. Wartung, Lieferung) oder direkt an Menschen (Pflege). Abgrenzung: Keine Hersteller.",
|
||||
"beispiele": "Sanimed, Fuchs+Möller, Strehlow, Healthcare at Home",
|
||||
"d365_branch_detail": "Healthcare/Pflegedienste"
|
||||
},
|
||||
"Servicedienstleister / Reparatur ohne Produktion": {
|
||||
"gruppe": "Service provider (Dienstleister)",
|
||||
"definition": "Reine Service-Organisationen, die technische Geräte warten und reparieren, aber nicht selbst herstellen.",
|
||||
"beispiele": "HSR, FFB",
|
||||
"d365_branch_detail": "Servicedienstleister / Reparatur ohne Produktion"
|
||||
},
|
||||
"Aufzüge und Rolltreppen": {
|
||||
"gruppe": "Service provider (Dienstleister)",
|
||||
"definition": "Hersteller und Unternehmen, die Service, Wartung und Installation von Aufzügen und Rolltreppen anbieten.",
|
||||
"beispiele": "TKE, Liftstar, Lifta",
|
||||
"d365_branch_detail": "Aufzüge und Rolltreppen"
|
||||
},
|
||||
"Feuer- und Sicherheitssysteme": {
|
||||
"gruppe": "Service provider (Dienstleister)",
|
||||
"definition": "Dienstleister für die Wartung, Installation und Überprüfung von Brandmelde- und Sicherheitssystemen.",
|
||||
"beispiele": "Minimax, Securiton",
|
||||
"d365_branch_detail": "Feuer- und Sicherheitssysteme"
|
||||
},
|
||||
"Personentransport": {
|
||||
"gruppe": "Service provider (Dienstleister)",
|
||||
"definition": "Unternehmen, die Personen befördern (z.B. Busunternehmen, Taxi-Zentralen) und eine eigene Fahrzeugflotte warten.",
|
||||
"beispiele": "Rhein-Sieg-Verkehrsgesellschaft",
|
||||
"d365_branch_detail": "Personentransport"
|
||||
},
|
||||
"Entsorgung": {
|
||||
"gruppe": "Service provider (Dienstleister)",
|
||||
"definition": "Unternehmen der Abfall- und Entsorgungswirtschaft mit komplexer Logistik und Fahrzeugmanagement.",
|
||||
"beispiele": "",
|
||||
"d365_branch_detail": "Entsorgung"
|
||||
},
|
||||
"Catering Services": {
|
||||
"gruppe": "Service provider (Dienstleister)",
|
||||
"definition": "Anbieter von Verpflegungsdienstleistungen, oft mit komplexer Logistik und Wartung von Küchengeräten.",
|
||||
"beispiele": "Café+Co International",
|
||||
"d365_branch_detail": "Catering Services"
|
||||
},
|
||||
"Auslieferdienste": {
|
||||
"gruppe": "Handel & Logistik",
|
||||
"definition": "Unternehmen, deren Kerngeschäft der Transport und die Logistik von Waren zum Endkunden ist (Lieferdienste). Abgrenzung: Keine reinen Logistik-Dienstleister.",
|
||||
"beispiele": "Edeka, Rewe, Saturn, Gamma Reifen",
|
||||
"d365_branch_detail": "Auslieferdienste"
|
||||
},
|
||||
"Energie (Brennstoffe)": {
|
||||
"gruppe": "Handel & Logistik",
|
||||
"definition": "Unternehmen, deren Kerngeschäft der Transport und die Logistik von Brennstoffen wie Heizöl zum Endkunden ist.",
|
||||
"beispiele": "Eckert & Ziegler",
|
||||
"d365_branch_detail": "Energie (Brennstoffe)"
|
||||
},
|
||||
"Großhandel": {
|
||||
"gruppe": "Handel & Logistik",
|
||||
"definition": "Großhandelsunternehmen, bei denen der Transport und die Logistik eine zentrale Rolle spielen.",
|
||||
"beispiele": "Hairhaus, NDI Nordisk",
|
||||
"d365_branch_detail": "Großhandel"
|
||||
},
|
||||
"Einzelhandel": {
|
||||
"gruppe": "Handel & Logistik",
|
||||
"definition": "Einzelhandelsunternehmen, oft mit eigener Lieferlogistik zum Endkunden.",
|
||||
"beispiele": "Cactus, mertens, Teuto",
|
||||
"d365_branch_detail": "Einzelhandel"
|
||||
},
|
||||
"Logistik": {
|
||||
"gruppe": "Handel & Logistik",
|
||||
"definition": "Allgemeine Logistikdienstleister, die nicht in eine der spezifischeren Kategorien passen.",
|
||||
"beispiele": "Gerdes + Landwehr, Rüdebusch, Winner",
|
||||
"d365_branch_detail": "Logistik - Sonstige"
|
||||
},
|
||||
"Baustoffhandel": {
|
||||
"gruppe": "Baubranche",
|
||||
"definition": "Großhandel mit Baustoffen wie Zement, Kies, Holz oder Fliesen – oft mit eigenen Fuhrparks und komplexer Filiallogistik.",
|
||||
"beispiele": "Kemmler Baustoffe, Henri Benthack",
|
||||
"d365_branch_detail": "Baustoffhandel"
|
||||
},
|
||||
"Baustoffindustrie": {
|
||||
"gruppe": "Baubranche",
|
||||
"definition": "Produktion von Baustoffen wie Beton, Ziegeln, Gips oder Dämmmaterial – häufig mit werkseigener Logistik.",
|
||||
"beispiele": "Heidelberg Materials, Saint Gobain Weber",
|
||||
"d365_branch_detail": "Baustoffindustrie"
|
||||
},
|
||||
"Logistiker Baustoffe": {
|
||||
"gruppe": "Baubranche",
|
||||
"definition": "Spezialisierte Transportdienstleister für Baustoffe – häufig im Nahverkehr, mit engen Zeitfenstern und Baustellenbelieferung.",
|
||||
"beispiele": "C.Bergmann, HENGE Baustoff GmbH",
|
||||
"d365_branch_detail": "Logistiker Baustoffe"
|
||||
},
|
||||
"Baustoffindustrie": {
|
||||
"gruppe": "Baubranche",
|
||||
"definition": "Produktion von Baustoffen wie Beton, Ziegeln, Gips oder Dämmmaterial – häufig mit werkseigener Logistik.",
|
||||
"beispiele": "Heidelberg Materials, Saint Gobain Weber",
|
||||
"d365_branch_detail": "Baustoffindustrie"
|
||||
},
|
||||
"Bauunternehmen": {
|
||||
"gruppe": "Baubranche",
|
||||
"definition": "Ausführung von Bauprojekten, oft mit eigenem Materialtransport – hoher Koordinationsaufwand bei Fahrzeugen, Maschinen und Baustellen.",
|
||||
"beispiele": "Max Bögl, Leonhard Weiss",
|
||||
"d365_branch_detail": "Bauunternehmen"
|
||||
},
|
||||
"Versicherungsgutachten": {
|
||||
"gruppe": "Gutachter / Versicherungen",
|
||||
"definition": "Gutachter, die im Auftrag von Versicherungen Schäden prüfen und bewerten.",
|
||||
"beispiele": "DEVK, Allianz",
|
||||
"d365_branch_detail": "Versicherungsgutachten"
|
||||
},
|
||||
"Technische Gutachten": {
|
||||
"gruppe": "Gutachter / Versicherungen",
|
||||
"definition": "Sachverständige und Organisationen, die technische Prüfungen, Inspektionen und Gutachten durchführen.",
|
||||
"beispiele": "TÜV, Audatex, Value, MDK",
|
||||
"d365_branch_detail": "Technische Gutachten"
|
||||
},
|
||||
"Medizinische Gutachten": {
|
||||
"gruppe": "Gutachter / Versicherungen",
|
||||
"definition": "Sachverständige und Organisationen (z.B. MDK), die medizinische Gutachten erstellen.",
|
||||
"beispiele": "MDK",
|
||||
"d365_branch_detail": "Medizinische Gutachten"
|
||||
},
|
||||
"Baugutachter": {
|
||||
"gruppe": "Gutachter / Versicherungen",
|
||||
"definition": "Sachverständige, die Bauschäden oder den Wert von Immobilien begutachten.",
|
||||
"beispiele": "",
|
||||
"d365_branch_detail": "Baugutachter"
|
||||
},
|
||||
"Wohnungswirtschaft": {
|
||||
"gruppe": "Housing",
|
||||
"definition": "Wohnungsbaugesellschaften oder -genossenschaften, die ihre Immobilien instand halten.",
|
||||
"beispiele": "GEWOFAG",
|
||||
"d365_branch_detail": "Wohnungswirtschaft"
|
||||
},
|
||||
"Renovierungsunternehmen": {
|
||||
"gruppe": "Housing",
|
||||
"definition": "Dienstleister, die auf die Renovierung und Sanierung von Wohnimmobilien spezialisiert sind.",
|
||||
"beispiele": "",
|
||||
"d365_branch_detail": "Renovierungsunternehmen"
|
||||
},
|
||||
"Sozialbau Unternehmen": {
|
||||
"gruppe": "Housing",
|
||||
"definition": "Unternehmen, die im Bereich des sozialen Wohnungsbaus tätig sind.",
|
||||
"beispiele": "",
|
||||
"d365_branch_detail": "Anbieter für Soziales Wohnen"
|
||||
},
|
||||
"IT Beratung": {
|
||||
"gruppe": "Sonstige",
|
||||
"definition": "Beratungsunternehmen mit Fokus auf IT-Strategie und -Implementierung. Abgrenzung: Keine Systemhäuser mit eigenem Außendienst.",
|
||||
"beispiele": "",
|
||||
"d365_branch_detail": "IT Beratung"
|
||||
},
|
||||
"Unternehmensberatung": {
|
||||
"gruppe": "Sonstige",
|
||||
"definition": "Klassische Management- und Strategieberatungen.",
|
||||
"beispiele": "",
|
||||
"d365_branch_detail": "Unternehmensberatung (old)"
|
||||
},
|
||||
"Engineering": {
|
||||
"gruppe": "Sonstige",
|
||||
"definition": "Ingenieurbüros und technische Planungsdienstleister.",
|
||||
"beispiele": "",
|
||||
"d365_branch_detail": "Engineering"
|
||||
},
|
||||
"Öffentliche Verwaltung": {
|
||||
"gruppe": "Sonstige",
|
||||
"definition": "Behörden und öffentliche Einrichtungen, oft mit eigenen technischen Abteilungen (z.B. Bauhöfe).",
|
||||
"beispiele": "",
|
||||
"d365_branch_detail": "Öffentliche Verwaltung"
|
||||
},
|
||||
"Sonstiger Service": {
|
||||
"gruppe": "Sonstige",
|
||||
"definition": "Auffangkategorie für Dienstleistungen, die keiner anderen Kategorie zugeordnet werden können.",
|
||||
"beispiele": "",
|
||||
"d365_branch_detail": "Sonstiger Service (old)"
|
||||
}
|
||||
}
|
||||
|
||||
# Branchenübergreifende Top-Referenzen als Fallback
|
||||
FALLBACK_REFERENCES = [
|
||||
"Jungheinrich (weltweit >4.000 Techniker)",
|
||||
"Vivawest (Kundenzufriedenheit > 95%)",
|
||||
"TK Elevators (1.500 Techniker)",
|
||||
"NetCologne"
|
||||
]
|
||||
|
||||
# --- API Schlüssel Speicherung (werden in main() geladen) ---
|
||||
API_KEYS = {}
|
||||
|
||||
@classmethod
|
||||
def load_api_keys(cls):
|
||||
"""Laedt API-Schluessel aus den definierten Dateien."""
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("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)
|
||||
|
||||
if cls.API_KEYS.get('openai'):
|
||||
# Hier nehmen wir an, dass 'openai' für Gemini verwendet wird (Legacy)
|
||||
# Falls in helpers.py direkt auf 'gemini' zugegriffen wird, müsste das hier auch gesetzt werden.
|
||||
logger.info("Gemini API Key (via 'openai' slot) erfolgreich geladen.")
|
||||
else:
|
||||
logger.warning("Gemini API Key konnte nicht geladen werden. KI-Funktionen sind deaktiviert.")
|
||||
|
||||
if not cls.API_KEYS.get('serpapi'):
|
||||
logger.warning("SerpAPI Key konnte nicht geladen werden. Suchfunktionen sind deaktiviert.")
|
||||
if not cls.API_KEYS.get('genderize'):
|
||||
logger.warning("Genderize API Key konnte nicht geladen werden. Geschlechtserkennung ist eingeschraenkt.")
|
||||
|
||||
@staticmethod
|
||||
def _load_key_from_file(filepath):
|
||||
"""Hilfsfunktion zum Laden eines Schluessels aus einer Datei."""
|
||||
logger = logging.getLogger(__name__)
|
||||
abs_path = os.path.abspath(filepath)
|
||||
try:
|
||||
with open(abs_path, "r", encoding="utf-8") as f:
|
||||
key = f.read().strip()
|
||||
if key:
|
||||
return key
|
||||
else:
|
||||
logger.warning(f"API key file is empty: '{abs_path}'")
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"API key file not found at path: '{abs_path}'")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading key file '{abs_path}': {e}")
|
||||
return None
|
||||
|
||||
# ==============================================================================
|
||||
# 4. GLOBALE DATENSTRUKTUR-VARIABLEN
|
||||
# ==============================================================================
|
||||
|
||||
# NEU: Definiert die exakte und garantierte Reihenfolge der Spalten.
|
||||
# Dies ist die neue "Single Source of Truth" für alle Index-Berechnungen.
|
||||
COLUMN_ORDER = [
|
||||
"ReEval Flag", "CRM Name", "CRM Kurzform", "Parent Account Name", "CRM Website", "CRM Ort", "CRM Land",
|
||||
"CRM Beschreibung", "CRM Branche", "CRM Beschreibung Branche extern", "CRM Anzahl Techniker", "CRM Umsatz",
|
||||
"CRM Anzahl Mitarbeiter", "CRM Vorschlag Wiki URL", "System Vorschlag Parent Account", "Parent Vorschlag Status",
|
||||
"Parent Vorschlag Timestamp", "Wiki URL", "Wiki Sitz Stadt", "Wiki Sitz Land", "Wiki Absatz", "Wiki Branche",
|
||||
"Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Wikipedia Timestamp", "Wiki Verif. Timestamp",
|
||||
"SerpAPI Wiki Search Timestamp", "Chat Wiki Konsistenzpruefung", "Chat Begründung Wiki Inkonsistenz",
|
||||
"Chat Vorschlag Wiki Artikel", "Begründung bei Abweichung", "Website Rohtext", "Website Zusammenfassung",
|
||||
"Website Meta-Details", "Website Scrape Timestamp", "URL Prüfstatus", "Chat Vorschlag Branche",
|
||||
"Chat Branche Konfidenz", "Chat Konsistenz Branche", "Chat Begruendung Abweichung Branche",
|
||||
"Chat Prüfung FSM Relevanz", "Chat Begründung für FSM Relevanz", "Chat Schätzung Anzahl Mitarbeiter",
|
||||
"Chat Konsistenzprüfung Mitarbeiterzahl", "Chat Begruendung Abweichung Mitarbeiterzahl",
|
||||
"Chat Einschätzung Anzahl Servicetechniker", "Chat Begründung Abweichung Anzahl Servicetechniker",
|
||||
"Chat Schätzung Umsatz", "Chat Begründung Abweichung Umsatz", "FSM Pitch", "FSM Pitch Timestamp",
|
||||
"Linked Serviceleiter gefunden", "Linked It-Leiter gefunden", "Linked Management gefunden",
|
||||
"Linked Disponent gefunden", "Contact Search Timestamp", "Finaler Umsatz (Wiki>CRM)",
|
||||
"Finaler Mitarbeiter (Wiki>CRM)", "Geschaetzter Techniker Bucket", "Plausibilität Umsatz",
|
||||
"Plausibilität Mitarbeiter", "Plausibilität Umsatz/MA Ratio", "Abweichung Umsatz CRM/Wiki",
|
||||
"Abweichung MA CRM/Wiki", "Plausibilität Begründung", "Plausibilität Prüfdatum",
|
||||
"Archiviert", "SyncConflict", "Timestamp letzte Pruefung", "Version", "Tokens", "CRM ID"
|
||||
]
|
||||
|
||||
# --- Spalten-Mapping (Single Source of Truth) ---
|
||||
# Version 1.8.0 - 68 Spalten (A-BP)
|
||||
COLUMN_MAP = {
|
||||
# A-E: Stammdaten & Prozesssteuerung
|
||||
"ReEval Flag": {"Titel": "A", "index": 0},
|
||||
"CRM Name": {"Titel": "B", "index": 1},
|
||||
"CRM Kurzform": {"Titel": "C", "index": 2},
|
||||
"Parent Account Name": {"Titel": "D", "index": 3},
|
||||
"CRM Website": {"Titel": "E", "index": 4},
|
||||
# F-M: CRM-Daten
|
||||
"CRM Ort": {"Titel": "F", "index": 5},
|
||||
"CRM Land": {"Titel": "G", "index": 6},
|
||||
"CRM Beschreibung": {"Titel": "H", "index": 7},
|
||||
"CRM Branche": {"Titel": "I", "index": 8},
|
||||
"CRM Beschreibung Branche extern": {"Titel": "J", "index": 9},
|
||||
"CRM Anzahl Techniker": {"Titel": "K", "index": 10},
|
||||
"CRM Umsatz": {"Titel": "L", "index": 11},
|
||||
"CRM Anzahl Mitarbeiter": {"Titel": "M", "index": 12},
|
||||
# N-Q: System & Parent Vorschläge
|
||||
"CRM Vorschlag Wiki URL": {"Titel": "N", "index": 13},
|
||||
"System Vorschlag Parent Account": {"Titel": "O", "index": 14},
|
||||
"Parent Vorschlag Status": {"Titel": "P", "index": 15},
|
||||
"Parent Vorschlag Timestamp": {"Titel": "Q", "index": 16},
|
||||
# R-AB: Wikipedia Extraktion
|
||||
"Wiki URL": {"Titel": "R", "index": 17},
|
||||
"Wiki Sitz Stadt": {"Titel": "S", "index": 18},
|
||||
"Wiki Sitz Land": {"Titel": "T", "index": 19},
|
||||
"Wiki Absatz": {"Titel": "U", "index": 20},
|
||||
"Wiki Branche": {"Titel": "V", "index": 21},
|
||||
"Wiki Umsatz": {"Titel": "W", "index": 22},
|
||||
"Wiki Mitarbeiter": {"Titel": "X", "index": 23},
|
||||
"Wiki Kategorien": {"Titel": "Y", "index": 24},
|
||||
"Wikipedia Timestamp": {"Titel": "Z", "index": 25},
|
||||
"Wiki Verif. Timestamp": {"Titel": "AA", "index": 26},
|
||||
"SerpAPI Wiki Search Timestamp": {"Titel": "AB", "index": 27},
|
||||
# AC-AF: ChatGPT Wiki Verifizierung
|
||||
"Chat Wiki Konsistenzpruefung": {"Titel": "AC", "index": 28},
|
||||
"Chat Begründung Wiki Inkonsistenz": {"Titel": "AD", "index": 29},
|
||||
"Chat Vorschlag Wiki Artikel": {"Titel": "AE", "index": 30},
|
||||
"Begründung bei Abweichung": {"Titel": "AF", "index": 31},
|
||||
# AG-AK: Website Scraping
|
||||
"Website Rohtext": {"Titel": "AG", "index": 32},
|
||||
"Website Zusammenfassung": {"Titel": "AH", "index": 33},
|
||||
"Website Meta-Details": {"Titel": "AI", "index": 34},
|
||||
"Website Scrape Timestamp": {"Titel": "AJ", "index": 35},
|
||||
"URL Prüfstatus": {"Titel": "AK", "index": 36},
|
||||
# AL-AU: ChatGPT Branchen & FSM Analyse
|
||||
"Chat Vorschlag Branche": {"Titel": "AL", "index": 37},
|
||||
"Chat Branche Konfidenz": {"Titel": "AM", "index": 38},
|
||||
"Chat Konsistenz Branche": {"Titel": "AN", "index": 39},
|
||||
"Chat Begruendung Abweichung Branche": {"Titel": "AO", "index": 40},
|
||||
"Chat Prüfung FSM Relevanz": {"Titel": "AP", "index": 41},
|
||||
"Chat Begründung für FSM Relevanz": {"Titel": "AQ", "index": 42},
|
||||
"Chat Schätzung Anzahl Mitarbeiter": {"Titel": "AR", "index": 43},
|
||||
"Chat Konsistenzprüfung Mitarbeiterzahl": {"Titel": "AS", "index": 44},
|
||||
"Chat Begruendung Abweichung Mitarbeiterzahl": {"Titel": "AT", "index": 45},
|
||||
"Chat Einschätzung Anzahl Servicetechniker": {"Titel": "AU", "index": 46},
|
||||
# AV-AZ: ChatGPT Fortsetzung & FSM Pitch
|
||||
"Chat Begründung Abweichung Anzahl Servicetechniker": {"Titel": "AV", "index": 47},
|
||||
"Chat Schätzung Umsatz": {"Titel": "AW", "index": 48},
|
||||
"Chat Begründung Abweichung Umsatz": {"Titel": "AX", "index": 49},
|
||||
"FSM Pitch": {"Titel": "AY", "index": 50},
|
||||
"FSM Pitch Timestamp": {"Titel": "AZ", "index": 51},
|
||||
# BA-BE: LinkedIn Kontaktsuche
|
||||
"Linked Serviceleiter gefunden": {"Titel": "BA", "index": 52},
|
||||
"Linked It-Leiter gefunden": {"Titel": "BB", "index": 53},
|
||||
"Linked Management gefunden": {"Titel": "BC", "index": 54},
|
||||
"Linked Disponent gefunden": {"Titel": "BD", "index": 55},
|
||||
"Contact Search Timestamp": {"Titel": "BE", "index": 56},
|
||||
# BF-BH: Konsolidierte Daten & ML
|
||||
"Finaler Umsatz (Wiki>CRM)": {"Titel": "BF", "index": 57},
|
||||
"Finaler Mitarbeiter (Wiki>CRM)": {"Titel": "BG", "index": 58},
|
||||
"Geschaetzter Techniker Bucket": {"Titel": "BH", "index": 59},
|
||||
# BI-BO: Plausibilitäts-Checks
|
||||
"Plausibilität Umsatz": {"Titel": "BI", "index": 60},
|
||||
"Plausibilität Mitarbeiter": {"Titel": "BJ", "index": 61},
|
||||
"Plausibilität Umsatz/MA Ratio": {"Titel": "BK", "index": 62},
|
||||
"Abweichung Umsatz CRM/Wiki": {"Titel": "BL", "index": 63},
|
||||
"Abweichung MA CRM/Wiki": {"Titel": "BM", "index": 64},
|
||||
"Plausibilität Begründung": {"Titel": "BN", "index": 65},
|
||||
"Plausibilität Prüfdatum": {"Titel": "BO", "index": 66},
|
||||
"Archiviert": {"Titel": "BP", "index": 67},
|
||||
"SyncConflict": {"Titel": "BQ", "index": 68},
|
||||
# BR-BU: Metadaten (Indizes verschoben)
|
||||
"Timestamp letzte Pruefung": {"Titel": "BR", "index": 69},
|
||||
"Version": {"Titel": "BS", "index": 70},
|
||||
"Tokens": {"Titel": "BT", "index": 71},
|
||||
"CRM ID": {"Titel": "BU", "index": 72}
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# 5. DEALFRONT AUTOMATION CONFIGURATION
|
||||
# ==============================================================================
|
||||
DEALFRONT_CREDENTIALS_FILE = os.path.join(BASE_DIR, "dealfront_credentials.json")
|
||||
DEALFRONT_LOGIN_URL = "https://app.dealfront.com/login"
|
||||
|
||||
# Die direkte URL zum 'Target'-Bereich. Dies hat sich als der robusteste Weg erwiesen.
|
||||
DEALFRONT_TARGET_URL = "https://app.dealfront.com/t/prospector/companies"
|
||||
|
||||
# WICHTIG: Der exakte Name der vordefinierten Suche, die nach der Navigation geladen werden soll.
|
||||
TARGET_SEARCH_NAME = "Facility Management" # <-- PASSEN SIE DIESEN NAMEN AN IHRE ZIEL-LISTE AN
|
||||
|
||||
|
||||
# --- END OF FILE config.py ---
|
||||
@@ -0,0 +1,252 @@
|
||||
# contact_grouping.py
|
||||
|
||||
__version__ = "v1.2.3"
|
||||
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
import sys
|
||||
import pandas as pd
|
||||
from collections import defaultdict
|
||||
|
||||
from google_sheet_handler import GoogleSheetHandler
|
||||
from helpers import create_log_filename, call_openai_chat
|
||||
from config import Config
|
||||
|
||||
# --- Konfiguration ---
|
||||
TARGET_SHEET_NAME = "Matching_Positions"
|
||||
LEARNING_SOURCE_SHEET_NAME = "CRM_Jobtitles"
|
||||
EXACT_MATCH_FILE = "exact_match_map.json"
|
||||
KEYWORD_RULES_FILE = "keyword_rules.json"
|
||||
DEFAULT_DEPARTMENT = "Undefined"
|
||||
AI_BATCH_SIZE = 150
|
||||
|
||||
def setup_logging():
|
||||
log_filename = create_log_filename("contact_grouping")
|
||||
if not log_filename:
|
||||
print("KRITISCHER FEHLER: Log-Datei konnte nicht erstellt werden. Logge nur in die Konsole.")
|
||||
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[logging.StreamHandler()])
|
||||
return
|
||||
log_level = logging.DEBUG
|
||||
root_logger = logging.getLogger()
|
||||
if root_logger.handlers:
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
logging.basicConfig(level=log_level, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[logging.FileHandler(log_filename, encoding='utf-8'), logging.StreamHandler()])
|
||||
logging.getLogger("gspread").setLevel(logging.WARNING)
|
||||
logging.getLogger("oauth2client").setLevel(logging.WARNING)
|
||||
logging.info(f"Logging erfolgreich initialisiert. Log-Datei: {log_filename}")
|
||||
|
||||
class ContactGrouper:
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__ + ".ContactGrouper")
|
||||
self.exact_match_map = None
|
||||
self.keyword_rules = None
|
||||
self.ai_example_prompt_part = ""
|
||||
|
||||
def load_knowledge_base(self):
|
||||
self.logger.info("Lade Wissensbasis...")
|
||||
self.exact_match_map = self._load_json(EXACT_MATCH_FILE)
|
||||
self.keyword_rules = self._load_json(KEYWORD_RULES_FILE)
|
||||
if self.exact_match_map is None or self.keyword_rules is None:
|
||||
self.logger.critical("Fehler beim Laden der Wissensbasis. Abbruch.")
|
||||
return False
|
||||
self._generate_ai_examples()
|
||||
self.logger.info("Wissensbasis erfolgreich geladen und KI-Beispiele generiert.")
|
||||
return True
|
||||
|
||||
def _load_json(self, file_path):
|
||||
if not os.path.exists(file_path):
|
||||
self.logger.error(f"Wissensbasis-Datei '{file_path}' nicht gefunden.")
|
||||
return None
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
self.logger.debug(f"Lese und parse '{file_path}'...")
|
||||
data = json.load(f)
|
||||
self.logger.debug(f"'{file_path}' erfolgreich geparst.")
|
||||
return data
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
self.logger.error(f"Fehler beim Laden der Datei '{file_path}': {e}")
|
||||
return None
|
||||
|
||||
def _normalize_text(self, text):
|
||||
if not isinstance(text, str): return ""
|
||||
return text.lower().strip()
|
||||
|
||||
def _generate_ai_examples(self):
|
||||
self.logger.info("Generiere KI-Beispiele aus der Wissensbasis...")
|
||||
if not self.exact_match_map:
|
||||
return
|
||||
titles_by_dept = defaultdict(list)
|
||||
for title, dept in self.exact_match_map.items():
|
||||
titles_by_dept[dept].append(title)
|
||||
example_lines = []
|
||||
sorted_depts = sorted(self.keyword_rules.keys(), key=lambda d: self.keyword_rules.get(d, {}).get('priority', 99))
|
||||
for dept in sorted_depts:
|
||||
if dept == DEFAULT_DEPARTMENT or not titles_by_dept[dept]:
|
||||
continue
|
||||
top_titles = sorted(titles_by_dept[dept], key=len)[:5]
|
||||
# --- KORREKTUR: Die fehlerhafte Zeile wurde ersetzt ---
|
||||
formatted_titles = ', '.join('"' + title + '"' for title in top_titles)
|
||||
example_lines.append(f"- Für '{dept}': {formatted_titles}")
|
||||
self.ai_example_prompt_part = "\n".join(example_lines)
|
||||
self.logger.debug(f"Generierter Beispiel-Prompt:\n{self.ai_example_prompt_part}")
|
||||
|
||||
def _find_best_match(self, job_title, company_branch):
|
||||
normalized_title = self._normalize_text(job_title)
|
||||
normalized_branch = self._normalize_text(company_branch)
|
||||
if not normalized_title: return DEFAULT_DEPARTMENT
|
||||
|
||||
exact_match = self.exact_match_map.get(normalized_title)
|
||||
if exact_match:
|
||||
rule = self.keyword_rules.get(exact_match, {})
|
||||
required_keywords = rule.get("required_branch_keywords")
|
||||
if required_keywords:
|
||||
if not any(keyword in normalized_branch for keyword in required_keywords):
|
||||
self.logger.debug(f"'{job_title}' -> Exakter Match '{exact_match}' verworfen (Branche: '{company_branch}')")
|
||||
else:
|
||||
self.logger.debug(f"'{job_title}' -> '{exact_match}' (Stufe 1, Branche OK)")
|
||||
return exact_match
|
||||
else:
|
||||
self.logger.debug(f"'{job_title}' -> '{exact_match}' (Stufe 1)")
|
||||
return exact_match
|
||||
|
||||
title_tokens = set(re.split(r'[\s/(),-]+', normalized_title))
|
||||
scores = {}
|
||||
for department, rules in self.keyword_rules.items():
|
||||
required_keywords = rules.get("required_branch_keywords")
|
||||
if required_keywords:
|
||||
if not any(keyword in normalized_branch for keyword in required_keywords):
|
||||
self.logger.debug(f"Dept '{department}' für '{job_title}' übersprungen (Branche: '{company_branch}')")
|
||||
continue
|
||||
matches = title_tokens.intersection(rules.get("keywords", []))
|
||||
if matches: scores[department] = len(matches)
|
||||
|
||||
if not scores:
|
||||
self.logger.debug(f"'{job_title}' -> '{DEFAULT_DEPARTMENT}' (Stufe 2: Keine passenden Keywords)")
|
||||
return DEFAULT_DEPARTMENT
|
||||
|
||||
max_score = max(scores.values())
|
||||
top_departments = [dept for dept, score in scores.items() if score == max_score]
|
||||
|
||||
if len(top_departments) == 1:
|
||||
winner = top_departments[0]
|
||||
self.logger.debug(f"'{job_title}' -> '{winner}' (Stufe 2: Score {max_score})")
|
||||
return winner
|
||||
|
||||
best_priority = float('inf')
|
||||
winner = top_departments[0]
|
||||
for department in top_departments:
|
||||
priority = self.keyword_rules.get(department, {}).get("priority", 99)
|
||||
if priority < best_priority:
|
||||
best_priority = priority
|
||||
winner = department
|
||||
|
||||
self.logger.debug(f"'{job_title}' -> '{winner}' (Stufe 2: Score {max_score}, Prio {best_priority})")
|
||||
return winner
|
||||
|
||||
def _get_ai_classification(self, contacts_to_classify):
|
||||
self.logger.info(f"Sende {len(contacts_to_classify)} Titel an KI (mit Kontext)...")
|
||||
if not contacts_to_classify: return {}
|
||||
valid_departments = sorted([dept for dept in self.keyword_rules.keys() if dept != DEFAULT_DEPARTMENT])
|
||||
prompt_parts = [
|
||||
"You are a specialized data processing tool. Your SOLE function is to receive a list of job titles and classify each one into a predefined department category.",
|
||||
"--- VALID DEPARTMENT CATEGORIES ---",
|
||||
", ".join(valid_departments),
|
||||
"\n--- EXAMPLES OF TYPICAL ROLES ---",
|
||||
self.ai_example_prompt_part,
|
||||
"\n--- RULES ---",
|
||||
"1. You MUST use the 'company_branch' to make a context-aware decision.",
|
||||
"2. For departments with branch requirements (like 'Baustofflogistik' for 'bau'), you MUST ONLY use them if the branch matches.",
|
||||
"3. Your response MUST be a single, valid JSON array of objects.",
|
||||
"4. Each object MUST contain the keys 'job_title' and 'department'.",
|
||||
"5. Your entire response MUST start with '[' and end with ']'.",
|
||||
"6. You MUST NOT add any introductory text, explanations, summaries, or markdown formatting like ```json.",
|
||||
"\n--- CONTACTS TO CLASSIFY (JSON) ---",
|
||||
json.dumps(contacts_to_classify, ensure_ascii=False)
|
||||
]
|
||||
prompt = "\n".join(prompt_parts)
|
||||
response_str = ""
|
||||
try:
|
||||
response_str = call_openai_chat(prompt, temperature=0.0, model="gpt-4o-mini", response_format_json=True)
|
||||
match = re.search(r'\[.*\]', response_str, re.DOTALL)
|
||||
if not match:
|
||||
self.logger.error("Kein JSON-Array in KI-Antwort gefunden.")
|
||||
self.logger.debug(f"ROH-ANTWORT DER API:\n{response_str}")
|
||||
return {}
|
||||
json_str = match.group(0)
|
||||
results_list = json.loads(json_str)
|
||||
classified_map = {item['job_title']: item['department'] for item in results_list if item.get('department') in valid_departments}
|
||||
self.logger.info(f"{len(classified_map)} Titel erfolgreich von KI klassifiziert.")
|
||||
return classified_map
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"Fehler beim Parsen des extrahierten JSON: {e}")
|
||||
self.logger.debug(f"EXTRAHIERTER JSON-STRING, DER FEHLER VERURSACHTE:\n{json_str}")
|
||||
return {}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Unerwarteter Fehler bei KI-Klassifizierung: {e}")
|
||||
return {}
|
||||
|
||||
def _append_learnings_to_source(self, gsh, new_mappings_df):
|
||||
if new_mappings_df.empty: return
|
||||
self.logger.info(f"Lern-Mechanismus: Hänge {len(new_mappings_df)} neue KI-Erkenntnisse an '{LEARNING_SOURCE_SHEET_NAME}' an...")
|
||||
rows_to_append = new_mappings_df[["Job Title", "Department"]].values.tolist()
|
||||
if not gsh.append_rows(LEARNING_SOURCE_SHEET_NAME, rows_to_append):
|
||||
self.logger.error("Fehler beim Anhängen der Lern-Daten.")
|
||||
|
||||
def process_contacts(self):
|
||||
self.logger.info("Starte Kontakt-Verarbeitung...")
|
||||
gsh = GoogleSheetHandler()
|
||||
df = gsh.get_sheet_as_dataframe(TARGET_SHEET_NAME)
|
||||
if df is None or df.empty:
|
||||
self.logger.warning(f"'{TARGET_SHEET_NAME}' ist leer. Nichts zu tun.")
|
||||
return
|
||||
self.logger.info(f"{len(df)} Zeilen aus '{TARGET_SHEET_NAME}' geladen.")
|
||||
df.columns = [col.strip() for col in df.columns]
|
||||
if "Job Title" not in df.columns or "Branche" not in df.columns:
|
||||
self.logger.critical(f"Benötigte Spalten 'Job Title' und/oder 'Branche' nicht gefunden. Abbruch.")
|
||||
return
|
||||
df['Original Job Title'] = df['Job Title']
|
||||
if "Department" not in df.columns: df["Department"] = ""
|
||||
self.logger.info("Starte regelbasierte Zuordnung (Stufe 1 & 2) mit Branchen-Kontext...")
|
||||
df['Department'] = df.apply(lambda row: self._find_best_match(row['Job Title'], row.get('Branche', '')), axis=1)
|
||||
self.logger.info("Regelbasierte Zuordnung abgeschlossen.")
|
||||
undefined_df = df[df['Department'] == DEFAULT_DEPARTMENT]
|
||||
if not undefined_df.empty:
|
||||
self.logger.info(f"{len(undefined_df)} Jobtitel konnten nicht zugeordnet werden. Starte Stufe 3 (KI).")
|
||||
contacts_to_classify = undefined_df[['Job Title', 'Branche']].drop_duplicates().to_dict('records')
|
||||
contacts_to_classify = [{'job_title': c['Job Title'], 'company_branch': c.get('Branche', '')} for c in contacts_to_classify]
|
||||
ai_results_map = {}
|
||||
contact_chunks = [contacts_to_classify[i:i + AI_BATCH_SIZE] for i in range(0, len(contacts_to_classify), AI_BATCH_SIZE)]
|
||||
self.logger.info(f"Teile KI-Anfrage in {len(contact_chunks)} Batches von max. {AI_BATCH_SIZE} Kontakten auf.")
|
||||
for i, chunk in enumerate(contact_chunks):
|
||||
self.logger.info(f"Verarbeite KI-Batch {i+1}/{len(contact_chunks)}...")
|
||||
chunk_results = self._get_ai_classification(chunk)
|
||||
ai_results_map.update(chunk_results)
|
||||
df['Department'] = df.apply(lambda row: ai_results_map.get(row['Job Title'], row['Department']) if row['Department'] == DEFAULT_DEPARTMENT else row['Department'], axis=1)
|
||||
new_learnings = [{'Job Title': title, 'Department': dept} for title, dept in ai_results_map.items()]
|
||||
if new_learnings:
|
||||
self._append_learnings_to_source(gsh, pd.DataFrame(new_learnings))
|
||||
else:
|
||||
self.logger.info("Alle Jobtitel durch Regeln zugeordnet. Stufe 3 wird übersprungen.")
|
||||
self.logger.info("--- Zuordnungs-Statistik ---")
|
||||
stats = df['Department'].value_counts()
|
||||
for department, count in stats.items(): self.logger.info(f"- {department}: {count} Zuordnungen")
|
||||
self.logger.info(f"GESAMT: {len(df)} Jobtitel verarbeitet.")
|
||||
output_df = df.drop(columns=['Original Job Title'])
|
||||
output_data = [output_df.columns.values.tolist()] + output_df.values.tolist()
|
||||
if gsh.clear_and_write_data(TARGET_SHEET_NAME, output_data):
|
||||
self.logger.info(f"Ergebnisse erfolgreich in '{TARGET_SHEET_NAME}' geschrieben.")
|
||||
else:
|
||||
self.logger.error("Fehler beim Zurückschreiben der Daten.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
setup_logging()
|
||||
logging.info(f"Starte contact_grouping.py v{__version__}")
|
||||
Config.load_api_keys()
|
||||
grouper = ContactGrouper()
|
||||
if not grouper.load_knowledge_base():
|
||||
logging.critical("Skript-Abbruch: Wissensbasis nicht geladen.")
|
||||
sys.exit(1)
|
||||
grouper.process_contacts()
|
||||
6531
ARCHIVE_legacy_scripts/_legacy_gsheets_system/data_processor.py
Normal file
6531
ARCHIVE_legacy_scripts/_legacy_gsheets_system/data_processor.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,161 @@
|
||||
# expand_knowledge_base.py
|
||||
|
||||
import os
|
||||
import yaml
|
||||
import logging
|
||||
import time
|
||||
import openai
|
||||
import argparse
|
||||
from config import Config
|
||||
|
||||
# --- Konfiguration ---
|
||||
BASE_KNOWLEDGE_FILE = "marketing_wissen.yaml"
|
||||
OUTPUT_FILE = "marketing_wissen_komplett.yaml"
|
||||
MODEL_TO_USE = "gpt-4o"
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
def call_openai_with_retry(prompt, is_extraction=False, max_retries=3, delay=5):
|
||||
# ... (Diese Funktion bleibt unverändert) ...
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
logging.info(f"Sende Prompt an OpenAI (Länge: {len(prompt)} Zeichen)...")
|
||||
response_format = {"type": "json_object"} if is_extraction else {"type": "text"}
|
||||
response = openai.ChatCompletion.create(
|
||||
model=MODEL_TO_USE,
|
||||
response_format=response_format,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.3,
|
||||
max_tokens=2048
|
||||
)
|
||||
content = response.choices[0].message['content'].strip()
|
||||
return content
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler bei OpenAI-API-Aufruf: {e}")
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(delay)
|
||||
else:
|
||||
return None
|
||||
|
||||
def generate_research_prompt(branch_name):
|
||||
# ... (Diese Funktion bleibt unverändert) ...
|
||||
return (
|
||||
f"Erstelle ein prägnantes Branchen-Dossier (ca. 300-400 Wörter) für: '{branch_name}'.\n"
|
||||
"Struktur des Dossiers:\n"
|
||||
"1. **Geschäftsmodelle & Field Service:** Beschreibe kurz die typischen Geschäftsmodelle und die zentrale Rolle des technischen Außendienstes (Field Service) in dieser Branche.\n"
|
||||
"2. **Herausforderungen & Trends:** Nenne die wichtigsten aktuellen Herausforderungen und Trends, die den Service-Bereich beeinflussen (z.B. Digitalisierung, Regularien, Fachkräftemangel).\n"
|
||||
"3. **Branchenspezifisches Wording:** Liste einige typische Fachbegriffe oder Abkürzungen auf, die im Service-Kontext dieser Branche üblich sind."
|
||||
)
|
||||
|
||||
def generate_extraction_prompt(dossier_content):
|
||||
"""Erstellt den Prompt, um die strukturierten Daten aus dem Dossier zu extrahieren."""
|
||||
return (
|
||||
"Du bist ein Branchenanalyst mit dem Spezialgebiet Field Service Management. Deine Aufgabe ist es, aus einem Branchen-Dossier die Kernaussagen zu extrahieren.\n"
|
||||
"Gib das Ergebnis ausschließlich als sauberes JSON-Objekt mit den Schlüsseln 'summary', 'pain_points' und 'key_terms' aus.\n\n"
|
||||
"WICHTIGE REGELN FÜR 'pain_points':\n"
|
||||
"- Extrahiere 5 **operative Schmerzpunkte, die direkt den technischen Außendienst betreffen**.\n"
|
||||
"- Formuliere sie als konkrete Probleme, die ein Service-Leiter lösen muss (z.B. 'Sicherstellung der Anlagenverfügbarkeit', 'Lückenlose Dokumentation für Audits').\n"
|
||||
"- Vermeide allgemeine Management-Themen wie 'Komplexität der Geschäftsmodelle' oder reine HR-Themen wie 'Fachkräftemangel'.\n\n"
|
||||
"--- DOSSIER ---\n"
|
||||
f"{dossier_content}"
|
||||
)
|
||||
|
||||
def main(branches_to_process=None):
|
||||
"""Erweitert die Wissensbasis um die fehlenden Branchen und speichert die Recherche-Dossiers."""
|
||||
logging.info("Starte Erweiterung der Wissensbasis...")
|
||||
|
||||
Config.load_api_keys()
|
||||
openai.api_key = Config.API_KEYS.get('openai')
|
||||
if not openai.api_key:
|
||||
logging.critical("OpenAI API Key nicht gefunden.")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(BASE_KNOWLEDGE_FILE, 'r', encoding='utf-8') as f:
|
||||
knowledge_base = yaml.safe_load(f)
|
||||
except FileNotFoundError:
|
||||
logging.critical(f"FEHLER: Basis-Wissensdatei '{BASE_KNOWLEDGE_FILE}' nicht gefunden.")
|
||||
return
|
||||
|
||||
all_branches = set(Config.BRANCH_GROUP_MAPPING.keys())
|
||||
existing_branches = set(knowledge_base.get('Branchen', {}).keys())
|
||||
|
||||
if branches_to_process:
|
||||
target_branches = [b for b in branches_to_process if b in all_branches]
|
||||
if not target_branches:
|
||||
logging.error("Keine der angegebenen Branchen ist gültig. Bitte prüfen Sie die Schreibweise.")
|
||||
logging.info(f"Gültige Branchen sind: {list(all_branches)}")
|
||||
return
|
||||
logging.info(f"Verarbeite die {len(target_branches)} explizit angegebenen Branchen...")
|
||||
else:
|
||||
target_branches = sorted(list(all_branches - existing_branches))
|
||||
if not target_branches:
|
||||
logging.info("Glückwunsch! Alle Branchen sind bereits in der Wissensbasis vorhanden.")
|
||||
return
|
||||
logging.info(f"Es werden {len(target_branches)} fehlende Branchen verarbeitet...")
|
||||
|
||||
logging.info(f"Zu verarbeitende Branchen: {', '.join(target_branches)}")
|
||||
|
||||
# KORRIGIERTE ZEILE
|
||||
DOSSIER_FOLDER = "industries"
|
||||
os.makedirs(DOSSIER_FOLDER, exist_ok=True)
|
||||
|
||||
for branch in target_branches:
|
||||
if not branches_to_process and branch in existing_branches:
|
||||
logging.debug(f"Branche '{branch}' bereits vorhanden, wird übersprungen.")
|
||||
continue
|
||||
|
||||
logging.info(f"\n--- Verarbeite Branche: {branch} ---")
|
||||
|
||||
logging.info(" -> Stufe 1: Generiere Recherche-Dossier...")
|
||||
research_prompt = generate_research_prompt(branch)
|
||||
dossier = call_openai_with_retry(research_prompt)
|
||||
if not dossier: continue
|
||||
|
||||
try:
|
||||
sanitized_branch_name = branch.replace('/', '-').replace('\\', '-')
|
||||
dossier_filepath = os.path.join(DOSSIER_FOLDER, f"{sanitized_branch_name}.txt")
|
||||
with open(dossier_filepath, 'w', encoding='utf-8') as f: f.write(dossier)
|
||||
logging.info(f" -> Dossier erfolgreich in '{dossier_filepath}' gespeichert.")
|
||||
except Exception as e:
|
||||
logging.error(f" -> Fehler beim Speichern des Dossiers für {branch}: {e}")
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
logging.info(" -> Stufe 2: Extrahiere strukturierte Daten aus dem Dossier...")
|
||||
extraction_prompt = generate_extraction_prompt(dossier)
|
||||
extracted_data_str = call_openai_with_retry(extraction_prompt, is_extraction=True)
|
||||
if not extracted_data_str: continue
|
||||
|
||||
try:
|
||||
if extracted_data_str.startswith("```"):
|
||||
extracted_data_str = extracted_data_str.split('\n', 1)[1].rsplit('```', 1)[0]
|
||||
|
||||
extracted_data = yaml.safe_load(extracted_data_str)
|
||||
extracted_data['references_DE'] = '[HIER DEUTSCHE REFERENZKUNDEN EINTRAGEN]'
|
||||
extracted_data['references_GB'] = '[HIER ENGLISCHE REFERENZKUNDEN EINTRAGEN]'
|
||||
knowledge_base['Branchen'][branch] = extracted_data
|
||||
logging.info(f" -> {branch} erfolgreich zur Wissensbasis hinzugefügt.")
|
||||
except Exception as e:
|
||||
logging.error(f" -> Fehler beim Parsen der extrahierten Daten für {branch}: {e}")
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
try:
|
||||
with open(OUTPUT_FILE, 'w', encoding='utf-8') as f:
|
||||
yaml.dump(knowledge_base, f, allow_unicode=True, sort_keys=False, width=120)
|
||||
logging.info(f"\nErfolgreich! Die aktualisierte Wissensbasis wurde in '{OUTPUT_FILE}' gespeichert.")
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Speichern der finalen YAML-Datei: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Erweitert die Marketing-Wissensbasis um fehlende Branchen.")
|
||||
parser.add_argument(
|
||||
"--branches",
|
||||
nargs='+',
|
||||
type=str,
|
||||
help="Eine oder mehrere spezifische Branchen, die verarbeitet werden sollen. Bei Angabe werden nur diese bearbeitet."
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
main(branches_to_process=args.branches)
|
||||
@@ -0,0 +1,189 @@
|
||||
# extract_insights.py
|
||||
|
||||
import os
|
||||
import yaml
|
||||
import logging
|
||||
import time
|
||||
import openai
|
||||
import docx # Die neue Bibliothek zur Verarbeitung von Word-Dokumenten
|
||||
from config import Config
|
||||
|
||||
# --- Konfiguration ---
|
||||
DOCS_SOURCE_FOLDER = "industry_docs" # Der Ordner, in dem Ihre .docx-Dateien liegen
|
||||
OUTPUT_FILE = "marketing_wissen_v1.yaml"
|
||||
MODEL_TO_USE = "gpt-4-turbo" # Empfohlen für komplexe Extraktionsaufgaben
|
||||
|
||||
# --- Logging einrichten ---
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
def call_openai_with_retry(prompt, max_retries=3, delay=5):
|
||||
"""Ruft die OpenAI API mit Retry-Logik auf."""
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
logging.info(f"Sende Prompt an OpenAI (Länge: {len(prompt)} Zeichen)...")
|
||||
response = openai.ChatCompletion.create(
|
||||
model=MODEL_TO_USE,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.2, # Niedrige Temperatur für präzise Extraktion
|
||||
max_tokens=1024
|
||||
)
|
||||
content = response.choices[0].message['content'].strip()
|
||||
return content
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler bei OpenAI-API-Aufruf: {e}")
|
||||
if attempt < max_retries - 1:
|
||||
logging.info(f"Warte {delay} Sekunden vor dem nächsten Versuch...")
|
||||
time.sleep(delay)
|
||||
else:
|
||||
logging.error("Maximale Anzahl an Wiederholungen erreicht.")
|
||||
return None
|
||||
|
||||
def read_docx_content(filepath):
|
||||
"""Liest den gesamten Textinhalt aus einer .docx-Datei, inklusive Tabellen."""
|
||||
try:
|
||||
doc = docx.Document(filepath)
|
||||
full_text = []
|
||||
for para in doc.paragraphs:
|
||||
full_text.append(para.text)
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
full_text.append(cell.text)
|
||||
return "\n".join(full_text)
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Lesen der DOCX-Datei {filepath}: {e}")
|
||||
return None
|
||||
|
||||
def extract_yaml_from_response(response_text):
|
||||
"""
|
||||
Extrahiert sauberen YAML-Code aus einer KI-Antwort,
|
||||
die Markdown-Codeblöcke enthalten kann.
|
||||
"""
|
||||
# Sucht nach dem Start des YAML-Codeblocks
|
||||
if '```yaml' in response_text:
|
||||
# Extrahiert den Teil nach dem ersten ```yaml
|
||||
parts = response_text.split('```yaml', 1)
|
||||
if len(parts) > 1:
|
||||
response_text = parts[1]
|
||||
|
||||
# Sucht nach dem Start eines generischen Codeblocks
|
||||
elif '```' in response_text:
|
||||
# Extrahiert den Teil nach dem ersten ```
|
||||
parts = response_text.split('```', 1)
|
||||
if len(parts) > 1:
|
||||
response_text = parts[1]
|
||||
|
||||
# Entfernt das Ende des Codeblocks
|
||||
if '```' in response_text:
|
||||
response_text = response_text.split('```')[0]
|
||||
|
||||
return response_text.strip()
|
||||
|
||||
|
||||
def generate_extraction_prompt(content, data_to_extract):
|
||||
"""Erstellt einen spezialisierten Prompt, um bestimmte Daten zu extrahieren."""
|
||||
prompts = {
|
||||
"pain_points": (
|
||||
"Du bist ein Branchenanalyst. Lies das folgende Dokument und extrahiere die 5 wichtigsten operativen "
|
||||
"Herausforderungen (Pain Points) für Unternehmen dieser Branche im Bereich Field Service. "
|
||||
"Formuliere sie als prägnante Stichpunkte.\n\n"
|
||||
"Gib das Ergebnis ausschließlich als YAML-Liste unter dem Schlüssel 'pain_points:' aus. KEINE weiteren Kommentare."
|
||||
),
|
||||
"key_terms": (
|
||||
"Du bist ein Fachlexikograf. Lies das folgende Dokument und extrahiere die 10 wichtigsten Fachbegriffe, "
|
||||
"Abkürzungen oder Normen, die im Kontext von Service, Wartung und Technik verwendet werden.\n\n"
|
||||
"Gib das Ergebnis ausschließlich als YAML-Liste unter dem Schlüssel 'key_terms:' aus."
|
||||
),
|
||||
"summary": (
|
||||
"Du bist ein Chefredakteur. Lies das folgende Dokument und verfasse eine prägnante Zusammenfassung (max. 3 Sätze) "
|
||||
"über die allgemeine Geschäftslage, die wichtigsten Trends und die Bedeutung des Field Service in dieser Branche.\n\n"
|
||||
"Gib das Ergebnis ausschließlich als einfachen Text unter dem YAML-Schlüssel 'summary:' aus."
|
||||
)
|
||||
}
|
||||
|
||||
if data_to_extract not in prompts:
|
||||
raise ValueError(f"Unbekannter Extraktionstyp: {data_to_extract}")
|
||||
|
||||
return f"{prompts[data_to_extract]}\n\n--- DOKUMENTENINHALT ---\n\n{content}"
|
||||
|
||||
|
||||
def main():
|
||||
"""Liest .docx-Dateien, extrahiert Wissen per KI und speichert es als YAML."""
|
||||
logging.info("Starte die KI-gestützte Extraktion von Branchen-Wissen...")
|
||||
|
||||
# API-Schlüssel laden
|
||||
Config.load_api_keys()
|
||||
openai.api_key = Config.API_KEYS.get('openai')
|
||||
if not openai.api_key:
|
||||
logging.critical("OpenAI API Key nicht in config.py gefunden. Skript wird beendet.")
|
||||
return
|
||||
|
||||
if not os.path.exists(DOCS_SOURCE_FOLDER):
|
||||
logging.critical(f"Der Quellordner '{DOCS_SOURCE_FOLDER}' wurde nicht gefunden. Bitte erstellen und die .docx-Dateien dort ablegen.")
|
||||
return
|
||||
|
||||
knowledge_base = {'Branchen': {}}
|
||||
|
||||
doc_files = [f for f in os.listdir(DOCS_SOURCE_FOLDER) if f.endswith('.docx')]
|
||||
logging.info(f"Gefundene Dokumente zur Verarbeitung: {', '.join(doc_files)}")
|
||||
|
||||
for filename in doc_files:
|
||||
# Extrahiere den Branchennamen aus dem Dateinamen
|
||||
# z.B. "Focus_insights_HVAC.docx" -> "Gebäudetechnik Heizung, Lüftung, Klima"
|
||||
# Dies muss manuell oder durch eine Mapping-Tabelle angepasst werden.
|
||||
# Für den Moment nehmen wir den Namen aus der Datei.
|
||||
base_name = os.path.splitext(filename)[0].replace("Focus_insights_", "")
|
||||
# Sie können hier ein Mapping zu den sauberen Namen aus Ihrer `config.py` einfügen.
|
||||
# Beispiel: branch_name = MAPPING.get(base_name, base_name)
|
||||
branch_name = base_name.replace("_", " ") # Einfache Normalisierung für den Start
|
||||
|
||||
logging.info(f"\n--- Verarbeite Branche: {branch_name} aus Datei {filename} ---")
|
||||
filepath = os.path.join(DOCS_SOURCE_FOLDER, filename)
|
||||
content = read_docx_content(filepath)
|
||||
|
||||
if not content:
|
||||
continue
|
||||
|
||||
branch_data = {
|
||||
'references_DE': '[HIER DEUTSCHE REFERENZKUNDEN EINTRAGEN]',
|
||||
'references_GB': '[HIER ENGLISCHE REFERENZKUNDEN EINTRAGEN]'
|
||||
}
|
||||
|
||||
# Extrahiere Pain Points, Key Terms und Summary
|
||||
for data_type in ["pain_points", "key_terms", "summary"]:
|
||||
logging.info(f" -> Extrahiere '{data_type}'...")
|
||||
prompt = generate_extraction_prompt(content, data_type)
|
||||
response_text = call_openai_with_retry(prompt)
|
||||
if response_text:
|
||||
try:
|
||||
# NEU: Erst den sauberen YAML-Teil extrahieren
|
||||
clean_yaml_text = extract_yaml_from_response(response_text)
|
||||
# Dann den sauberen Text parsen
|
||||
parsed_yaml = yaml.safe_load(clean_yaml_text)
|
||||
if parsed_yaml: # Sicherstellen, dass das Ergebnis nicht leer ist
|
||||
branch_data.update(parsed_yaml)
|
||||
else:
|
||||
raise ValueError("Geparsstes YAML ist leer.")
|
||||
except Exception as e:
|
||||
logging.error(f" Fehler beim Parsen der YAML-Antwort für '{data_type}': {e}")
|
||||
# Speichere die *gesamte* ursprüngliche Antwort für Debugging-Zwecke
|
||||
branch_data[data_type] = f"PARSING-FEHLER: {response_text}"
|
||||
time.sleep(2) # Pause zwischen API-Aufrufen
|
||||
|
||||
knowledge_base['Branchen'][branch_name] = branch_data
|
||||
|
||||
# Persona-Daten hinzufügen (diese sind statisch)
|
||||
# Hier können Sie die Persona-Daten aus der letzten Iteration einfügen.
|
||||
# ...
|
||||
|
||||
# Ergebnis in YAML-Datei speichern
|
||||
try:
|
||||
with open(OUTPUT_FILE, 'w', encoding='utf-8') as f:
|
||||
yaml.dump(knowledge_base, f, allow_unicode=True, sort_keys=False, width=120)
|
||||
logging.info(f"\nErfolgreich! Die Wissensbasis wurde in '{OUTPUT_FILE}' gespeichert.")
|
||||
logging.info("BITTE ÜBERPRÜFEN SIE DIESE DATEI UND PASSEN SIE SIE NACH BEDARF AN.")
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Speichern der YAML-Datei: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,149 @@
|
||||
# generate_knowledge_base.py
|
||||
|
||||
import os
|
||||
import yaml
|
||||
import logging
|
||||
import time
|
||||
import openai
|
||||
from config import Config # Wir nutzen die Config für den API-Schlüssel
|
||||
|
||||
# --- Konfiguration ---
|
||||
# HIER BITTE IHRE FOKUSBRANCHEN EINTRAGEN
|
||||
# Diese Namen sollten mit den Keys im BRANCH_GROUP_MAPPING aus config.py übereinstimmen.
|
||||
FOKUS_BRANCHEN = [
|
||||
"Medizintechnik",
|
||||
"Anlagenbau",
|
||||
"Facility Management",
|
||||
"Maschinenbau",
|
||||
"IT / Telekommunikation" # Beispiel, bitte anpassen
|
||||
]
|
||||
|
||||
POSITIONEN = {
|
||||
"IT": "IT-Leiter",
|
||||
"Management / GF / C-Level": "Geschäftsführer / C-Level",
|
||||
"Finanzen": "Finanzleiter / CFO",
|
||||
"Procurement / Einkauf": "Einkaufsleiter",
|
||||
"Field Service Management": "Leiter Kundenservice / Field Service"
|
||||
}
|
||||
|
||||
OUTPUT_FILE = "marketing_wissen_entwurf.yaml"
|
||||
|
||||
# Logging einrichten
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
def call_openai_with_retry(prompt, max_retries=3, delay=5):
|
||||
"""Ruft die OpenAI API mit Retry-Logik auf."""
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
logging.info(f"Sende Prompt an OpenAI (Versuch {attempt + 1}/{max_retries})...")
|
||||
response = openai.ChatCompletion.create(
|
||||
model="gpt-4-turbo", # Oder ein anderes Modell Ihrer Wahl
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.5,
|
||||
max_tokens=500
|
||||
)
|
||||
content = response.choices[0].message['content'].strip()
|
||||
return content
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler bei OpenAI-API-Aufruf: {e}")
|
||||
if attempt < max_retries - 1:
|
||||
logging.info(f"Warte {delay} Sekunden vor dem nächsten Versuch...")
|
||||
time.sleep(delay)
|
||||
else:
|
||||
logging.error("Maximale Anzahl an Wiederholungen erreicht. Breche ab.")
|
||||
return None
|
||||
|
||||
def generate_pain_points_prompt(branch_name):
|
||||
"""Erstellt den Prompt zur Generierung von Pain Points für eine Branche."""
|
||||
return "\n".join([
|
||||
"Du bist ein Top-Strategieberater mit Branchen-Expertise bei einer führenden Unternehmensberatung. Du analysierst die operativen Kernprozesse von Unternehmen und identifizierst die entscheidenden Hebel für Effizienzsteigerungen im Außendienst.",
|
||||
f"Branche: {branch_name}",
|
||||
"\n--- Denkprozess (Chain of Thought) ---",
|
||||
"1. Versetze dich in ein typisches Unternehmen dieser Branche.",
|
||||
"2. Was sind die häufigsten, sich wiederholenden Aufgaben, die mobile Techniker dort ausführen (z.B. Wartung, Reparatur, Installation, Inspektion)?",
|
||||
"3. Welche spezifischen Probleme und Engpässe treten bei der Planung und Durchführung DIESER Aufgaben auf? Denke an Regularien, Kundenanforderungen, technische Komplexität und wirtschaftlichen Druck.",
|
||||
"4. Formuliere aus diesen Problemen 5 prägnante, operative 'Pain Points', die sich auf den Service-Außendienst beziehen.",
|
||||
"\n--- Aufgabe ---",
|
||||
"Gib eine Liste von genau 5 Pain Points für die angegebene Branche aus. Formuliere sie als Herausforderungen aus Sicht des Unternehmens.",
|
||||
"Gib das Ergebnis ausschließlich als saubere YAML-Liste unter dem Schlüssel 'pain_points:' aus. KEINE weiteren Einleitungen oder Kommentare.",
|
||||
"\n--- Beispiel für den gewünschten Output-Stil (Branche: Aufzüge und Rolltreppen) ---",
|
||||
"""
|
||||
pain_points:
|
||||
- "Sicherstellung der gesetzlich vorgeschriebenen, regelmäßigen Sicherheitsüberprüfungen und deren lückenlose Dokumentation."
|
||||
- "Minimierung der Ausfallzeiten von Aufzügen in hochfrequentierten Gebäuden durch extrem schnelle Reaktionszeiten bei Störungen."
|
||||
- "Effiziente Routenplanung, um die Vielzahl an dezentral verteilten Anlagen mit minimalem Fahrtaufwand abzudecken."
|
||||
- "Bereitstellung von technischer Dokumentation und spezifischen Wartungsplänen für hunderte verschiedene Modelle direkt vor Ort."
|
||||
- "Management von Ersatzteilen und deren Verfügbarkeit im Servicefahrzeug."
|
||||
"""
|
||||
])
|
||||
|
||||
def generate_position_focus_prompt(position_name):
|
||||
"""Erstellt den Prompt zur Generierung des Fokus-Textes für eine Position."""
|
||||
return "\n".join([
|
||||
"Du bist ein erfahrener B2B-Vertriebs-Coach. Du formulierst Kernaussagen, die den spezifischen Blickwinkel unterschiedlicher Entscheidungsträger treffen.",
|
||||
f"Position: {position_name}",
|
||||
"\n--- Aufgabe ---",
|
||||
"Formuliere EINEN EINZIGEN Satz, der den typischen Fokus oder das Hauptinteresse dieser Position in Bezug auf die Optimierung von Serviceprozessen beschreibt.",
|
||||
"Dieser Satz wird später in einer E-Mail verwendet, beginnend mit 'Für Sie als...'. Formuliere den Satz so, dass er dort direkt passt.",
|
||||
"Beispiel für 'Geschäftsführer': 'stehen vermutlich die Steigerung der Effizienz, die Kundenzufriedenheit und die Skalierbarkeit Ihrer Serviceprozesse im Vordergrund.'",
|
||||
"Gib NUR den reinen Satz ohne Anführungszeichen oder einleitende Phrasen aus."
|
||||
])
|
||||
|
||||
|
||||
def main():
|
||||
"""Hauptfunktion zur Generierung der Wissensbasis."""
|
||||
logging.info("Starte die Generierung der Wissensbasis für Marketing-Texte...")
|
||||
|
||||
# API-Schlüssel laden
|
||||
Config.load_api_keys()
|
||||
openai.api_key = Config.API_KEYS.get('openai')
|
||||
if not openai.api_key:
|
||||
logging.critical("OpenAI API Key nicht in config.py gefunden. Skript wird beendet.")
|
||||
return
|
||||
|
||||
knowledge_base = {'Branchen': {}, 'Positionen': {}}
|
||||
|
||||
# 1. Pain Points für jede Fokusbranche generieren
|
||||
logging.info(f"Generiere Pain Points für {len(FOKUS_BRANCHEN)} Fokusbranchen...")
|
||||
for branch in FOKUS_BRANCHEN:
|
||||
logging.info(f"--- Verarbeite Branche: {branch} ---")
|
||||
prompt = generate_pain_points_prompt(branch)
|
||||
response_text = call_openai_with_retry(prompt)
|
||||
if response_text:
|
||||
try:
|
||||
# Versuche, den YAML-Teil zu parsen
|
||||
parsed_yaml = yaml.safe_load(response_text)
|
||||
knowledge_base['Branchen'][branch] = {
|
||||
'pain_points': parsed_yaml.get('pain_points', ['FEHLER: Konnte Pain Points nicht parsen.']),
|
||||
'references_DE': '[HIER DEUTSCHE REFERENZKUNDEN EINTRAGEN]',
|
||||
'references_GB': '[HIER ENGLISCHE REFERENZKUNDEN EINTRAGEN]'
|
||||
}
|
||||
except yaml.YAMLError as e:
|
||||
logging.error(f"Fehler beim Parsen der YAML-Antwort für {branch}: {e}")
|
||||
knowledge_base['Branchen'][branch] = {'pain_points': [f'PARSING-FEHLER: {response_text}']}
|
||||
time.sleep(2) # Kurze Pause, um Rate-Limits zu vermeiden
|
||||
|
||||
# 2. Fokus für jede Position generieren
|
||||
logging.info(f"\nGeneriere Fokus-Texte für {len(POSITIONEN)} Positionen...")
|
||||
for key, name in POSITIONEN.items():
|
||||
logging.info(f"--- Verarbeite Position: {name} ---")
|
||||
prompt = generate_position_focus_prompt(name)
|
||||
response_text = call_openai_with_retry(prompt)
|
||||
if response_text:
|
||||
knowledge_base['Positionen'][key] = {
|
||||
'focus_DE': response_text,
|
||||
'focus_GB': '[HIER ENGLISCHE ÜBERSETZUNG DES FOKUS-SATZES EINTRAGEN]'
|
||||
}
|
||||
time.sleep(2)
|
||||
|
||||
# 3. Ergebnis in YAML-Datei speichern
|
||||
try:
|
||||
with open(OUTPUT_FILE, 'w', encoding='utf-8') as f:
|
||||
yaml.dump(knowledge_base, f, allow_unicode=True, sort_keys=False, width=120)
|
||||
logging.info(f"\nErfolgreich! Die Wissensbasis wurde in '{OUTPUT_FILE}' gespeichert.")
|
||||
logging.info("BITTE ÜBERPRÜFEN SIE DIESE DATEI UND PASSEN SIE SIE NACH BEDARF AN.")
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Speichern der YAML-Datei: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,239 @@
|
||||
# generate_marketing_text.py
|
||||
|
||||
import os
|
||||
import yaml
|
||||
import logging
|
||||
import time
|
||||
import openai
|
||||
import json
|
||||
import pandas as pd
|
||||
import argparse
|
||||
from config import Config
|
||||
from helpers import create_log_filename # NEU: Logging-Funktion importieren
|
||||
from google_sheet_handler import GoogleSheetHandler
|
||||
|
||||
# --- Konfiguration ---
|
||||
KNOWLEDGE_BASE_FILE = "marketing_wissen_final.yaml"
|
||||
OUTPUT_SHEET_NAME = "Texte_Automation"
|
||||
MODEL_TO_USE = "gpt-4o"
|
||||
|
||||
# --- Logging einrichten ---
|
||||
# Wird jetzt in main() initialisiert, um einen Dateinamen zu haben
|
||||
|
||||
def call_openai_with_retry(prompt, max_retries=3, delay=5):
|
||||
# ... (Diese Funktion bleibt unverändert) ...
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
logging.info(f"Sende Prompt an OpenAI (Versuch {attempt + 1}/{max_retries})...")
|
||||
response = openai.ChatCompletion.create(
|
||||
model=MODEL_TO_USE,
|
||||
response_format={"type": "json_object"},
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.6,
|
||||
max_tokens=1024
|
||||
)
|
||||
content = response.choices[0].message['content'].strip()
|
||||
return json.loads(content)
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler bei OpenAI-API-Aufruf: {e}")
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(delay)
|
||||
else:
|
||||
return None
|
||||
|
||||
def build_prompt(branch_name, branch_data, position_name, position_data):
|
||||
"""
|
||||
Baut den finalen Master-Prompt (v4.3) dynamisch zusammen.
|
||||
Nutzt eine Fallback-Logik, wenn keine branchenspezifischen Referenzen vorhanden sind.
|
||||
"""
|
||||
branch_pain_points = "\n".join([f"- {p}" for p in branch_data.get('pain_points', [])])
|
||||
position_pain_points = "\n".join([f"- {p}" for p in position_data.get('pains_DE', [])])
|
||||
|
||||
# --- Dynamischer Teil: Referenzen und Expertise-Formulierung ---
|
||||
specific_references = branch_data.get('references_DE')
|
||||
|
||||
# Prüfen, ob echte Referenzen vorhanden sind (nicht leer und nicht der Platzhalter)
|
||||
if specific_references and '[HIER' not in specific_references:
|
||||
references_for_prompt = specific_references
|
||||
expertise_instruction = (
|
||||
"- **Satz 2 (Branchen-Expertise):** Betone unsere Erfahrung in der Branche. **Vermeide das Wort 'Branche'.** "
|
||||
f"Formuliere stattdessen spezifisch, z.B. 'Durch die Zusammenarbeit sind wir mit den spezifischen Anforderungen von {branch_name}-Unternehmen bestens vertraut.'"
|
||||
)
|
||||
else:
|
||||
# Fallback-Logik
|
||||
references_for_prompt = ", ".join(Config.FALLBACK_REFERENCES)
|
||||
expertise_instruction = (
|
||||
"- **Satz 2 (Branchen-Expertise):** Formuliere allgemeiner. Betone unsere branchenübergreifende Expertise in der Optimierung komplexer Serviceprozesse. "
|
||||
"Formuliere z.B. 'Unsere Erfahrung zeigt, dass die grundlegenden Herausforderungen in der Einsatzplanung oft branchenübergreifend ähnlich sind.'"
|
||||
)
|
||||
|
||||
# --- Zusammensetzen des finalen Prompts ---
|
||||
return "\n".join([
|
||||
"Du bist ein kompetenter Lösungsberater und brillanter Texter...", # Gekürzt zur Übersicht
|
||||
"AUFGABE: Erstelle 3 Textblöcke (Subject, Introduction_Textonly, Industry_References_Textonly) für eine E-Mail.",
|
||||
|
||||
"\n--- KONTEXT ---",
|
||||
f"ZIELBRANCHE: {branch_name}",
|
||||
f"BRANCHEN-HERAUSFORDERUNGEN (PAIN POINTS):\n{branch_pain_points}",
|
||||
f"\nANSPRECHPARTNER: {position_name}",
|
||||
f"PERSÖNLICHE HERAUSFORDERUNGEN DES ANSPRECHPARTNERS (PAIN POINTS):\n{position_pain_points}",
|
||||
f"\nREFERENZKUNDEN (Rohdaten):\n{references_for_prompt}",
|
||||
|
||||
"\n--- DEINE AUFGABE ---",
|
||||
"1. **Subject:** Formuliere eine kurze Betreffzeile (max. 5 Wörter). Richte sie **direkt an einem der persönlichen Pain Points** des Ansprechpartners.",
|
||||
"2. **Introduction_Textonly:** Formuliere einen Einleitungstext (2 Sätze).",
|
||||
" - **Satz 1 (Die Brücke):** Knüpfe an die (uns unbekannte) operative Herausforderung an. Beschreibe subtil den Nutzen einer Lösung...",
|
||||
" - **Satz 2 (Die Relevanz):** Schaffe die Relevanz für die Zielperson, indem du das Thema mit einem ihrer persönlichen Pain Points verknüpfst.",
|
||||
"3. **Industry_References_Textonly:** Formuliere einen **strategischen Referenz-Block (ca. 2-3 Sätze)** nach folgendem Muster:",
|
||||
" - **Satz 1 (Social Proof):** Beginne direkt mit den Referenzkunden. Integriere **alle** genannten Referenzen und quantitative Erfolge elegant.",
|
||||
expertise_instruction, # HIER WIRD DIE DYNAMISCHE ANWEISUNG EINGEFÜGT
|
||||
" - **Satz 3 (Rollen-Relevanz):** Schaffe den direkten Nutzen für die Zielperson. Formuliere z.B. 'Dieser Wissensvorsprung hilft uns, Ihre [persönlicher Pain Point der Rolle] besonders effizient zu lösen.'",
|
||||
|
||||
"\n--- BEISPIEL FÜR EINEN PERFEKTEN OUTPUT (MIT SPEZIFISCHEN REFERENZEN) ---",
|
||||
'''
|
||||
{
|
||||
"Subject": "Nahtlose Systemintegration",
|
||||
"Introduction_Textonly": "Genau hier setzt die digitale Unterstützung Ihrer Techniker an... Für Sie als IT-Leiter ist dabei die nahtlose und sichere Integration... von entscheidender Bedeutung.",
|
||||
"Industry_References_Textonly": "Ihre Marktbegleiter wie Jungheinrich mit weltweit über 4.000 Technikern und Christ Wash Systems... profitieren bereits... Durch die langjährige Zusammenarbeit sind wir mit den spezifischen Anforderungen von Anlagenbau-Unternehmen... bestens vertraut. Dieser Wissensvorsprung hilft uns, Ihre Integrations-Herausforderungen... zu lösen."
|
||||
}
|
||||
''',
|
||||
"\n--- BEISPIEL FÜR EINEN PERFEKTEN OUTPUT (MIT FALLBACK-REFERENZEN) ---",
|
||||
'''
|
||||
{
|
||||
"Subject": "Kostenkontrolle im Service",
|
||||
"Introduction_Textonly": "Genau bei der Optimierung dieser Serviceprozesse können erhebliche Effizienzgewinne erzielt werden. Für Sie als Finanzleiter ist dabei die Sicherstellung der Profitabilität bei gleichzeitiger Kostentransparenz von zentraler Bedeutung.",
|
||||
"Industry_References_Textonly": "Namhafte Unternehmen wie Jungheinrich, Vivawest und TK Elevators profitieren bereits von unseren Lösungen. Unsere Erfahrung zeigt, dass die grundlegenden Herausforderungen in der Einsatzplanung oft branchenübergreifend ähnlich sind. Dieser Wissensvorsprung hilft uns, Ihre Ziele bei der Kostenkontrolle und Profitabilitätssteigerung besonders effizient zu unterstützen."
|
||||
}
|
||||
''',
|
||||
"\nErstelle jetzt das JSON-Objekt für die oben genannte Kombination aus Branche und Ansprechpartner."
|
||||
])
|
||||
|
||||
|
||||
def main(specific_branch=None):
|
||||
"""Hauptfunktion zur Generierung der Marketing-Texte."""
|
||||
|
||||
# --- NEUES, ROBUSTES LOGGING SETUP ---
|
||||
log_file_path = create_log_filename("generate_texts")
|
||||
log_level = logging.INFO
|
||||
log_format = '%(asctime)s - %(levelname)-8s - %(name)-25s - %(message)s'
|
||||
|
||||
# Root-Logger konfigurieren
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(log_level)
|
||||
|
||||
# Bestehende Handler entfernen, um Dopplung zu vermeiden
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
|
||||
# Neue Handler hinzufügen
|
||||
root_logger.addHandler(logging.StreamHandler()) # Immer auf der Konsole loggen
|
||||
if log_file_path:
|
||||
file_handler = logging.FileHandler(log_file_path, mode='a', encoding='utf-8')
|
||||
file_handler.setFormatter(logging.Formatter(log_format))
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
logging.info(f"===== Skript gestartet: Modus 'generate_texts' =====")
|
||||
logging.info(f"Logdatei: {log_file_path}")
|
||||
|
||||
# --- Initialisierung ---
|
||||
try:
|
||||
Config.load_api_keys()
|
||||
openai.api_key = Config.API_KEYS.get('openai')
|
||||
if not openai.api_key: raise ValueError("OpenAI API Key nicht gefunden.")
|
||||
|
||||
with open(KNOWLEDGE_BASE_FILE, 'r', encoding='utf-8') as f:
|
||||
knowledge_base = yaml.safe_load(f)
|
||||
|
||||
sheet_handler = GoogleSheetHandler()
|
||||
except Exception as e:
|
||||
logging.critical(f"FEHLER bei der Initialisierung: {e}")
|
||||
return
|
||||
|
||||
# --- NEU: Bestehende Texte aus dem Sheet laden ---
|
||||
try:
|
||||
logging.info(f"Lese bestehende Texte aus dem Tabellenblatt '{OUTPUT_SHEET_NAME}'...")
|
||||
existing_texts_df = sheet_handler.get_sheet_as_dataframe(OUTPUT_SHEET_NAME)
|
||||
if existing_texts_df is not None and not existing_texts_df.empty:
|
||||
existing_combinations = set(zip(existing_texts_df['Branch Detail'], existing_texts_df['Department']))
|
||||
logging.info(f"{len(existing_combinations)} bereits existierende Kombinationen gefunden.")
|
||||
else:
|
||||
existing_combinations = set()
|
||||
logging.info("Keine bestehenden Texte gefunden. Alle Kombinationen werden neu erstellt.")
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Lesen des '{OUTPUT_SHEET_NAME}'-Sheets. Nehme an, es ist leer. Fehler: {e}")
|
||||
existing_combinations = set()
|
||||
|
||||
# --- Generierungs-Loop ---
|
||||
newly_generated_results = []
|
||||
|
||||
target_branches = knowledge_base.get('Branchen', {})
|
||||
if specific_branch:
|
||||
# ... (Logik für specific_branch bleibt gleich) ...
|
||||
if specific_branch in target_branches:
|
||||
target_branches = {specific_branch: target_branches[specific_branch]}
|
||||
else:
|
||||
logging.error(f"FEHLER: Die angegebene Branche '{specific_branch}' wurde nicht gefunden.")
|
||||
return
|
||||
|
||||
positions = knowledge_base.get('Positionen', {})
|
||||
|
||||
total_combinations = len(target_branches) * len(positions)
|
||||
logging.info(f"Prüfe {total_combinations} mögliche Kombinationen...")
|
||||
|
||||
for branch_name, branch_data in target_branches.items():
|
||||
for position_key, position_data in positions.items():
|
||||
|
||||
# NEU: Überspringe, wenn die Kombination bereits existiert
|
||||
if (branch_name, position_key) in existing_combinations:
|
||||
logging.debug(f"Überspringe bereits existierende Kombination: Branche='{branch_name}', Position='{position_key}'")
|
||||
continue
|
||||
|
||||
logging.info(f"--- Generiere Texte für NEUE Kombination: Branche='{branch_name}', Position='{position_key}' ---")
|
||||
|
||||
prompt = build_prompt(branch_name, branch_data, position_data.get('name_DE', position_key), position_data)
|
||||
generated_json = call_openai_with_retry(prompt)
|
||||
|
||||
if generated_json:
|
||||
newly_generated_results.append({
|
||||
'Branch Detail': branch_name,
|
||||
'Department': position_key,
|
||||
'Language': 'DE',
|
||||
'Subject': generated_json.get('Subject', 'FEHLER'),
|
||||
'Introduction_Textonly': generated_json.get('Introduction_Textonly', 'FEHLER'),
|
||||
'Industry References (Text only)': generated_json.get('Industry_References_Textonly', 'FEHLER')
|
||||
})
|
||||
else:
|
||||
# Füge einen Fehler-Eintrag hinzu, um zu sehen, was fehlgeschlagen ist
|
||||
newly_generated_results.append({
|
||||
'Branch Detail': branch_name,
|
||||
'Department': position_key,
|
||||
'Language': 'DE',
|
||||
'Subject': 'FEHLER: KI-Antwort war ungültig',
|
||||
'Introduction_Textonly': 'FEHLER: KI-Antwort war ungültig',
|
||||
'Industry References (Text only)': 'FEHLER: KI-Antwort war ungültig'
|
||||
})
|
||||
time.sleep(2)
|
||||
|
||||
# --- NEU: Hänge neue Ergebnisse an das Sheet an ---
|
||||
if newly_generated_results:
|
||||
logging.info(f"{len(newly_generated_results)} neue Textvarianten wurden generiert.")
|
||||
df_new = pd.DataFrame(newly_generated_results)
|
||||
|
||||
# Konvertiere in die Liste-von-Listen-Struktur
|
||||
values_to_append = df_new.values.tolist()
|
||||
|
||||
success = sheet_handler.append_rows(OUTPUT_SHEET_NAME, values_to_append)
|
||||
|
||||
if success:
|
||||
logging.info(f"Erfolgreich! {len(values_to_append)} neue Textvarianten wurden an das Google Sheet '{OUTPUT_SHEET_NAME}' angehängt.")
|
||||
else:
|
||||
logging.error("Fehler! Die neuen Textvarianten konnten nicht an das Google Sheet angehängt werden.")
|
||||
else:
|
||||
logging.info("Keine neuen Textvarianten zu generieren. Das Sheet ist auf dem neuesten Stand.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Generiert Marketing-Textblöcke basierend auf der Wissensbasis.")
|
||||
parser.add_argument("--branch", type=str, help="Generiert Texte nur für diese eine Branche.")
|
||||
args = parser.parse_args()
|
||||
|
||||
main(specific_branch=args.branch)
|
||||
@@ -0,0 +1,154 @@
|
||||
# google_sheet_handler.py
|
||||
|
||||
__version__ = "v2.0.1"
|
||||
|
||||
import os
|
||||
import logging
|
||||
import gspread
|
||||
import pandas as pd
|
||||
from oauth2client.service_account import ServiceAccountCredentials
|
||||
from config import Config, COLUMN_MAP, CREDENTIALS_FILE
|
||||
from helpers import retry_on_failure, _get_col_letter
|
||||
|
||||
class GoogleSheetHandler:
|
||||
"""
|
||||
Kapselt alle Interaktionen mit dem Google Sheet.
|
||||
Finale, robuste Version v2.1.2
|
||||
"""
|
||||
def __init__(self, sheet_url=None):
|
||||
self.logger = logging.getLogger(__name__ + ".GoogleSheetHandler")
|
||||
self.logger.info("Initialisiere GoogleSheetHandler...")
|
||||
self.sheet_url = sheet_url or Config.SHEET_URL
|
||||
if "docs.google.com" not in self.sheet_url:
|
||||
raise ValueError(f"Ungültige Google Sheet URL: '{self.sheet_url}'")
|
||||
self.client = None
|
||||
self.sheet = None
|
||||
self._all_data_with_headers = []
|
||||
self._header_rows = 5
|
||||
|
||||
@retry_on_failure
|
||||
def _connect(self):
|
||||
if self.client: return True
|
||||
self.logger.info("Stelle neue Verbindung mit Google Sheets her...")
|
||||
try:
|
||||
if not os.path.exists(CREDENTIALS_FILE):
|
||||
raise FileNotFoundError(f"Credential-Datei nicht gefunden: {CREDENTIALS_FILE}")
|
||||
creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, ["https://www.googleapis.com/auth/spreadsheets"])
|
||||
self.client = gspread.authorize(creds)
|
||||
spreadsheet = self.client.open_by_url(self.sheet_url)
|
||||
self.sheet = spreadsheet.sheet1
|
||||
self.logger.info("Verbindung erfolgreich.")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"FEHLER bei Google Sheets Verbindung: {e}")
|
||||
self.client = None
|
||||
return False
|
||||
|
||||
@retry_on_failure
|
||||
def load_data(self):
|
||||
if not self.client and not self._connect(): return False
|
||||
self.logger.info("Lade Daten aus dem Haupt-Sheet ('Tabelle1')...")
|
||||
try:
|
||||
self._all_data_with_headers = self.sheet.get_all_values()
|
||||
self.logger.info(f"Daten geladen: {len(self._all_data_with_headers)} Zeilen.")
|
||||
for i, row in enumerate(self._all_data_with_headers):
|
||||
if "CRM Name" in row:
|
||||
self._header_rows = i + 1
|
||||
break
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.critical(f"Fehler beim Laden der Sheet Daten: {e}")
|
||||
return False
|
||||
|
||||
def get_all_data_with_headers(self):
|
||||
return self._all_data_with_headers.copy()
|
||||
|
||||
def get_sheet_as_dataframe(self, sheet_name):
|
||||
"""
|
||||
Liest ein komplettes Tabellenblatt und gibt es als Pandas DataFrame zurück.
|
||||
NEU: Funktioniert auch, wenn die Header-Zeile doppelte Spaltennamen enthält.
|
||||
"""
|
||||
try:
|
||||
if not self.client and not self._connect(): return None
|
||||
|
||||
self.logger.debug(f"Lese Tabellenblatt '{sheet_name}' als DataFrame...")
|
||||
worksheet = self.client.open_by_url(self.sheet_url).worksheet(sheet_name)
|
||||
|
||||
# Lese alle Werte als Liste von Listen, das ist robuster
|
||||
all_values = worksheet.get_all_values()
|
||||
|
||||
if not all_values:
|
||||
self.logger.warning(f"Tabellenblatt '{sheet_name}' ist leer. Erstelle leeren DataFrame.")
|
||||
return pd.DataFrame()
|
||||
|
||||
# Nimm die erste Zeile als Header und die restlichen als Daten
|
||||
header = all_values[0]
|
||||
data = all_values[1:]
|
||||
|
||||
df = pd.DataFrame(data, columns=header)
|
||||
self.logger.info(f"{len(df)} Zeilen aus '{sheet_name}' als DataFrame geladen.")
|
||||
return df
|
||||
except gspread.exceptions.WorksheetNotFound:
|
||||
self.logger.warning(f"Tabellenblatt '{sheet_name}' nicht gefunden. Erstelle leeren DataFrame.")
|
||||
return pd.DataFrame()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Fehler beim Lesen des Sheets '{sheet_name}' als DataFrame: {e}")
|
||||
return None
|
||||
|
||||
def append_rows(self, sheet_name, values):
|
||||
try:
|
||||
if not self.client and not self._connect(): return False
|
||||
worksheet = self.client.open_by_url(self.sheet_url).worksheet(sheet_name)
|
||||
worksheet.append_rows(values, value_input_option='USER_ENTERED')
|
||||
self.logger.info(f"{len(values)} Zeilen erfolgreich an '{sheet_name}' angehängt.")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Fehler beim Anhängen von Zeilen an das Sheet '{sheet_name}': {e}")
|
||||
return False
|
||||
|
||||
def clear_and_write_data(self, sheet_name, data):
|
||||
try:
|
||||
if not self.client and not self._connect(): return False
|
||||
worksheet = self.client.open_by_url(self.sheet_url).worksheet(sheet_name)
|
||||
worksheet.clear()
|
||||
if not data:
|
||||
self.logger.warning("Keine Daten zum Schreiben in '{sheet_name}' vorhanden.")
|
||||
return True
|
||||
end_col_letter = _get_col_letter(len(data[0]))
|
||||
range_to_update = f'A1:{end_col_letter}{len(data)}'
|
||||
worksheet.update(range_name=range_to_update, values=data)
|
||||
self.logger.info(f"Schreiben von {len(data)} Zeilen in '{sheet_name}' erfolgreich.")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Fehler bei clear_and_write_data für '{sheet_name}': {e}")
|
||||
return False
|
||||
|
||||
def batch_update_cells(self, update_data):
|
||||
if not self.sheet and not self._connect():
|
||||
self.logger.error("FEHLER: Keine Sheet-Verbindung fuer Batch-Update.")
|
||||
return False
|
||||
if not update_data:
|
||||
return True
|
||||
|
||||
sanitized_update_data = []
|
||||
for item in update_data:
|
||||
if 'range' in item and 'values' in item and isinstance(item['values'], list):
|
||||
sanitized_values = [[str(cell) if cell is not None else "" for cell in row] for row in item['values']]
|
||||
sanitized_update_data.append({'range': item['range'], 'values': sanitized_values})
|
||||
|
||||
if not sanitized_update_data: return True
|
||||
|
||||
total_cells = sum(len(row) for item in sanitized_update_data for row in item.get('values', []))
|
||||
self.logger.debug(f"Sende Batch-Update mit {len(sanitized_update_data)} Anfragen ({total_cells} Zellen)...")
|
||||
self.sheet.batch_update(sanitized_update_data, value_input_option='USER_ENTERED')
|
||||
self.logger.info(f"Batch-Update mit {total_cells} Zellen erfolgreich gesendet.")
|
||||
return True
|
||||
|
||||
def get_main_sheet_name(self):
|
||||
"""
|
||||
Stellt eine Verbindung sicher und gibt den Namen des Haupt-Tabellenblatts zurück.
|
||||
"""
|
||||
if not self.sheet and not self._connect():
|
||||
self.logger.error("FEHLER: Kann Sheet-Namen nicht abrufen, da keine Verbindung besteht.")
|
||||
return None
|
||||
return self.sheet.title
|
||||
412
ARCHIVE_legacy_scripts/_legacy_gsheets_system/helpers.py
Normal file
412
ARCHIVE_legacy_scripts/_legacy_gsheets_system/helpers.py
Normal file
@@ -0,0 +1,412 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
helpers.py
|
||||
|
||||
Sammlung von globalen, wiederverwendbaren Hilfsfunktionen für das Projekt
|
||||
"Automatisierte Unternehmensbewertung". Enthält Decorators, Text-Normalisierung,
|
||||
API-Wrapper und andere Dienstprogramme.
|
||||
"""
|
||||
|
||||
__version__ = "v2.4.0_Final_Fix"
|
||||
|
||||
ALLOWED_TARGET_BRANCHES = []
|
||||
|
||||
# ==============================================================================
|
||||
# 1. IMPORTS
|
||||
# ==============================================================================
|
||||
# Standardbibliotheken
|
||||
import os
|
||||
import time
|
||||
import re
|
||||
import csv
|
||||
import json
|
||||
import random
|
||||
import logging
|
||||
import traceback
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse, unquote
|
||||
from difflib import SequenceMatcher
|
||||
import base64
|
||||
import sys
|
||||
|
||||
# Externe Bibliotheken
|
||||
try:
|
||||
import gspread
|
||||
GSPREAD_AVAILABLE = True
|
||||
except ImportError:
|
||||
GSPREAD_AVAILABLE = False
|
||||
gspread = None
|
||||
try:
|
||||
import wikipedia
|
||||
WIKIPEDIA_AVAILABLE = True
|
||||
except ImportError:
|
||||
WIKIPEDIA_AVAILABLE = False
|
||||
wikipedia = None
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
try:
|
||||
import pandas as pd
|
||||
PANDAS_AVAILABLE = True
|
||||
except Exception as e:
|
||||
logging.warning(f"Pandas import failed: {e}")
|
||||
PANDAS_AVAILABLE = False
|
||||
pd = None
|
||||
|
||||
# --- KI UMSCHALTUNG: Google Generative AI (Dual Support) ---
|
||||
HAS_NEW_GENAI = False
|
||||
HAS_OLD_GENAI = False
|
||||
|
||||
# 1. Neue Bibliothek (google-genai)
|
||||
try:
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
HAS_NEW_GENAI = True
|
||||
logging.info("Bibliothek 'google.genai' (v1.0+) geladen.")
|
||||
except ImportError:
|
||||
logging.warning("Bibliothek 'google.genai' nicht gefunden. Versuche Fallback.")
|
||||
|
||||
# 2. Alte Bibliothek (google-generativeai)
|
||||
try:
|
||||
import google.generativeai as old_genai
|
||||
HAS_OLD_GENAI = True
|
||||
logging.info("Bibliothek 'google.generativeai' (Legacy) geladen.")
|
||||
except ImportError:
|
||||
logging.warning("Bibliothek 'google.generativeai' nicht gefunden.")
|
||||
|
||||
HAS_GEMINI = HAS_NEW_GENAI or HAS_OLD_GENAI
|
||||
|
||||
# OpenAI Imports (Legacy)
|
||||
try:
|
||||
import openai
|
||||
from openai.error import AuthenticationError, OpenAIError, RateLimitError, APIError, Timeout, InvalidRequestError, ServiceUnavailableError
|
||||
OPENAI_AVAILABLE = True
|
||||
except ImportError:
|
||||
OPENAI_AVAILABLE = False
|
||||
class AuthenticationError(Exception): pass
|
||||
class OpenAIError(Exception): pass
|
||||
class RateLimitError(Exception): pass
|
||||
class APIError(Exception): pass
|
||||
class Timeout(Exception): pass
|
||||
class InvalidRequestError(Exception): pass
|
||||
class ServiceUnavailableError(Exception): pass
|
||||
|
||||
from config import (Config, BRANCH_MAPPING_FILE, URL_CHECK_MARKER, USER_AGENTS, LOG_DIR)
|
||||
from config import Config, COLUMN_MAP, COLUMN_ORDER
|
||||
|
||||
# Optionale Bibliotheken
|
||||
try:
|
||||
import tiktoken
|
||||
except ImportError:
|
||||
tiktoken = None
|
||||
|
||||
gender = None
|
||||
gender_detector = None
|
||||
|
||||
def get_col_idx(key):
|
||||
try:
|
||||
return COLUMN_ORDER.index(key)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# ==============================================================================
|
||||
# 2. RETRY DECORATOR
|
||||
# ==============================================================================
|
||||
decorator_logger = logging.getLogger(__name__ + ".Retry")
|
||||
|
||||
def retry_on_failure(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
func_name = func.__name__
|
||||
self_arg = args[0] if args and hasattr(args[0], func_name) and isinstance(args[0], object) else None
|
||||
effective_func_name = f"{self_arg.__class__.__name__}.{func_name}" if self_arg else func_name
|
||||
|
||||
max_retries_config = getattr(Config, 'MAX_RETRIES', 3)
|
||||
base_delay = getattr(Config, 'RETRY_DELAY', 5)
|
||||
|
||||
if max_retries_config <= 0:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
for attempt in range(max_retries_config):
|
||||
try:
|
||||
if attempt > 0:
|
||||
decorator_logger.warning(f"Wiederhole Versuch {attempt + 1}/{max_retries_config} fuer '{effective_func_name}'...")
|
||||
return func(*args, **kwargs)
|
||||
|
||||
except Exception as e:
|
||||
permanent_errors = [ValueError]
|
||||
if GSPREAD_AVAILABLE:
|
||||
permanent_errors.append(gspread.exceptions.SpreadsheetNotFound)
|
||||
|
||||
if any(isinstance(e, error_type) for error_type in permanent_errors):
|
||||
raise e
|
||||
|
||||
if attempt < max_retries_config - 1:
|
||||
wait_time = base_delay * (2 ** attempt) + random.uniform(0, 1)
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
raise e
|
||||
raise RuntimeError(f"Retry loop error for {effective_func_name}")
|
||||
|
||||
return wrapper
|
||||
|
||||
# ==============================================================================
|
||||
# 3. LOGGING & UTILS
|
||||
# ==============================================================================
|
||||
|
||||
def token_count(text, model=None):
|
||||
if not text or not isinstance(text, str): return 0
|
||||
return len(str(text).split())
|
||||
|
||||
def log_module_versions(modules_to_log):
|
||||
pass
|
||||
|
||||
def create_log_filename(mode):
|
||||
try:
|
||||
now = datetime.now().strftime("%Y-%m-%d_%H-%M")
|
||||
ver_short = getattr(Config, 'VERSION', 'unknown').replace(".", "")
|
||||
return os.path.join(LOG_DIR, f"{now}_{ver_short}_Modus-{mode}.txt")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ==============================================================================
|
||||
# 4. TEXT, STRING & URL UTILITIES
|
||||
# ==============================================================================
|
||||
def simple_normalize_url(url): return url if url else "k.A."
|
||||
def normalize_string(s): return s
|
||||
def clean_text(text): return str(text).strip() if text else "k.A."
|
||||
def normalize_company_name(name): return name.lower().strip() if name else ""
|
||||
def _get_col_letter(col_num): return ""
|
||||
def fuzzy_similarity(str1, str2): return 0.0
|
||||
def extract_numeric_value(raw_value, is_umsatz=False): return "k.A."
|
||||
def get_numeric_filter_value(value_str, is_umsatz=False): return 0.0
|
||||
@retry_on_failure
|
||||
def _call_genderize_api(name, api_key): return {}
|
||||
def get_gender(firstname): return "unknown"
|
||||
def get_email_address(firstname, lastname, website): return ""
|
||||
|
||||
# ==============================================================================
|
||||
# 8. GEMINI API WRAPPERS
|
||||
# ==============================================================================
|
||||
|
||||
def _get_gemini_api_key():
|
||||
api_key = Config.API_KEYS.get('gemini') or Config.API_KEYS.get('openai')
|
||||
if api_key: return api_key
|
||||
api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
||||
if api_key: return api_key
|
||||
raise ValueError("API Key missing.")
|
||||
|
||||
@retry_on_failure
|
||||
def call_gemini_flash(prompt, system_instruction=None, temperature=0.3, json_mode=False):
|
||||
"""
|
||||
Ruft Gemini auf (Text). Nutzt gemini-2.0-flash als Standard.
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
api_key = _get_gemini_api_key()
|
||||
|
||||
# Priorität 1: Alte Bibliothek (bewährt für Text in diesem Setup)
|
||||
if HAS_OLD_GENAI:
|
||||
try:
|
||||
old_genai.configure(api_key=api_key)
|
||||
generation_config = {
|
||||
"temperature": temperature,
|
||||
"top_p": 0.95,
|
||||
"top_k": 40,
|
||||
"max_output_tokens": 8192,
|
||||
}
|
||||
if json_mode:
|
||||
generation_config["response_mime_type"] = "application/json"
|
||||
|
||||
# WICHTIG: Nutze 2.0, da 1.5 nicht verfügbar war
|
||||
model = old_genai.GenerativeModel(
|
||||
model_name="gemini-2.0-flash",
|
||||
generation_config=generation_config,
|
||||
system_instruction=system_instruction
|
||||
)
|
||||
contents = [prompt] if isinstance(prompt, str) else prompt
|
||||
response = model.generate_content(contents)
|
||||
return response.text.strip()
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler mit alter GenAI Lib: {e}")
|
||||
if not HAS_NEW_GENAI: raise e
|
||||
# Fallthrough to new lib
|
||||
|
||||
# Priorität 2: Neue Bibliothek
|
||||
if HAS_NEW_GENAI:
|
||||
try:
|
||||
client = genai.Client(api_key=api_key)
|
||||
config = {
|
||||
"temperature": temperature,
|
||||
"top_p": 0.95,
|
||||
"top_k": 40,
|
||||
"max_output_tokens": 8192,
|
||||
}
|
||||
if json_mode:
|
||||
config["response_mime_type"] = "application/json"
|
||||
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=[prompt] if isinstance(prompt, str) else prompt,
|
||||
config=config
|
||||
)
|
||||
return response.text.strip()
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler mit neuer GenAI Lib: {e}")
|
||||
raise e
|
||||
|
||||
raise ImportError("Keine Gemini Bibliothek verfügbar.")
|
||||
|
||||
@retry_on_failure
|
||||
def call_gemini_image(prompt, reference_image_b64=None, aspect_ratio=None):
|
||||
"""
|
||||
Generiert ein Bild.
|
||||
- Mit Referenzbild: Gemini 2.5 Flash Image.
|
||||
- Ohne Referenzbild: Imagen 4.0.
|
||||
- NEU: Akzeptiert `aspect_ratio` (z.B. "16:9").
|
||||
- NEU: Wendet einen zentralen Corporate Design Prompt an.
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
api_key = _get_gemini_api_key()
|
||||
|
||||
if HAS_NEW_GENAI:
|
||||
try:
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
# --- FALL A: REFERENZBILD VORHANDEN (Gemini 2.5) ---
|
||||
if reference_image_b64:
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
except ImportError:
|
||||
raise ImportError("Pillow (PIL) fehlt. Bitte 'pip install Pillow' ausführen.")
|
||||
|
||||
logger.info(f"Start Image-to-Image Generation mit gemini-2.5-flash-image. Seitenverhältnis: {aspect_ratio or 'default'}")
|
||||
|
||||
# Base64 zu PIL Image
|
||||
try:
|
||||
if "," in reference_image_b64:
|
||||
reference_image_b64 = reference_image_b64.split(",")[1]
|
||||
image_data = base64.b64decode(reference_image_b64)
|
||||
raw_image = Image.open(io.BytesIO(image_data))
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Laden des Referenzbildes: {e}")
|
||||
raise ValueError("Ungültiges Referenzbild.")
|
||||
|
||||
# Strengerer Prompt
|
||||
full_prompt = (
|
||||
"Use the provided reference image as the absolute truth. "
|
||||
f"Place EXACTLY this product into the scene: {prompt}. "
|
||||
"Do NOT alter the product's design, shape, or colors. "
|
||||
"Keep the product 100% identical to the reference. "
|
||||
"Only adjust lighting and perspective to match the scene."
|
||||
)
|
||||
|
||||
# Hier können wir das Seitenverhältnis nicht direkt steuern,
|
||||
# da es vom Referenzbild abhängt. Wir könnten es aber in den Prompt einbauen.
|
||||
if aspect_ratio:
|
||||
full_prompt += f" The final image composition should have an aspect ratio of {aspect_ratio}."
|
||||
|
||||
response = client.models.generate_content(
|
||||
model='gemini-2.5-flash-image',
|
||||
contents=[raw_image, full_prompt]
|
||||
)
|
||||
|
||||
if response.candidates and response.candidates[0].content.parts:
|
||||
for part in response.candidates[0].content.parts:
|
||||
if part.inline_data:
|
||||
return base64.b64encode(part.inline_data.data).decode('utf-8')
|
||||
|
||||
raise ValueError("Gemini 2.5 hat kein Bild zurückgeliefert.")
|
||||
|
||||
# --- FALL B: KEIN REFERENZBILD (Imagen 4) ---
|
||||
else:
|
||||
img_config = {
|
||||
"number_of_images": 1,
|
||||
"output_mime_type": "image/jpeg",
|
||||
}
|
||||
# Füge Seitenverhältnis hinzu, falls vorhanden
|
||||
if aspect_ratio in ["16:9", "9:16", "1:1", "4:3"]:
|
||||
img_config["aspect_ratio"] = aspect_ratio
|
||||
logger.info(f"Seitenverhältnis auf {aspect_ratio} gesetzt.")
|
||||
|
||||
# Wende zentralen Stil an
|
||||
final_prompt = f"{Config.CORPORATE_DESIGN_PROMPT}\n\nTask: {prompt}"
|
||||
|
||||
method = getattr(client.models, 'generate_images', None)
|
||||
if not method:
|
||||
available_methods = [m for m in dir(client.models) if not m.startswith('_')]
|
||||
raise AttributeError(f"Client hat keine Image-Methode. Verfügbar: {available_methods}")
|
||||
|
||||
candidates = [
|
||||
'imagen-4.0-generate-001',
|
||||
'imagen-4.0-fast-generate-001',
|
||||
'imagen-4.0-ultra-generate-001'
|
||||
]
|
||||
|
||||
last_error = None
|
||||
for model_name in candidates:
|
||||
try:
|
||||
logger.info(f"Versuche Text-zu-Bild mit Modell: {model_name}")
|
||||
response = method(
|
||||
model=model_name,
|
||||
prompt=final_prompt,
|
||||
config=img_config
|
||||
)
|
||||
|
||||
if response.generated_images:
|
||||
image_bytes = response.generated_images[0].image.image_bytes
|
||||
return base64.b64encode(image_bytes).decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.warning(f"Modell {model_name} fehlgeschlagen: {e}")
|
||||
last_error = e
|
||||
|
||||
if last_error: raise last_error
|
||||
raise ValueError("Kein Modell konnte Bilder generieren.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei Image Gen: {e}")
|
||||
raise e
|
||||
else:
|
||||
logger.error("Image Generation erfordert die neue 'google-genai' Bibliothek.")
|
||||
raise ImportError("Installieren Sie 'google-genai' für Bildgenerierung.")
|
||||
|
||||
@retry_on_failure
|
||||
def call_openai_chat(prompt, temperature=0.3, model=None, response_format_json=False):
|
||||
return call_gemini_flash(
|
||||
prompt=prompt,
|
||||
temperature=temperature,
|
||||
json_mode=response_format_json,
|
||||
system_instruction=None
|
||||
)
|
||||
|
||||
def summarize_website_content(raw_text, company_name): return "k.A."
|
||||
def summarize_wikipedia_article(full_text, company_name): return "k.A."
|
||||
def evaluate_branche_chatgpt(company_name, website_summary, wiki_absatz): return {}
|
||||
def evaluate_branches_batch(companies_data): return []
|
||||
def verify_wiki_article_chatgpt(company_name, parent_name, website, wiki_title, wiki_summary): return {}
|
||||
def generate_fsm_pitch(company_name, company_short_name, ki_branche, website_summary, wiki_absatz, anzahl_ma, anzahl_techniker, techniker_bucket_ml): return ""
|
||||
def serp_website_lookup(company_name): return "k.A."
|
||||
def search_linkedin_contacts(company_name, website, position_query, crm_kurzform, num_results=10): return []
|
||||
def get_website_raw(url, max_length=30000, verify_cert=False): return "k.A."
|
||||
|
||||
def scrape_website_details(url):
|
||||
logger = logging.getLogger(__name__)
|
||||
if not url or not isinstance(url, str) or not url.startswith('http'):
|
||||
return "Keine gültige URL angegeben."
|
||||
try:
|
||||
headers = {'User-Agent': random.choice(USER_AGENTS)}
|
||||
response = requests.get(url, headers=headers, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15), verify=False)
|
||||
response.raise_for_status()
|
||||
if 'text/html' not in response.headers.get('Content-Type', ''): return "Kein HTML."
|
||||
soup = BeautifulSoup(response.content, 'html.parser')
|
||||
for element in soup(['script', 'style', 'noscript', 'iframe', 'svg', 'header', 'footer', 'nav', 'aside', 'form', 'button', 'a']):
|
||||
element.decompose()
|
||||
body = soup.find('body')
|
||||
text = body.get_text(separator=' ', strip=True) if body else soup.get_text(separator=' ', strip=True)
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
return text[:25000] if text else "Leer."
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler URL {url}: {e}")
|
||||
return "Fehler beim Scraping."
|
||||
|
||||
def is_valid_wikipedia_article_url(url): return False
|
||||
def alignment_demo(sheet_handler): pass
|
||||
@@ -0,0 +1,195 @@
|
||||
# knowledge_base_builder.py
|
||||
|
||||
__version__ = "v1.2.4"
|
||||
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter
|
||||
import pandas as pd
|
||||
|
||||
from google_sheet_handler import GoogleSheetHandler
|
||||
from helpers import create_log_filename
|
||||
from config import Config
|
||||
|
||||
# --- Konfiguration ---
|
||||
SOURCE_SHEET_NAME = "CRM_Jobtitles"
|
||||
EXACT_MATCH_OUTPUT_FILE = "exact_match_map.json"
|
||||
KEYWORD_RULES_OUTPUT_FILE = "keyword_rules.json"
|
||||
|
||||
# --- NEU: Priorisierung nach Geschäfts-Relevanz ---
|
||||
DEPARTMENT_PRIORITIES = {
|
||||
# Tier 1: Kern-Fachabteilungen (geordnet nach Häufigkeit)
|
||||
"Field Service Management / Kundenservice": 1,
|
||||
"IT": 2,
|
||||
"Logistik": 3,
|
||||
"Production Maintenance / Wartung Produktion": 4,
|
||||
"Utility Maintenance": 5,
|
||||
"Procurement / Einkauf": 6,
|
||||
"Vertrieb": 7,
|
||||
"Supply Chain Management": 8,
|
||||
"Finanzen": 9,
|
||||
"Technik": 10,
|
||||
"Transportwesen": 11,
|
||||
|
||||
# Tier 2: Spezifische Nischen-Abteilungen (geordnet nach Häufigkeit)
|
||||
"Fuhrparkmanagement": 15,
|
||||
"Legal": 16,
|
||||
"Baustofflogistik": 17,
|
||||
"Baustoffherstellung": 18,
|
||||
|
||||
# Tier 3: Allgemeine, übergreifende Abteilungen
|
||||
"Management / GF / C-Level": 20, # Muss niedriger als Fachabteilungen sein
|
||||
|
||||
# Tier 4: Auffang-Kategorien
|
||||
"Berater": 25,
|
||||
"Undefined": 99
|
||||
}
|
||||
|
||||
BRANCH_GROUP_RULES = {
|
||||
"bau": ["Baustoffhandel", "Baustoffindustrie", "Logistiker Baustoffe", "Bauunternehmen"],
|
||||
"versorger": ["Stadtwerke", "Verteilnetzbetreiber", "Telekommunikation", "Gase & Mineralöl"],
|
||||
"produktion": ["Maschinenbau", "Automobil", "Anlagenbau", "Medizintechnik", "Chemie & Pharma", "Elektrotechnik", "Lebensmittelproduktion", "Bürotechnik", "Automaten (Vending, Slot)", "Gebäudetechnik Allgemein", "Braune & Weiße Ware", "Fenster / Glas", "Getränke", "Möbel", "Agrar, Pellets"]
|
||||
}
|
||||
|
||||
MIN_SAMPLES_FOR_BRANCH_RULE = 5
|
||||
BRANCH_SPECIFICITY_THRESHOLD = 0.6
|
||||
|
||||
# --- OPTIMIERTE STOP_WORDS LISTE ---
|
||||
STOP_WORDS = {
|
||||
# Administrative Titelteile
|
||||
'manager', 'leiter', 'head', 'lead', 'senior', 'junior', 'direktor', 'director',
|
||||
'verantwortlicher', 'beauftragter', 'referent', 'sachbearbeiter', 'mitarbeiter',
|
||||
'spezialist', 'specialist', 'expert', 'experte', 'consultant',
|
||||
'assistant', 'assistenz', 'teamleiter', 'teamlead', 'abteilungsleiter',
|
||||
'bereichsleiter', 'gruppenleiter', 'geschäftsführer', 'vorstand', 'ceo', 'cio',
|
||||
'cfo', 'cto', 'coo',
|
||||
# Füllwörter
|
||||
'von', 'of', 'und', 'für', 'der', 'die', 'das', '&',
|
||||
# Zu allgemeine Begriffe, die aber Signalwörter überstimmen
|
||||
'leitung', 'leiterin', 'teamleitung', 'gruppenleitung', 'bereichsleitung', 'abteilungsleitung',
|
||||
'operations', 'business', 'development', 'zentrale', 'center'
|
||||
# WICHTIG: 'service', 'customer', 'care', 'support' wurden bewusst entfernt!
|
||||
}
|
||||
|
||||
def setup_logging():
|
||||
log_filename = create_log_filename("knowledge_base_builder")
|
||||
if not log_filename:
|
||||
print("KRITISCHER FEHLER: Log-Datei konnte nicht erstellt werden. Logge nur in die Konsole.")
|
||||
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[logging.StreamHandler()])
|
||||
return
|
||||
|
||||
log_level = logging.DEBUG
|
||||
root_logger = logging.getLogger()
|
||||
if root_logger.handlers:
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(log_filename, encoding='utf-8'),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logging.getLogger("gspread").setLevel(logging.WARNING)
|
||||
logging.getLogger("oauth2client").setLevel(logging.WARNING)
|
||||
logging.info(f"Logging erfolgreich initialisiert. Log-Datei: {log_filename}")
|
||||
|
||||
def build_knowledge_base():
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Starte Erstellung der Wissensbasis (Version {__version__})...")
|
||||
|
||||
gsh = GoogleSheetHandler()
|
||||
df = gsh.get_sheet_as_dataframe(SOURCE_SHEET_NAME)
|
||||
|
||||
if df is None or df.empty:
|
||||
logger.critical(f"Konnte keine Daten aus '{SOURCE_SHEET_NAME}' laden. Abbruch.")
|
||||
return
|
||||
|
||||
df.columns = [col.strip() for col in df.columns]
|
||||
|
||||
required_cols = ["Job Title", "Department", "Branche"]
|
||||
if not all(col in df.columns for col in required_cols):
|
||||
logger.critical(f"Benötigte Spalten {required_cols} nicht in '{SOURCE_SHEET_NAME}' gefunden. Abbruch.")
|
||||
return
|
||||
|
||||
logger.info(f"{len(df)} Zeilen aus '{SOURCE_SHEET_NAME}' geladen.")
|
||||
|
||||
df.dropna(subset=required_cols, inplace=True)
|
||||
df = df[df["Job Title"].str.strip() != '']
|
||||
df['normalized_title'] = df['Job Title'].str.lower().str.strip()
|
||||
logger.info(f"{len(df)} Zeilen nach Bereinigung.")
|
||||
|
||||
logger.info("Erstelle 'Primary Mapping' für exakte Treffer (Stufe 1)...")
|
||||
exact_match_map = df.groupby('normalized_title')['Department'].apply(lambda x: x.mode()[0]).to_dict()
|
||||
try:
|
||||
with open(EXACT_MATCH_OUTPUT_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(exact_match_map, f, indent=4, ensure_ascii=False)
|
||||
logger.info(f"-> '{EXACT_MATCH_OUTPUT_FILE}' mit {len(exact_match_map)} Titeln erstellt.")
|
||||
except IOError as e:
|
||||
logger.error(f"Fehler beim Schreiben der Datei '{EXACT_MATCH_OUTPUT_FILE}': {e}")
|
||||
return
|
||||
|
||||
logger.info("Erstelle 'Keyword-Datenbank' mit automatischer Branchen-Logik (Stufe 2)...")
|
||||
|
||||
titles_by_department = df.groupby('Department')['normalized_title'].apply(list).to_dict()
|
||||
branches_by_department = df.groupby('Department')['Branche'].apply(list).to_dict()
|
||||
|
||||
keyword_rules = {}
|
||||
for department, titles in titles_by_department.items():
|
||||
all_words = []
|
||||
for title in titles:
|
||||
words = re.split(r'[\s/(),-]+', title)
|
||||
all_words.extend([word for word in words if word])
|
||||
|
||||
word_counts = Counter(all_words)
|
||||
top_keywords = [word for word, count in word_counts.most_common(50) if word not in STOP_WORDS and (len(word) > 2 or word in {'it', 'edv'})]
|
||||
|
||||
if top_keywords:
|
||||
rule = {
|
||||
"priority": DEPARTMENT_PRIORITIES.get(department, 99),
|
||||
"keywords": sorted(top_keywords)
|
||||
}
|
||||
|
||||
department_branches = branches_by_department.get(department, [])
|
||||
total_titles_in_dept = len(department_branches)
|
||||
|
||||
if total_titles_in_dept >= MIN_SAMPLES_FOR_BRANCH_RULE:
|
||||
branch_group_counts = Counter()
|
||||
for branch_name in department_branches:
|
||||
for group_keyword, d365_names in BRANCH_GROUP_RULES.items():
|
||||
if branch_name in d365_names:
|
||||
branch_group_counts[group_keyword] += 1
|
||||
|
||||
if branch_group_counts:
|
||||
most_common_group, count = branch_group_counts.most_common(1)[0]
|
||||
ratio = count / total_titles_in_dept
|
||||
if ratio > BRANCH_SPECIFICITY_THRESHOLD:
|
||||
logger.info(f" -> Department '{department}' ist spezifisch für Branche '{most_common_group}' ({ratio:.0%}). Regel wird hinzugefügt.")
|
||||
rule["required_branch_keywords"] = [most_common_group]
|
||||
else:
|
||||
logger.debug(f" -> Department '{department}' nicht spezifisch genug. Dominante Branche '{most_common_group}' nur bei {ratio:.0%}, benötigt >{BRANCH_SPECIFICITY_THRESHOLD:.0%}.")
|
||||
else:
|
||||
logger.debug(f" -> Department '{department}' konnte keiner Branchen-Gruppe zugeordnet werden.")
|
||||
else:
|
||||
logger.debug(f" -> Department '{department}' hat zu wenige Datenpunkte ({total_titles_in_dept} < {MIN_SAMPLES_FOR_BRANCH_RULE}) für eine Branchen-Regel.")
|
||||
|
||||
keyword_rules[department] = rule
|
||||
|
||||
try:
|
||||
with open(KEYWORD_RULES_OUTPUT_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(keyword_rules, f, indent=4, ensure_ascii=False)
|
||||
logger.info(f"-> '{KEYWORD_RULES_OUTPUT_FILE}' mit Regeln für {len(keyword_rules)} Departments erstellt.")
|
||||
except IOError as e:
|
||||
logger.error(f"Fehler beim Schreiben der Datei '{KEYWORD_RULES_OUTPUT_FILE}': {e}")
|
||||
return
|
||||
|
||||
logger.info("Wissensbasis erfolgreich erstellt.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
setup_logging()
|
||||
build_knowledge_base()
|
||||
587
ARCHIVE_legacy_scripts/_legacy_gsheets_system/sync_manager.py
Normal file
587
ARCHIVE_legacy_scripts/_legacy_gsheets_system/sync_manager.py
Normal file
@@ -0,0 +1,587 @@
|
||||
#!/usr/-bin/env python3
|
||||
"""
|
||||
sync_manager.py
|
||||
|
||||
Modul für den Datenabgleich zwischen einem D365 Excel-Export und dem Google Sheet.
|
||||
Führt einen intelligenten "Full-Sync" durch, um neue, geänderte und
|
||||
gelöschte Datensätze zu identifizieren und zu verarbeiten.
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import logging
|
||||
import re, unicodedata
|
||||
from collections import defaultdict
|
||||
|
||||
from config import COLUMN_ORDER, COLUMN_MAP, Config
|
||||
|
||||
class SyncStatistics:
|
||||
"""Eine einfache Klasse zum Sammeln von Statistiken während des Sync-Prozesses."""
|
||||
def __init__(self):
|
||||
self.new_accounts = 0
|
||||
self.existing_accounts = 0
|
||||
self.archived_accounts = 0
|
||||
self.accounts_to_update = set()
|
||||
self.field_updates = defaultdict(int)
|
||||
self.conflict_accounts = set()
|
||||
self.field_conflicts = defaultdict(int)
|
||||
|
||||
def generate_report(self):
|
||||
report = [
|
||||
"\n" + "="*50,
|
||||
" Sync-Prozess Abschlussbericht",
|
||||
"="*50,
|
||||
f"| Neue Accounts hinzugefügt: | {self.new_accounts}",
|
||||
f"| Bestehende Accounts analysiert: | {self.existing_accounts}",
|
||||
f"| Accounts für Archivierung markiert:| {self.archived_accounts}",
|
||||
"-"*50,
|
||||
f"| Accounts mit Updates gesamt: | {len(self.accounts_to_update)}",
|
||||
]
|
||||
if self.field_updates:
|
||||
report.append("| Feld-Updates im Detail:")
|
||||
# Sortiert die Feld-Updates nach Häufigkeit
|
||||
sorted_updates = sorted(self.field_updates.items(), key=lambda item: item[1], reverse=True)
|
||||
for field, count in sorted_updates:
|
||||
report.append(f"| - {field:<25} | {count} mal")
|
||||
else:
|
||||
report.append("| Keine Feld-Updates durchgeführt.")
|
||||
|
||||
report.append("-" * 50)
|
||||
report.append(f"| Accounts mit Konflikten: | {len(self.conflict_accounts)}")
|
||||
if self.field_conflicts:
|
||||
report.append("| Feld-Konflikte im Detail:")
|
||||
sorted_conflicts = sorted(self.field_conflicts.items(), key=lambda item: item[1], reverse=True)
|
||||
for field, count in sorted_conflicts:
|
||||
report.append(f"| - {field:<25} | {count} mal")
|
||||
else:
|
||||
report.append("| Keine Konflikte festgestellt.")
|
||||
|
||||
report.append("="*50)
|
||||
return "\n".join(report)
|
||||
|
||||
class SyncManager:
|
||||
"""
|
||||
Kapselt die Logik für den Abgleich zwischen D365-Export und Google Sheet.
|
||||
"""
|
||||
|
||||
def _normalize_text_for_comparison(self, text: str) -> str:
|
||||
"""Normalisiert einen Text, um irrelevante Whitespace-Unterschiede zu ignorieren."""
|
||||
if not isinstance(text, str): text = str(text)
|
||||
# Ersetze Windows-Zeilenumbrüche, dann fasse alle Whitespace-Arten zusammen und trimme
|
||||
return " ".join(text.replace('\r\n', '\n').split())
|
||||
|
||||
def __init__(self, sheet_handler, d365_export_path):
|
||||
self.sheet_handler = sheet_handler
|
||||
self.d365_export_path = d365_export_path
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.stats = SyncStatistics()
|
||||
self.target_sheet_name = None
|
||||
|
||||
self.d365_to_gsheet_map = {
|
||||
"Account Name": "CRM Name", "Parent Account": "Parent Account Name",
|
||||
"Website": "CRM Website", "City": "CRM Ort", "Country": "CRM Land",
|
||||
"Description FSM": "CRM Beschreibung", "Branch detail": "CRM Branche",
|
||||
"No. Service Technicians": "CRM Anzahl Techniker",
|
||||
"Annual Revenue (Mio. €)": "CRM Umsatz",
|
||||
"Number of Employees": "CRM Anzahl Mitarbeiter", "GUID": "CRM ID"
|
||||
}
|
||||
|
||||
self.d365_wins_cols = ["CRM Name", "Parent Account Name", "CRM Ort", "CRM Land",
|
||||
"CRM Anzahl Techniker", "CRM Branche", "CRM Umsatz",
|
||||
"CRM Anzahl Mitarbeiter", "CRM Beschreibung"]
|
||||
self.smart_merge_cols = ["CRM Website"]
|
||||
|
||||
def _load_data(self):
|
||||
"""Lädt und bereitet die Daten aus D365 (Excel) und Google Sheets vor. Hart gegen „verschmutzte“ Header im Sheet."""
|
||||
# ----------------------------
|
||||
# D365-EXPORT LADEN (Excel)
|
||||
# ----------------------------
|
||||
self.logger.info(f"Lade Daten aus D365-Export: '{self.d365_export_path}'...")
|
||||
try:
|
||||
# Alles als String laden und NaN -> '' setzen, damit Vergleiche stabil sind
|
||||
temp_d365_df = pd.read_excel(self.d365_export_path, dtype=str).fillna('')
|
||||
|
||||
# Erwartete Spalten aus dem D365-Export prüfen
|
||||
for d365_col in self.d365_to_gsheet_map.keys():
|
||||
if d365_col not in temp_d365_df.columns:
|
||||
raise ValueError(f"Erwartete Spalte '{d365_col}' nicht in der D365-Exportdatei gefunden.")
|
||||
|
||||
# Auf die relevanten Spalten reduzieren und auf GSheet-Namen umbenennen
|
||||
self.d365_df = temp_d365_df[list(self.d365_to_gsheet_map.keys())].copy()
|
||||
self.d365_df.rename(columns=self.d365_to_gsheet_map, inplace=True)
|
||||
|
||||
# GUID-Format vereinheitlichen (lowercase, Trim) und nur gültige GUIDs behalten
|
||||
if 'CRM ID' not in self.d365_df.columns:
|
||||
raise ValueError("Nach dem Umbenennen fehlt die Spalte 'CRM ID' im D365-DataFrame.")
|
||||
self.d365_df['CRM ID'] = self.d365_df['CRM ID'].str.strip().str.lower()
|
||||
self.d365_df = self.d365_df[self.d365_df['CRM ID'].str.match(r'^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$', na=False)]
|
||||
|
||||
# Leere DataFrames vermeiden: fehlende Spalten aus COLUMN_ORDER ergänzen
|
||||
for col_name in COLUMN_ORDER:
|
||||
if col_name not in self.d365_df.columns:
|
||||
self.d365_df[col_name] = ''
|
||||
|
||||
except Exception as e:
|
||||
self.logger.critical(f"Fehler beim Laden der Excel-Datei: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
# ----------------------------
|
||||
# GOOGLE SHEET LADEN + HEADER NORMALISIEREN
|
||||
# ----------------------------
|
||||
self.logger.info("Lade bestehende Daten aus dem Google Sheet...")
|
||||
try:
|
||||
all_data_with_headers = self.sheet_handler.get_all_data_with_headers()
|
||||
|
||||
if not all_data_with_headers or len(all_data_with_headers) < self.sheet_handler._header_rows:
|
||||
# Kein valider Header -> leeres DF mit korrekter Spaltenreihenfolge
|
||||
self.gsheet_df = pd.DataFrame(columns=COLUMN_ORDER)
|
||||
else:
|
||||
actual_header = all_data_with_headers[self.sheet_handler._header_rows - 1]
|
||||
data_rows = all_data_with_headers[self.sheet_handler._header_rows:]
|
||||
|
||||
# Header im Log als repr ausgeben, um unsichtbare Zeichen später schnell zu finden
|
||||
try:
|
||||
self.logger.debug("Roh-Header (repr): " + " | ".join(repr(h) for h in actual_header))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---- Header-Normalisierung (NBSP, Zero-Width, BOM, überflüssige Spaces) ----
|
||||
def _norm_header(s: str) -> str:
|
||||
if s is None:
|
||||
return ""
|
||||
s = str(s)
|
||||
s = s.replace("\u00A0", " ") # NBSP -> Space
|
||||
s = s.replace("\u200B", "").replace("\u200E", "").replace("\u200F", "").replace("\ufeff", "") # ZWSP/RTL/BOM raus
|
||||
# Control/Format Zeichen entfernen
|
||||
s = "".join(ch for ch in s if unicodedata.category(ch) not in ("Cf", "Cc", "Cs"))
|
||||
# Whitespace normalisieren
|
||||
s = re.sub(r"\s+", " ", s).strip()
|
||||
return s
|
||||
|
||||
norm_header = [_norm_header(h) for h in actual_header]
|
||||
|
||||
# Evtl. doppelte (normalisierte) Header technisch eindeutig machen
|
||||
seen = {}
|
||||
unique_norm_header = []
|
||||
for h in norm_header:
|
||||
n = seen.get(h, 0)
|
||||
unique_norm_header.append(h if n == 0 else f"{h}__dup{n}")
|
||||
seen[h] = n + 1
|
||||
|
||||
# Datenzeilen auf Header-Länge bringen und direkt zu Strings casten
|
||||
fixed_rows = []
|
||||
target_len = len(unique_norm_header)
|
||||
for r in data_rows:
|
||||
if len(r) < target_len:
|
||||
r = r + [''] * (target_len - len(r))
|
||||
else:
|
||||
r = r[:target_len]
|
||||
fixed_rows.append([str(v) for v in r])
|
||||
|
||||
temp_df = pd.DataFrame(fixed_rows, columns=unique_norm_header)
|
||||
|
||||
# Kanonische Namen (COLUMN_ORDER) vorbereiten: normalisiert -> Original
|
||||
canon_map = {_norm_header(c): c for c in COLUMN_ORDER}
|
||||
|
||||
# Spalten umbenennen (normalisierte -> kanonische Namen) und unmappbare loggen
|
||||
rename_map = {}
|
||||
unmapped_cols = []
|
||||
for col in list(temp_df.columns):
|
||||
base = col.split("__dup")[0] # Duplikatsuffix entfernen
|
||||
if base in canon_map:
|
||||
rename_map[col] = canon_map[base]
|
||||
else:
|
||||
unmapped_cols.append(col)
|
||||
|
||||
if rename_map:
|
||||
temp_df.rename(columns=rename_map, inplace=True)
|
||||
|
||||
if unmapped_cols:
|
||||
self.logger.warning(
|
||||
"Folgende GSheet-Spalten konnten NICHT auf COLUMN_ORDER gemappt werden "
|
||||
"(vermutlich fremde/alte/abweichende Header): "
|
||||
+ ", ".join([f"{c!r}" for c in unmapped_cols])
|
||||
)
|
||||
|
||||
# Fehlende Spalten (gegenüber COLUMN_ORDER) ergänzen
|
||||
for col_name in COLUMN_ORDER:
|
||||
if col_name not in temp_df.columns:
|
||||
temp_df[col_name] = ""
|
||||
|
||||
# Final in gewünschte Reihenfolge bringen
|
||||
self.gsheet_df = temp_df[COLUMN_ORDER]
|
||||
|
||||
# Sanity-Check für den gemeldeten Fall (nur Info-Log)
|
||||
try:
|
||||
if "CRM Anzahl Techniker" in self.gsheet_df.columns and "CRM ID" in self.gsheet_df.columns:
|
||||
probe_guid = "0f68a69d-e330-ec11-b6e6-000d3adbc80e"
|
||||
probe_row = self.gsheet_df[self.gsheet_df["CRM ID"].str.lower() == probe_guid]
|
||||
if not probe_row.empty:
|
||||
val = probe_row.iloc[0]["CRM Anzahl Techniker"]
|
||||
self.logger.info(
|
||||
f"Sanity-Check: GSheet['CRM Anzahl Techniker'] für {probe_guid} -> {val!r} (Typ: {type(val)})"
|
||||
)
|
||||
except Exception:
|
||||
# Nur zur Sicherheit – Sync soll nicht am Check scheitern
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
self.logger.critical(f"Fehler beim Laden/Umwandeln der GSheet-Daten: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
# ----------------------------
|
||||
# ZIEL-SHEET ERMITTELN & SYNC-BASIS BESTIMMEN
|
||||
# ----------------------------
|
||||
self.target_sheet_name = self.sheet_handler.get_main_sheet_name()
|
||||
if not self.target_sheet_name:
|
||||
self.logger.critical("Konnte Namen des Ziel-Sheets nicht ermitteln. Abbruch.")
|
||||
return False
|
||||
|
||||
# IDs bestimmen (nur auf gefüllte CRM IDs)
|
||||
d365_ids = set(self.d365_df['CRM ID'].dropna()) if 'CRM ID' in self.d365_df.columns else set()
|
||||
gsheet_ids = set(self.gsheet_df['CRM ID'].dropna()) if 'CRM ID' in self.gsheet_df.columns else set()
|
||||
|
||||
new_ids = d365_ids - gsheet_ids
|
||||
existing_ids = d365_ids.intersection(gsheet_ids)
|
||||
|
||||
# Archivierung wird (wie bisher) übersprungen – Teil-Export angenommen
|
||||
deleted_ids = set()
|
||||
self.logger.info("Archivierungs-Schritt wird übersprungen (Teil-Export angenommen).")
|
||||
|
||||
self.logger.info(
|
||||
f"Sync-Basis: {len(new_ids)} neu, {len(existing_ids)} vorhanden, {len(deleted_ids)} gelöscht (übersprungen)."
|
||||
)
|
||||
|
||||
# Ergebnisse in Objekt speichern
|
||||
self.new_ids = new_ids
|
||||
self.existing_ids = existing_ids
|
||||
self.deleted_ids = deleted_ids
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def run_sync(self):
|
||||
"""Führt den gesamten Synchronisationsprozess aus."""
|
||||
if not self._load_data(): return
|
||||
|
||||
self.target_sheet_name = self.sheet_handler.get_main_sheet_name()
|
||||
if not self.target_sheet_name:
|
||||
self.logger.critical("Konnte Namen des Ziel-Sheets nicht ermitteln. Abbruch.")
|
||||
return
|
||||
|
||||
d365_ids = set(self.d365_df['CRM ID'].dropna())
|
||||
gsheet_ids = set(self.gsheet_df['CRM ID'].dropna())
|
||||
|
||||
new_ids = d365_ids - gsheet_ids
|
||||
deleted_ids = set()
|
||||
self.logger.info("Archivierungs-Schritt wird übersprungen (Teil-Export angenommen).")
|
||||
existing_ids = d365_ids.intersection(gsheet_ids)
|
||||
|
||||
# Statistik befüllen
|
||||
self.stats.new_accounts = len(new_ids)
|
||||
self.stats.archived_accounts = len(deleted_ids)
|
||||
self.stats.existing_accounts = len(existing_ids)
|
||||
self.logger.info(f"Sync-Analyse: {self.stats.new_accounts} neue, {self.stats.archived_accounts} zu archivierende, {self.stats.existing_accounts} bestehende Accounts.")
|
||||
|
||||
updates_to_batch, rows_to_append = [], []
|
||||
|
||||
if new_ids:
|
||||
new_accounts_df = self.d365_df[self.d365_df['CRM ID'].isin(new_ids)]
|
||||
for _, row in new_accounts_df.iterrows():
|
||||
new_row_data = [""] * len(COLUMN_ORDER)
|
||||
for gsheet_col in self.d365_to_gsheet_map.values():
|
||||
if gsheet_col in row:
|
||||
col_idx = COLUMN_MAP[gsheet_col]['index']
|
||||
new_row_data[col_idx] = row[gsheet_col]
|
||||
rows_to_append.append(new_row_data)
|
||||
|
||||
if existing_ids:
|
||||
d365_indexed = self.d365_df.set_index('CRM ID')
|
||||
gsheet_to_update_df = self.gsheet_df[self.gsheet_df['CRM ID'].isin(existing_ids)]
|
||||
|
||||
for original_row_index, gsheet_row in gsheet_to_update_df.iterrows():
|
||||
crm_id = gsheet_row['CRM ID']
|
||||
if crm_id not in d365_indexed.index: continue
|
||||
d365_row = d365_indexed.loc[crm_id]
|
||||
|
||||
row_updates, conflict_messages, needs_reeval = {}, [], False
|
||||
|
||||
for gsheet_col in self.d365_wins_cols:
|
||||
d365_val = str(d365_row[gsheet_col]).strip()
|
||||
gsheet_val = str(gsheet_row[gsheet_col]).strip()
|
||||
trigger_update = False
|
||||
|
||||
if gsheet_col == 'CRM Land':
|
||||
d365_code_lower, gsheet_val_lower = d365_val.lower(), gsheet_val.lower()
|
||||
d365_translated_lower = Config.COUNTRY_CODE_MAP.get(d365_code_lower, d365_code_lower).lower()
|
||||
if gsheet_val_lower != d365_code_lower and gsheet_val_lower != d365_translated_lower:
|
||||
trigger_update = True
|
||||
elif gsheet_col == 'CRM Anzahl Techniker':
|
||||
if (d365_val == '-1' or d365_val == '0') and gsheet_val == '': pass
|
||||
elif d365_val != gsheet_val: trigger_update = True
|
||||
elif gsheet_col == 'CRM Branche':
|
||||
if gsheet_row['Chat Vorschlag Branche'] == '' and d365_val != gsheet_val:
|
||||
trigger_update = True
|
||||
elif gsheet_col == 'CRM Umsatz':
|
||||
if gsheet_row['Wiki Umsatz'] == '' and d365_val != gsheet_val:
|
||||
trigger_update = True
|
||||
elif gsheet_col == 'CRM Anzahl Mitarbeiter':
|
||||
if gsheet_row['Wiki Mitarbeiter'] == '' and d365_val != gsheet_val:
|
||||
trigger_update = True
|
||||
elif gsheet_col == 'CRM Beschreibung':
|
||||
if gsheet_row['Website Zusammenfassung'] == '' and d365_val != gsheet_val:
|
||||
trigger_update = True
|
||||
else:
|
||||
if d365_val != gsheet_val: trigger_update = True
|
||||
|
||||
if trigger_update:
|
||||
row_updates[gsheet_col] = d365_val; needs_reeval = True
|
||||
self.logger.debug(f"Update für {crm_id} durch '{gsheet_col}': D365='{d365_val}' | GSheet='{gsheet_val}'")
|
||||
|
||||
for gsheet_col in self.smart_merge_cols:
|
||||
d365_val = str(d365_row.get(gsheet_col, '')).strip()
|
||||
gsheet_val = str(gsheet_row.get(gsheet_col, '')).strip()
|
||||
if d365_val and not gsheet_val:
|
||||
row_updates[gsheet_col] = d365_val; needs_reeval = True
|
||||
elif d365_val and gsheet_val and d365_val != gsheet_val:
|
||||
conflict_messages.append(f"{gsheet_col}_CONFLICT: D365='{d365_val}' | GSHEET='{gsheet_val}'")
|
||||
|
||||
if conflict_messages:
|
||||
row_updates["SyncConflict"] = "; ".join(conflict_messages)
|
||||
self.stats.conflict_accounts.add(crm_id)
|
||||
for msg in conflict_messages: self.stats.field_conflicts[msg.split('_CONFLICT')[0]] += 1
|
||||
|
||||
if needs_reeval: row_updates["ReEval Flag"] = "x"
|
||||
|
||||
if row_updates:
|
||||
self.stats.accounts_to_update.add(crm_id)
|
||||
for field in row_updates.keys(): self.stats.field_updates[field] += 1
|
||||
sheet_row_number = original_row_index + self.sheet_handler._header_rows + 1
|
||||
for col_name, value in row_updates.items():
|
||||
updates_to_batch.append({ "range": f"{COLUMN_MAP[col_name]['Titel']}{sheet_row_number}", "values": [[value]] })
|
||||
|
||||
if rows_to_append:
|
||||
self.logger.info(f"Füge {len(rows_to_append)} neue Zeilen zum Google Sheet hinzu...")
|
||||
self.sheet_handler.append_rows(sheet_name=self.target_sheet_name, values=rows_to_append)
|
||||
|
||||
if updates_to_batch:
|
||||
self.logger.info(f"Sende {len(updates_to_batch)} Zell-Updates an das Google Sheet...")
|
||||
self.sheet_handler.batch_update_cells(updates_to_batch)
|
||||
|
||||
# --- WIEDERHERGESTELLTER STATISTIK-BLOCK ---
|
||||
report = self.stats.generate_report()
|
||||
self.logger.info(report)
|
||||
print(report)
|
||||
# --- ENDE STATISTIK-BLOCK ---
|
||||
|
||||
self.logger.info("Synchronisation erfolgreich abgeschlossen.")
|
||||
|
||||
def debug_sync(self, debug_id=None):
|
||||
"""
|
||||
Führt eine Analyse des Sync-Prozesses durch. Ohne debug_id wird eine
|
||||
allgemeine Statistik ausgegeben. Mit debug_id wird eine Tiefenanalyse
|
||||
für einen einzelnen Datensatz durchgeführt.
|
||||
"""
|
||||
self.logger.info("========== START SYNC-DEBUG-MODUS ==========")
|
||||
|
||||
# Lade die Rohdaten, aber brich die _load_data Funktion noch nicht ab
|
||||
self.logger.info("Lade Rohdaten aus Google Sheet für Tiefenanalyse...")
|
||||
try:
|
||||
all_data_with_headers = self.sheet_handler.get_all_data_with_headers()
|
||||
if not all_data_with_headers:
|
||||
self.logger.error("Debug abgebrochen, Google Sheet ist leer.")
|
||||
return
|
||||
except Exception as e:
|
||||
self.logger.error(f"Debug abgebrochen, Fehler beim Laden der Rohdaten: {e}")
|
||||
return
|
||||
|
||||
if not debug_id:
|
||||
# Führe den Rest von _load_data aus für die allgemeine Statistik
|
||||
if not self._load_data():
|
||||
self.logger.error("Debug abgebrochen, da das Laden der Daten fehlschlug.")
|
||||
return
|
||||
self.logger.info("Keine spezifische ID angegeben. Führe allgemeine Statistik-Analyse durch.")
|
||||
d365_ids = set(self.d365_df['CRM ID'])
|
||||
gsheet_ids = set(self.gsheet_df[self.gsheet_df['CRM ID'] != '']['CRM ID'].dropna())
|
||||
self.logger.info("\n--- Set-Analyse (Vergleich) ---")
|
||||
self.logger.info(f"Anzahl neuer IDs: {len(d365_ids - gsheet_ids)}")
|
||||
self.logger.info(f"Anzahl zu archivierender IDs: {len(gsheet_ids - d365_ids)}")
|
||||
self.logger.info(f"Größe der Schnittmenge: {len(d365_ids.intersection(gsheet_ids))}")
|
||||
self.logger.info("========== ENDE SYNC-DEBUG-MODUS ==========")
|
||||
return
|
||||
|
||||
# --- TIEFENANALYSE FÜR EINE SPEZIFISCHE ID ---
|
||||
self.logger.info(f"\n--- Tiefenanalyse für CRM ID: {debug_id} ---")
|
||||
debug_id_lower = debug_id.lower().strip()
|
||||
|
||||
# 1. Finde die Roh-Zeile im Google Sheet
|
||||
self.logger.info("\n--- Rohdaten-Analyse aus Google Sheet ---")
|
||||
header = all_data_with_headers[self.sheet_handler._header_rows - 1]
|
||||
crm_id_index = -1
|
||||
try:
|
||||
# Finde den Index der 'CRM ID' Spalte im Header
|
||||
crm_id_index = header.index("CRM ID")
|
||||
except ValueError:
|
||||
self.logger.error("Spalte 'CRM ID' nicht im Header des Google Sheets gefunden!")
|
||||
|
||||
found_raw_row = None
|
||||
if crm_id_index != -1:
|
||||
for i, row in enumerate(all_data_with_headers[self.sheet_handler._header_rows:]):
|
||||
# Stelle sicher, dass die Zeile lang genug ist
|
||||
if len(row) > crm_id_index:
|
||||
if str(row[crm_id_index]).lower().strip() == debug_id_lower:
|
||||
found_raw_row = row
|
||||
self.logger.info(f"Roh-Zeile gefunden bei Index {i} (nach Header):")
|
||||
self.logger.info(found_raw_row)
|
||||
break
|
||||
|
||||
if not found_raw_row:
|
||||
self.logger.warning("ID in den Rohdaten des Google Sheets nicht gefunden.")
|
||||
|
||||
# 2. Führe jetzt die normale Datenverarbeitung durch, um das DataFrame zu bekommen
|
||||
if not self._load_data():
|
||||
self.logger.error("Debug abgebrochen, da das Laden der Daten fehlschlug.")
|
||||
return
|
||||
|
||||
# 3. Analyse der DataFrames (wie gehabt)
|
||||
d365_row = self.d365_df[self.d365_df['CRM ID'] == debug_id_lower]
|
||||
if d365_row.empty:
|
||||
self.logger.warning("ID in D365-Export nicht gefunden.")
|
||||
else:
|
||||
self.logger.info("\nDatensatz aus D365-Export (nach Verarbeitung):")
|
||||
self.logger.info(d365_row.to_dict('records')[0])
|
||||
|
||||
gsheet_row = self.gsheet_df[self.gsheet_df['CRM ID'] == debug_id_lower]
|
||||
if gsheet_row.empty:
|
||||
self.logger.warning("ID im Google Sheet DataFrame nicht gefunden (nach Bereinigung).")
|
||||
else:
|
||||
self.logger.info("\nDatensatz aus Google Sheet (nach Verarbeitung zu DataFrame):")
|
||||
self.logger.info(gsheet_row.to_dict('records')[0])
|
||||
|
||||
# 4. Direkter Vergleich des kritischen Feldes
|
||||
if not d365_row.empty and not gsheet_row.empty:
|
||||
self.logger.info("\n--- Direkter Feld-Vergleich: CRM Anzahl Techniker ---")
|
||||
d365_val = d365_row.iloc[0]['CRM Anzahl Techniker']
|
||||
gsheet_val = gsheet_row.iloc[0]['CRM Anzahl Techniker']
|
||||
|
||||
self.logger.info(f"Wert aus D365: '{d365_val}' (Typ: {type(d365_val)})")
|
||||
self.logger.info(f"Wert aus GSheet DataFrame: '{gsheet_val}' (Typ: {type(gsheet_val)})")
|
||||
|
||||
if str(d365_val).strip() != str(gsheet_val).strip():
|
||||
self.logger.info("--> Ergebnis: Werte sind UNTERSCHIEDLICH.")
|
||||
else:
|
||||
self.logger.info("--> Ergebnis: Werte sind IDENTISCH.")
|
||||
|
||||
self.logger.info("========== ENDE SYNC-DEBUG-MODUS ==========")
|
||||
|
||||
def simulate_sync(self, debug_id=None):
|
||||
"""
|
||||
Führt eine reine "Trockenlauf"-Analyse des Sync-Prozesses durch, ohne Daten zu schreiben.
|
||||
Gibt einen detaillierten, gruppierten Bericht über alle potenziellen Änderungen aus.
|
||||
"""
|
||||
self.logger.info("========== START SYNC-SIMULATION ==========")
|
||||
if not self._load_data():
|
||||
self.logger.error("Simulation abgebrochen, da das Laden der Daten fehlschlug.")
|
||||
return
|
||||
|
||||
# Die Analyse-Logik ist identisch zum echten Lauf
|
||||
d365_ids = set(self.d365_df['CRM ID'].dropna())
|
||||
gsheet_ids = set(self.gsheet_df['CRM ID'].dropna())
|
||||
new_ids = d365_ids - gsheet_ids
|
||||
existing_ids = d365_ids.intersection(gsheet_ids)
|
||||
|
||||
simulation_results = defaultdict(list)
|
||||
|
||||
# 1. Bestehende Accounts analysieren
|
||||
if existing_ids:
|
||||
d365_indexed = self.d365_df.set_index('CRM ID')
|
||||
gsheet_to_update_df = self.gsheet_df[self.gsheet_df['CRM ID'].isin(existing_ids)]
|
||||
|
||||
for _, gsheet_row in gsheet_to_update_df.iterrows():
|
||||
crm_id = gsheet_row['CRM ID']
|
||||
d365_row = d365_indexed.loc[crm_id]
|
||||
|
||||
changes = []
|
||||
conflicts = []
|
||||
needs_reeval = False
|
||||
|
||||
for gsheet_col in self.d365_wins_cols:
|
||||
d365_val = str(d365_row[gsheet_col]).strip()
|
||||
gsheet_val = str(gsheet_row[gsheet_col]).strip()
|
||||
|
||||
trigger_update = False
|
||||
if gsheet_col == 'CRM Land':
|
||||
d365_code_lower, gsheet_val_lower = d365_val.lower(), gsheet_val.lower()
|
||||
d365_translated = Config.COUNTRY_CODE_MAP.get(d365_code_lower, d365_code_lower).lower()
|
||||
if gsheet_val_lower != d365_code_lower and gsheet_val_lower != d365_translated:
|
||||
trigger_update = True
|
||||
elif gsheet_col == 'CRM Anzahl Techniker':
|
||||
semantically_empty = ['', '0', '-1']
|
||||
if d365_val in semantically_empty and gsheet_val in semantically_empty: pass
|
||||
elif d365_val != gsheet_val: trigger_update = True
|
||||
elif gsheet_col == 'CRM Branche':
|
||||
if gsheet_row['Chat Vorschlag Branche'] == '' and d365_val != gsheet_val:
|
||||
trigger_update = True
|
||||
elif gsheet_col == 'CRM Umsatz':
|
||||
if gsheet_row['Wiki Umsatz'] == '' and d365_val != gsheet_val:
|
||||
trigger_update = True
|
||||
elif gsheet_col == 'CRM Anzahl Mitarbeiter':
|
||||
if gsheet_row['Wiki Mitarbeiter'] == '' and d365_val != gsheet_val:
|
||||
trigger_update = True
|
||||
elif gsheet_col == 'CRM Beschreibung':
|
||||
if gsheet_row['Website Zusammenfassung'] == '' and d365_val != gsheet_val:
|
||||
trigger_update = True
|
||||
else:
|
||||
if d365_val != gsheet_val: trigger_update = True
|
||||
|
||||
if trigger_update:
|
||||
# --- NEUE KOMPAKTE LOG-AUSGABE ---
|
||||
if gsheet_col == 'CRM Beschreibung':
|
||||
changes.append(f"UPDATE: {gsheet_col} wurde geändert (Text zu lang für Log).")
|
||||
else:
|
||||
changes.append(f"UPDATE: {gsheet_col} von '{gsheet_val}' zu '{d365_val}'")
|
||||
needs_reeval = True
|
||||
|
||||
for gsheet_col in self.smart_merge_cols:
|
||||
d365_val = str(d365_row.get(gsheet_col, '')).strip()
|
||||
gsheet_val = str(gsheet_row.get(gsheet_col, '')).strip()
|
||||
if d365_val and gsheet_val and d365_val != gsheet_val:
|
||||
conflicts.append(f"CONFLICT: {gsheet_col} (D365='{d365_val}' vs GSheet='{gsheet_val}')")
|
||||
|
||||
if changes or conflicts:
|
||||
account_name = d365_row.get('CRM Name', 'Unbekannt')
|
||||
key = f"ACCOUNT: {crm_id} ({account_name})"
|
||||
simulation_results[key].extend(changes)
|
||||
simulation_results[key].extend(conflicts)
|
||||
if needs_reeval:
|
||||
simulation_results[key].append("AKTION: ReEval Flag würde gesetzt werden.")
|
||||
|
||||
# 2. Den Bericht generieren und ausgeben
|
||||
self.logger.info("\n\n" + "="*80)
|
||||
self.logger.info(" S Y N C S I M U L A T I O N S B E R I C H T")
|
||||
self.logger.info("="*80)
|
||||
|
||||
self.logger.info(f"\n--- ZUSAMMENFASSUNG ---")
|
||||
self.logger.info(f"Accounts im D365-Export: {len(d365_ids)}")
|
||||
self.logger.info(f"Accounts im Google Sheet: {len(gsheet_ids)}")
|
||||
self.logger.info(f"--> {len(new_ids)} NEUE Accounts würden hinzugefügt.")
|
||||
self.logger.info(f"--> {len(simulation_results)} BESTEHENDE Accounts würden geändert.")
|
||||
self.logger.info(f"--> {len(existing_ids) - len(simulation_results)} bestehende Accounts bleiben UNVERÄNDERT.")
|
||||
self.logger.info("-" * 80)
|
||||
|
||||
if new_ids:
|
||||
self.logger.info(f"\n--- {len(new_ids)} NEUE ACCOUNTS ---")
|
||||
new_accounts_df = self.d365_df[self.d365_df['CRM ID'].isin(new_ids)]
|
||||
for _, row in new_accounts_df.head(20).iterrows(): # Zeige maximal die ersten 20
|
||||
self.logger.info(f" - NEU: {row['CRM ID']} ({row['CRM Name']})")
|
||||
if len(new_ids) > 20: self.logger.info(" - ... und weitere.")
|
||||
|
||||
if simulation_results:
|
||||
self.logger.info(f"\n--- {len(simulation_results)} ZU AKTUALISIERENDE ACCOUNTS ---")
|
||||
for account, details in simulation_results.items():
|
||||
self.logger.info(account)
|
||||
for detail in details:
|
||||
self.logger.info(f" - {detail}")
|
||||
|
||||
self.logger.info("\n" + "="*80)
|
||||
self.logger.info(" S I M U L A T I O N B E E N D E T")
|
||||
self.logger.info("="*80)
|
||||
@@ -0,0 +1,481 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
wikipedia_scraper.py
|
||||
|
||||
Klasse zur Kapselung der Interaktionen mit Wikipedia, inklusive Suche,
|
||||
Validierung und Extraktion von Unternehmensdaten.
|
||||
"""
|
||||
|
||||
__version__ = "v2.0.2"
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import traceback
|
||||
from urllib.parse import unquote
|
||||
|
||||
import requests
|
||||
import wikipedia
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# Import der abhängigen Module
|
||||
from config import Config
|
||||
from helpers import (retry_on_failure, simple_normalize_url,
|
||||
normalize_company_name, extract_numeric_value,
|
||||
clean_text, fuzzy_similarity)
|
||||
|
||||
class WikipediaScraper:
|
||||
"""
|
||||
Handhabt das Suchen von Wikipedia-Artikeln und das Extrahieren relevanter
|
||||
Unternehmensdaten. Beinhaltet Validierungslogik fuer Artikel.
|
||||
Nutzt die wikipedia-Bibliothek und Requests fuer direktes HTML-Scraping.
|
||||
"""
|
||||
def __init__(self, user_agent=None):
|
||||
"""
|
||||
Initialisiert den Scraper mit einer Requests-Session und konfigurierter
|
||||
Wikipedia-Bibliothek.
|
||||
"""
|
||||
self.logger = logging.getLogger(__name__ + ".WikipediaScraper")
|
||||
self.logger.debug("WikipediaScraper initialisiert.")
|
||||
|
||||
self.user_agent = user_agent or getattr(Config, 'USER_AGENT', 'Mozilla/5.0 (compatible; UnternehmenSkript/1.0; +http://www.example.com/bot)')
|
||||
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', 'taetigkeit', 'sektor', 'produkte', 'leistungen'],
|
||||
'umsatz': ['umsatz', 'erloes', 'revenue', 'jahresumsatz', 'konzernumsatz', 'ergebnis'],
|
||||
'mitarbeiter': ['mitarbeiter', 'mitarbeiterzahl', 'beschaeftigte', 'employees', 'number of employees', 'personal', 'belegschaft'],
|
||||
'sitz': ['sitz', 'hauptsitz', 'unternehmenssitz', 'firmensitz', 'headquarters', 'standort', 'sitz des unternehmens', 'anschrift', 'adresse']
|
||||
}
|
||||
|
||||
try:
|
||||
wiki_lang = getattr(Config, 'LANG', 'de')
|
||||
wikipedia.set_lang(wiki_lang)
|
||||
wikipedia.set_rate_limiting(False)
|
||||
self.logger.info(f"Wikipedia library language set to '{wiki_lang}'. Rate limiting DISABLED.")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Fehler beim Setzen der Wikipedia-Sprache oder Rate Limiting: {e}")
|
||||
|
||||
@retry_on_failure
|
||||
def serp_wikipedia_lookup(self, company_name, lang='de'):
|
||||
"""
|
||||
Sucht die beste Wikipedia-URL für ein Unternehmen über eine Google-Suche (via SerpAPI).
|
||||
Priorisiert Treffer aus dem Knowledge Graph und organische Ergebnisse.
|
||||
|
||||
Args:
|
||||
company_name (str): Der Name des zu suchenden Unternehmens.
|
||||
lang (str): Der Sprachcode für die Wikipedia-Suche (z.B. 'de').
|
||||
|
||||
Returns:
|
||||
str: Die URL des besten Treffers oder None, wenn nichts Passendes gefunden wurde.
|
||||
"""
|
||||
self.logger.info(f"Starte SerpAPI Wikipedia-Suche für '{company_name}'...")
|
||||
serp_key = Config.API_KEYS.get('serpapi')
|
||||
if not serp_key:
|
||||
self.logger.warning("SerpAPI Key nicht konfiguriert. Suche wird übersprungen.")
|
||||
return None
|
||||
|
||||
query = f'site:{lang}.wikipedia.org "{company_name}"'
|
||||
params = {"engine": "google", "q": query, "api_key": serp_key, "hl": lang}
|
||||
|
||||
try:
|
||||
response = requests.get("https://serpapi.com/search", params=params, timeout=Config.REQUEST_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# 1. Knowledge Graph prüfen (höchste Priorität)
|
||||
if "knowledge_graph" in data and "source" in data["knowledge_graph"]:
|
||||
source = data["knowledge_graph"]["source"]
|
||||
if "link" in source and f"{lang}.wikipedia.org" in source["link"]:
|
||||
url = source["link"]
|
||||
self.logger.info(f" -> Treffer aus Knowledge Graph gefunden: {url}")
|
||||
return url
|
||||
|
||||
# 2. Organische Ergebnisse prüfen
|
||||
if "organic_results" in data:
|
||||
for result in data.get("organic_results", []):
|
||||
link = result.get("link")
|
||||
if link and f"{lang}.wikipedia.org/wiki/" in link:
|
||||
self.logger.info(f" -> Bester organischer Treffer gefunden: {link}")
|
||||
return link
|
||||
|
||||
self.logger.warning(f" -> Keine passende Wikipedia-URL für '{company_name}' in den SerpAPI-Ergebnissen gefunden.")
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.error(f"Fehler bei der SerpAPI-Anfrage für '{company_name}': {e}")
|
||||
return None
|
||||
|
||||
def _get_full_domain(self, website):
|
||||
"""Extrahiert die normalisierte Domain (ohne www, ohne Pfad) aus einer URL."""
|
||||
return simple_normalize_url(website)
|
||||
|
||||
def _generate_search_terms(self, company_name, website=None):
|
||||
"""
|
||||
Generiert eine Liste von potenziellen Wikipedia-Artikeltiteln.
|
||||
v2.0: Mit verbesserter Logik für Namen, die Zahlen enthalten.
|
||||
"""
|
||||
if not company_name:
|
||||
return []
|
||||
|
||||
normalized = normalize_company_name(company_name)
|
||||
|
||||
# Verbesserte Logik für Namen wie "11 88 0 Solutions"
|
||||
condensed_normalized = None
|
||||
if re.search(r'\d[\s\d]+\d', normalized):
|
||||
condensed_normalized = re.sub(r'(\d)\s+(\d)', r'\1\2', normalized)
|
||||
condensed_normalized = normalize_company_name(condensed_normalized)
|
||||
|
||||
search_terms = []
|
||||
if condensed_normalized: search_terms.append(condensed_normalized)
|
||||
search_terms.append(company_name)
|
||||
search_terms.append(normalized)
|
||||
|
||||
parts = normalized.split()
|
||||
if len(parts) > 1:
|
||||
search_terms.append(parts[0])
|
||||
search_terms.append(" ".join(parts[:2]))
|
||||
|
||||
if website:
|
||||
domain = simple_normalize_url(website)
|
||||
if domain != "k.A.":
|
||||
search_terms.append(domain)
|
||||
|
||||
unique_terms = list(dict.fromkeys([term for term in search_terms if term])) # Entfernt Duplikate, behält Reihenfolge
|
||||
return unique_terms[:5]
|
||||
|
||||
@retry_on_failure
|
||||
def _get_page_soup(self, url):
|
||||
"""
|
||||
Holt HTML von einer URL und gibt ein BeautifulSoup-Objekt zurueck.
|
||||
"""
|
||||
if not url or not isinstance(url, str) or not url.lower().startswith(("http://", "https://")):
|
||||
self.logger.warning(f"_get_page_soup: Ungueltige URL '{url[:100]}...'.")
|
||||
return None
|
||||
try:
|
||||
self.logger.debug(f"_get_page_soup: Rufe URL ab: {url[:100]}...")
|
||||
response = self.session.get(url, timeout=getattr(Config, 'REQUEST_TIMEOUT', 15))
|
||||
response.raise_for_status()
|
||||
response.encoding = response.apparent_encoding
|
||||
soup = BeautifulSoup(response.text, getattr(Config, 'HTML_PARSER', 'html.parser'))
|
||||
return soup
|
||||
except Exception as e:
|
||||
self.logger.error(f"_get_page_soup: Fehler beim Abrufen oder Parsen von HTML von {url[:100]}...: {e}")
|
||||
raise e
|
||||
|
||||
def _validate_article(self, page, company_name, website, crm_city, parent_name=None):
|
||||
"""
|
||||
Validiert faktenbasiert, ob ein Wikipedia-Artikel zum Unternehmen passt.
|
||||
Priorisiert harte Fakten (Domain, Sitz) vor reiner Namensähnlichkeit.
|
||||
"""
|
||||
if not page or not hasattr(page, 'html'):
|
||||
return False
|
||||
|
||||
self.logger.debug(f"Validiere Artikel '{page.title}' für Firma '{company_name}'...")
|
||||
|
||||
try:
|
||||
page_html = page.html()
|
||||
soup = BeautifulSoup(page_html, Config.HTML_PARSER)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Konnte HTML für Artikel '{page.title}' nicht parsen: {e}")
|
||||
return False
|
||||
|
||||
# --- Stufe 1: Website-Domain-Validierung (sehr starkes Signal) ---
|
||||
normalized_domain = simple_normalize_url(website)
|
||||
if normalized_domain != "k.A.":
|
||||
# Suche nach der Domain im "Weblinks"-Abschnitt oder in der Infobox
|
||||
external_links = soup.select('.external, .infobox a[href*="."]')
|
||||
for link in external_links:
|
||||
href = link.get('href', '')
|
||||
if normalized_domain in href:
|
||||
self.logger.info(f" => VALIDATION SUCCESS (Domain Match): Domain '{normalized_domain}' in Weblinks gefunden.")
|
||||
return True
|
||||
|
||||
# --- Stufe 2: Sitz-Validierung (starkes Signal) ---
|
||||
if crm_city and crm_city.lower() != 'k.a.':
|
||||
infobox_sitz_raw = self._extract_infobox_value(soup, 'sitz')
|
||||
if infobox_sitz_raw and infobox_sitz_raw.lower() != 'k.a.':
|
||||
if crm_city.lower() in infobox_sitz_raw.lower():
|
||||
self.logger.info(f" => VALIDATION SUCCESS (City Match): CRM-Ort '{crm_city}' in Infobox-Sitz '{infobox_sitz_raw}' gefunden.")
|
||||
return True
|
||||
|
||||
# --- Stufe 3: Parent-Validierung ---
|
||||
normalized_parent = normalize_company_name(parent_name) if parent_name else None
|
||||
if normalized_parent:
|
||||
page_content_for_check = (page.title + " " + page.summary).lower()
|
||||
if normalized_parent in page_content_for_check:
|
||||
self.logger.info(f" => VALIDATION SUCCESS (Parent Match): Parent-Name '{parent_name}' im Artikel gefunden.")
|
||||
return True
|
||||
|
||||
# --- Stufe 4: Namensähnlichkeit (Fallback mit strengeren Regeln) ---
|
||||
normalized_company = normalize_company_name(company_name)
|
||||
normalized_title = normalize_company_name(page.title)
|
||||
similarity = fuzzy_similarity(normalized_title, normalized_company)
|
||||
|
||||
if similarity > 0.85: # Strengere Schwelle
|
||||
self.logger.info(f" => VALIDATION SUCCESS (High Similarity): Hohe Namensähnlichkeit ({similarity:.2f}).")
|
||||
return True
|
||||
|
||||
self.logger.debug(f" => VALIDATION FAILED: Kein harter Fakt (Domain, Sitz, Parent) und Ähnlichkeit ({similarity:.2f}) zu gering.")
|
||||
return False
|
||||
|
||||
def search_company_article(self, company_name, website=None, crm_city=None, parent_name=None):
|
||||
"""
|
||||
Sucht und validiert einen passenden Wikipedia-Artikel nach der "Google-First"-Strategie.
|
||||
1. Sucht die beste URL via SerpAPI.
|
||||
2. Validiert den gefundenen Artikel mit harten Fakten.
|
||||
"""
|
||||
if not company_name:
|
||||
return None
|
||||
|
||||
self.logger.info(f"Starte 'Google-First' Wikipedia-Suche für '{company_name}'...")
|
||||
|
||||
# 1. Finde den besten URL-Kandidaten via Google-Suche
|
||||
url_candidate = self.serp_wikipedia_lookup(company_name)
|
||||
|
||||
if not url_candidate:
|
||||
self.logger.warning(f" -> Keine URL via SerpAPI gefunden. Suche abgebrochen.")
|
||||
return None
|
||||
|
||||
# 2. Lade und validiere den gefundenen Artikel
|
||||
try:
|
||||
page_title = unquote(url_candidate.split('/wiki/')[-1].replace('_', ' '))
|
||||
page = wikipedia.page(title=page_title, auto_suggest=False, redirect=True)
|
||||
|
||||
# Nutze die neue, faktenbasierte Validierung
|
||||
if self._validate_article(page, company_name, website, crm_city, parent_name):
|
||||
self.logger.info(f" -> Artikel '{page.title}' erfolgreich validiert.")
|
||||
return page
|
||||
else:
|
||||
self.logger.warning(f" -> Artikel '{page.title}' konnte nicht validiert werden.")
|
||||
return None
|
||||
except wikipedia.exceptions.PageError:
|
||||
self.logger.error(f" -> Fehler: Gefundene URL '{url_candidate}' führte zu keiner gültigen Wikipedia-Seite.")
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.error(f" -> Unerwarteter Fehler bei der Verarbeitung der Seite '{url_candidate}': {e}")
|
||||
return None
|
||||
|
||||
def _extract_first_paragraph_from_soup(self, soup):
|
||||
"""
|
||||
Extrahiert den ersten aussagekraeftigen Absatz aus dem Soup-Objekt eines Wikipedia-Artikels.
|
||||
"""
|
||||
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')
|
||||
|
||||
for p in paragraphs:
|
||||
for sup in p.find_all('sup', class_='reference'): sup.decompose()
|
||||
for span in p.find_all('span', style=lambda v: v and 'display:none' in v): span.decompose()
|
||||
for span in p.find_all('span', id='coordinates'): span.decompose()
|
||||
text = clean_text(p.get_text(separator=' ', strip=True))
|
||||
if text != "k.A." and len(text) > 50 and not re.match(r'^(Datei:|Abbildung:|Siehe auch:|Einzelnachweise|Siehe auch|Literatur)', text, re.IGNORECASE):
|
||||
paragraph_text = text[:1500]
|
||||
break
|
||||
except Exception as e:
|
||||
self.logger.error(f"Fehler beim Extrahieren des ersten Absatzes: {e}")
|
||||
return paragraph_text
|
||||
|
||||
def extract_categories(self, soup):
|
||||
"""
|
||||
Extrahiert Wikipedia-Kategorien aus dem Soup-Objekt.
|
||||
"""
|
||||
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 isinstance(c, str) and c.strip() and "kategorien:" not in c.lower()]
|
||||
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_infobox_value(self, soup, target):
|
||||
"""
|
||||
Extrahiert gezielt Werte (Branche, Umsatz, etc.) aus der Infobox.
|
||||
"""
|
||||
if not soup or target not in self.keywords_map:
|
||||
return "k.A."
|
||||
keywords = self.keywords_map[target]
|
||||
infobox = soup.select_one('table[class*="infobox"]')
|
||||
if not infobox: return "k.A."
|
||||
|
||||
value_found = "k.A."
|
||||
try:
|
||||
rows = infobox.find_all('tr')
|
||||
for row in rows:
|
||||
cells = row.find_all(['th', 'td'], recursive=False)
|
||||
header_text, value_cell = None, None
|
||||
|
||||
if len(cells) >= 2:
|
||||
if cells[0].name == 'th':
|
||||
header_text, value_cell = cells[0].get_text(strip=True), cells[1]
|
||||
elif cells[0].name == 'td' and cells[1].name == 'td':
|
||||
style = cells[0].get('style', '').lower()
|
||||
is_header_like = 'font-weight' in style and ('bold' in style or '700' in style) or cells[0].find(['b', 'strong'], recursive=False)
|
||||
if is_header_like:
|
||||
header_text, value_cell = cells[0].get_text(strip=True), cells[1]
|
||||
|
||||
if header_text and value_cell:
|
||||
if any(kw in header_text.lower() for kw in keywords):
|
||||
for sup in value_cell.find_all(['sup', 'span']):
|
||||
sup.decompose()
|
||||
|
||||
raw_value_text = value_cell.get_text(separator=' ', strip=True)
|
||||
|
||||
if target == 'branche' or target == 'sitz':
|
||||
value_found = clean_text(raw_value_text).split('\n')[0].strip()
|
||||
elif target == 'umsatz':
|
||||
value_found = extract_numeric_value(raw_value_text, is_umsatz=True)
|
||||
elif target == 'mitarbeiter':
|
||||
value_found = extract_numeric_value(raw_value_text, is_umsatz=False)
|
||||
|
||||
value_found = value_found if value_found else "k.A."
|
||||
self.logger.info(f" --> Infobox '{target}' gefunden: '{value_found}'")
|
||||
break
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Fehler beim Durchlaufen der Infobox-Zeilen fuer '{target}': {e}")
|
||||
return "k.A. (Fehler Extraktion)"
|
||||
|
||||
return value_found
|
||||
|
||||
def _parse_sitz_string_detailed(self, raw_sitz_string_input):
|
||||
"""
|
||||
Versucht, aus einem rohen Sitz-String Stadt und Land detailliert zu extrahieren.
|
||||
"""
|
||||
sitz_stadt_val, sitz_land_val = "k.A.", "k.A."
|
||||
if not raw_sitz_string_input or not isinstance(raw_sitz_string_input, str):
|
||||
return {'sitz_stadt': sitz_stadt_val, 'sitz_land': sitz_land_val}
|
||||
|
||||
temp_sitz = raw_sitz_string_input.strip()
|
||||
if not temp_sitz or temp_sitz.lower() == "k.a.":
|
||||
return {'sitz_stadt': sitz_stadt_val, 'sitz_land': sitz_land_val}
|
||||
|
||||
# Diese Mappings könnten in die Config ausgelagert werden
|
||||
known_countries_detailed = {
|
||||
"deutschland": "Deutschland", "germany": "Deutschland", "de": "Deutschland",
|
||||
"österreich": "Österreich", "austria": "Österreich", "at": "Österreich",
|
||||
"schweiz": "Schweiz", "switzerland": "Schweiz", "ch": "Schweiz", "suisse": "Schweiz",
|
||||
"usa": "USA", "u.s.": "USA", "united states": "USA", "vereinigte staaten": "USA",
|
||||
"vereinigtes königreich": "Vereinigtes Königreich", "united kingdom": "Vereinigtes Königreich", "uk": "Vereinigtes Königreich",
|
||||
}
|
||||
region_to_country = {
|
||||
"nrw": "Deutschland", "nordrhein-westfalen": "Deutschland", "bayern": "Deutschland", "hessen": "Deutschland",
|
||||
"zg": "Schweiz", "zug": "Schweiz", "zh": "Schweiz", "zürich": "Schweiz",
|
||||
"ca": "USA", "california": "USA", "ny": "USA", "new york": "USA",
|
||||
}
|
||||
|
||||
extracted_country = ""
|
||||
original_temp_sitz = temp_sitz
|
||||
|
||||
klammer_match = re.search(r'\(([^)]+)\)$', temp_sitz)
|
||||
if klammer_match:
|
||||
suffix_in_klammer = klammer_match.group(1).strip().lower()
|
||||
if suffix_in_klammer in known_countries_detailed:
|
||||
extracted_country = known_countries_detailed[suffix_in_klammer]
|
||||
temp_sitz = temp_sitz[:klammer_match.start()].strip(" ,")
|
||||
elif suffix_in_klammer in region_to_country:
|
||||
extracted_country = region_to_country[suffix_in_klammer]
|
||||
temp_sitz = temp_sitz[:klammer_match.start()].strip(" ,")
|
||||
|
||||
if not extracted_country and ',' in temp_sitz:
|
||||
parts = [p.strip() for p in temp_sitz.split(',')]
|
||||
if len(parts) > 1:
|
||||
last_part_lower = parts[-1].lower()
|
||||
if last_part_lower in known_countries_detailed:
|
||||
extracted_country = known_countries_detailed[last_part_lower]
|
||||
temp_sitz = ", ".join(parts[:-1]).strip(" ,")
|
||||
elif last_part_lower in region_to_country:
|
||||
extracted_country = region_to_country[last_part_lower]
|
||||
temp_sitz = ", ".join(parts[:-1]).strip(" ,")
|
||||
|
||||
sitz_land_val = extracted_country if extracted_country else "k.A."
|
||||
sitz_stadt_val = re.sub(r'^\d{4,8}\s*', '', temp_sitz).strip(" ,")
|
||||
|
||||
if not sitz_stadt_val:
|
||||
sitz_stadt_val = "k.A." if sitz_land_val != "k.A." else re.sub(r'^\d{4,8}\s*', '', original_temp_sitz).strip(" ,") or "k.A."
|
||||
|
||||
return {'sitz_stadt': sitz_stadt_val, 'sitz_land': sitz_land_val}
|
||||
|
||||
@retry_on_failure
|
||||
def extract_company_data(self, url_or_page):
|
||||
"""
|
||||
Extrahiert strukturierte Unternehmensdaten aus einem Wikipedia-Artikel (URL oder page-Objekt).
|
||||
Gibt nun auch den gesamten Rohtext des Artikels ('full_text') und den Titel zurück.
|
||||
"""
|
||||
default_result = {
|
||||
'url': 'k.A.', 'title': 'k.A.', 'sitz_stadt': 'k.A.', 'sitz_land': 'k.A.',
|
||||
'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.',
|
||||
'mitarbeiter': 'k.A.', 'categories': 'k.A.', 'full_text': ''
|
||||
}
|
||||
page = None
|
||||
|
||||
try:
|
||||
if isinstance(url_or_page, str) and "wikipedia.org" in url_or_page:
|
||||
page_title = unquote(url_or_page.split('/wiki/')[-1].replace('_', ' '))
|
||||
page = wikipedia.page(title=page_title, auto_suggest=False, redirect=True)
|
||||
elif not isinstance(url_or_page, str): # Annahme: es ist ein page-Objekt
|
||||
page = url_or_page
|
||||
else:
|
||||
self.logger.warning(f"extract_company_data: Ungültiger Input '{str(url_or_page)[:100]}...'.")
|
||||
return default_result
|
||||
|
||||
self.logger.info(f"Extrahiere Daten für Wiki-Artikel: {page.title[:100]}...")
|
||||
|
||||
# Grundlegende Daten direkt aus dem page-Objekt extrahieren
|
||||
first_paragraph = page.summary.split('\n')[0] if page.summary else 'k.A.'
|
||||
categories = ", ".join(page.categories)
|
||||
full_text = page.content
|
||||
|
||||
# Für Infobox-Daten benötigen wir weiterhin BeautifulSoup, da die 'wikipedia'-Bibliothek
|
||||
# keinen strukturierten Zugriff darauf bietet.
|
||||
soup = self._get_page_soup(page.url)
|
||||
if not soup:
|
||||
self.logger.warning(f" -> Konnte Seite für Soup-Parsing nicht laden. Extrahiere nur Basis-Daten.")
|
||||
# Fallback, wenn Soup fehlschlägt
|
||||
return {
|
||||
'url': page.url, 'title': page.title, 'sitz_stadt': 'k.A.', 'sitz_land': 'k.A.',
|
||||
'first_paragraph': first_paragraph, 'branche': 'k.A.', 'umsatz': 'k.A.',
|
||||
'mitarbeiter': 'k.A.', 'categories': categories, 'full_text': full_text
|
||||
}
|
||||
|
||||
# Extraktion der Infobox-Daten mit den bestehenden Helper-Funktionen
|
||||
branche_val = self._extract_infobox_value(soup, 'branche')
|
||||
umsatz_val = self._extract_infobox_value(soup, 'umsatz')
|
||||
mitarbeiter_val = self._extract_infobox_value(soup, 'mitarbeiter')
|
||||
raw_sitz_string = self._extract_infobox_value(soup, 'sitz')
|
||||
parsed_sitz = self._parse_sitz_string_detailed(raw_sitz_string)
|
||||
sitz_stadt_val = parsed_sitz['sitz_stadt']
|
||||
sitz_land_val = parsed_sitz['sitz_land']
|
||||
|
||||
# Sammle die finalen Daten
|
||||
result = {
|
||||
'url': page.url,
|
||||
'title': page.title,
|
||||
'sitz_stadt': sitz_stadt_val,
|
||||
'sitz_land': sitz_land_val,
|
||||
'first_paragraph': first_paragraph,
|
||||
'branche': branche_val,
|
||||
'umsatz': umsatz_val,
|
||||
'mitarbeiter': mitarbeiter_val,
|
||||
'categories': categories,
|
||||
'full_text': full_text
|
||||
}
|
||||
|
||||
self.logger.info(f" -> Extrahierte Daten: Stadt='{sitz_stadt_val}', Land='{sitz_land_val}', U='{umsatz_val}', M='{mitarbeiter_val}'")
|
||||
return result
|
||||
|
||||
except wikipedia.exceptions.PageError:
|
||||
self.logger.error(f" -> Fehler: Wikipedia-Artikel für '{str(url_or_page)[:100]}' konnte nicht gefunden werden (PageError).")
|
||||
return {**default_result, 'url': str(url_or_page) if isinstance(url_or_page, str) else 'k.A.'}
|
||||
except Exception as e:
|
||||
self.logger.error(f" -> Unerwarteter Fehler bei der Extraktion von '{str(url_or_page)[:100]}': {e}")
|
||||
return {**default_result, 'url': str(url_or_page) if isinstance(url_or_page, str) else 'k.A.'}
|
||||
Reference in New Issue
Block a user