feat(company-explorer): Initial Web UI & Backend with Enrichment Flow

This commit introduces the foundational elements for the new "Company Explorer" web application, marking a significant step away from the legacy Google Sheets / CLI system.

Key changes include:
- Project Structure: A new  directory with separate  (FastAPI) and  (React/Vite) components.
- Data Persistence: Migration from Google Sheets to a local SQLite database () using SQLAlchemy.
- Core Utilities: Extraction and cleanup of essential helper functions (LLM wrappers, text utilities) into .
- Backend Services: , ,  for AI-powered analysis, and  logic.
- Frontend UI: Basic React application with company table, import wizard, and dynamic inspector sidebar.
- Docker Integration: Updated  and  for multi-stage builds and sideloading.
- Deployment & Access: Integrated into central Nginx proxy and dashboard, accessible via .

Lessons Learned & Fixed during development:
- Frontend Asset Loading: Addressed issues with Vite's  path and FastAPI's .
- TypeScript Configuration: Added  and .
- Database Schema Evolution: Solved  errors by forcing a new database file and correcting  override.
- Logging: Implemented robust file-based logging ().

This new foundation provides a powerful and maintainable platform for future B2B robotics lead generation.
This commit is contained in:
2026-01-07 17:55:08 +00:00
parent e27cc995f6
commit c6a37a3c17
51 changed files with 3475 additions and 2 deletions

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View 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 ---

View File

@@ -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()

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View 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

View File

@@ -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()

View 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)

View File

@@ -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.'}