Files
Brancheneinstufung2/data_processor.py
Floke 3a330a1387 v2.0.4: refactor: Integrate Google-First Wikipedia logic
- Umbau von `_process_single_row` im DataProcessor zur Nutzung der neuen Such- und Validierungslogik.
- Anpassung der an `search_company_article` übergebenen Parameter.
- Härtung der Wikipedia-Pipeline gegen fehlgeschlagene Suchen oder Validierungen.
2025-08-04 18:43:16 +00:00

6689 lines
354 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
data_processor.py
Zentrale Klasse zur Orchestrierung und Verarbeitung von Unternehmensdaten.
Enthält die Logik für die Verarbeitung einzelner Zeilen sowie die Steuerung
verschiedener Batch-Modi und Dienstprogramme.
"""
__version__ = "v2.0.4"
import logging
import time
import traceback
import concurrent.futures
import threading
import pickle
import json
import os
import re
from datetime import datetime
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.impute import SimpleImputer
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
from imblearn.over_sampling import SMOTE
from imblearn.pipeline import Pipeline as ImbPipeline
from concurrent.futures import ThreadPoolExecutor, as_completed
# Import der abhängigen Module
from config import Config, COLUMN_MAP, MODEL_FILE, IMPUTER_FILE, PATTERNS_FILE_JSON
from helpers import (
retry_on_failure,
serp_website_lookup,
get_website_raw,
scrape_website_details,
summarize_website_content,
URL_CHECK_MARKER,
evaluate_branche_chatgpt,
get_numeric_filter_value,
initialize_target_schema,
search_linkedin_contacts,
is_valid_wikipedia_article_url,
verify_wiki_article_chatgpt,
generate_fsm_pitch,
get_col_idx,
_get_col_letter,
summarize_wikipedia_article,
evaluate_branches_batch
)
# Klassen-Imports
from google_sheet_handler import GoogleSheetHandler
from wikipedia_scraper import WikipediaScraper
class DataProcessor:
def __init__(self, sheet_handler, wiki_scraper):
self.logger = logging.getLogger(__name__ + ".DataProcessor")
self.logger.info("Initialisiere DataProcessor...")
if not isinstance(sheet_handler, GoogleSheetHandler): raise ValueError("...")
if not isinstance(wiki_scraper, WikipediaScraper): raise ValueError("...")
self.sheet_handler = sheet_handler
self.wiki_scraper = wiki_scraper
self.model = None
self.imputer = None
self._expected_features = None
self.is_setup_complete = False
self.schema_data = None # Wichtig: Neues Attribut
def setup(self):
self.logger.info("Führe DataProcessor-Setup durch...")
# Lade das Branchenschema und speichere es in der Instanz
self.schema_data = initialize_target_schema()
if not self.schema_data:
self.logger.error("Setup fehlgeschlagen: Branchenschema konnte nicht geladen werden.")
self.is_setup_complete = False
return False
self._load_ml_model(MODEL_FILE, IMPUTER_FILE)
self.is_setup_complete = True
self.logger.info("DataProcessor-Setup erfolgreich abgeschlossen.")
return True
def _get_cell_value_safe(self, row, column_key):
"""
Greift sicher auf eine Zelle in einer Zeile zu, basierend auf dem Spaltennamen.
Angepasst an die neue COLUMN_MAP Struktur.
"""
col_info = COLUMN_MAP.get(column_key)
if col_info is None or 'index' not in col_info:
self.logger.error(f"Spalte '{column_key}' oder ihr 'index' nicht im COLUMN_MAP gefunden.")
return ""
idx = col_info['index']
if len(row) > idx:
return row[idx]
return ""
def _needs_website_processing(self, row_data, force_reeval):
"""
Prueft, ob Website-Scraping/Summarization fuer diese Zeile noetig ist.
"""
if force_reeval:
return True
at_value = self._get_cell_value_safe(
row_data, "Website Scrape Timestamp").strip()
if not at_value:
return True
return False
def _needs_wiki_processing(self, row_data, force_reeval):
"""
Prueft, ob Wikipedia-Suche/Extraktion fuer diese Zeile noetig ist.
"""
if force_reeval:
return True
an_value = self._get_cell_value_safe(
row_data, "Wikipedia Timestamp").strip()
if not an_value:
return True
s_value = self._get_cell_value_safe(
row_data, "Chat Wiki Konsistenzpruefung").strip().upper()
if s_value == "X (URL COPIED)":
return True
return False
def _needs_wiki_verification(self, row_data, force_reeval):
"""
Prueft, ob Wikipedia-Verifizierung fuer diese Zeile noetig ist.
"""
if force_reeval:
return True
ax_value = self._get_cell_value_safe(
row_data, "Wiki Verif. Timestamp").strip()
if not ax_value:
m_value = self._get_cell_value_safe(row_data, "Wiki URL").strip()
if m_value and m_value.lower() not in [
"k.a.", "kein artikel gefunden", "fehler bei suche", "http:"]:
return True
return False
def _needs_chat_evaluations(
self,
row_data,
force_reeval,
wiki_data_just_updated):
"""
Prueft, ob ChatGPT-Evaluationen fuer diese Zeile noetig sind.
"""
if force_reeval:
return True
ao_value = self._get_cell_value_safe(
row_data, "Timestamp letzte Pruefung").strip()
if not ao_value:
return True
if wiki_data_just_updated:
return True
return False
def _needs_ml_prediction(self, row_data, force_reeval, chat_eval_just_ran):
"""
Prueft, ob die ML-Schaetzung fuer diese Zeile noetig ist.
"""
if force_reeval:
return True
au_value = self._get_cell_value_safe(
row_data, "Geschaetzter Techniker Bucket").strip()
if not au_value or au_value.lower() in ["k.a.", "fehler schaetzung"]:
if chat_eval_just_ran:
return True
av_value = self._get_cell_value_safe(
row_data, "Finaler Umsatz (Wiki>CRM)").strip()
aw_value = self._get_cell_value_safe(
row_data, "Finaler Mitarbeiter (Wiki>CRM)").strip()
if av_value != "k.A." and not av_value.startswith(
"FEHLER") and aw_value != "k.A." and not aw_value.startswith("FEHLER"):
return True
return False
def _process_single_row(self, row_num_in_sheet, row_data, steps_to_run, force_reeval=False, clear_x_flag=False):
"""
Verarbeitet die Daten fuer eine einzelne Zeile im Sheet. Fuehrt ausgewaehlte
Anreicherungs- und Analyseprozesse durch.
"""
self.logger.info(
f"--- Starte Verarbeitung fuer Zeile {row_num_in_sheet} {'(Re-Eval)' if force_reeval else ''} (Angefragte Schritte: {', '.join(steps_to_run) if steps_to_run else 'Keine'}) ---")
# NEU: Detaillierteres Logging der auszuführenden Schritte
run_reasons = []
if 'web' in steps_to_run and self._needs_website_processing(row_data, force_reeval):
run_reasons.append("WEB (Timestamp leer oder Re-Eval)")
if 'wiki' in steps_to_run and self._needs_wiki_processing(row_data, force_reeval):
run_reasons.append("WIKI (Timestamp leer oder Re-Eval)")
if 'chat' in steps_to_run and self._needs_chat_evaluations(row_data, force_reeval, False): # False, da wir es nur für die Logik prüfen
run_reasons.append("CHAT (Timestamp leer oder Re-Eval)")
if 'ml_predict' in steps_to_run and self._needs_ml_prediction(row_data, force_reeval, False):
run_reasons.append("ML (Bucket leer oder Re-Eval)")
if run_reasons:
self.logger.info(f"Zeile {row_num_in_sheet}: Fuehre Schritte aus -> {', '.join(run_reasons)}")
updates = []
now_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
any_processing_done = False
wiki_data_updated_in_this_run = False
chat_eval_just_ran = False
# --- Initiale Werte lesen ---
company_name = self._get_cell_value_safe(row_data, "CRM Name").strip()
website_url = self._get_cell_value_safe(
row_data, "CRM Website").strip()
crm_kurzform = self._get_cell_value_safe(
row_data, "CRM Kurzform").strip()
crm_branche = self._get_cell_value_safe(
row_data, "CRM Branche").strip()
crm_beschreibung = self._get_cell_value_safe(
row_data, "CRM Beschreibung").strip()
parent_account_name_d = self._get_cell_value_safe(
row_data, "Parent Account Name").strip()
current_wiki_data = {
'url': self._get_cell_value_safe(row_data, "Wiki URL") or 'k.A.',
'sitz_stadt': self._get_cell_value_safe(row_data, "Wiki Sitz Stadt") or 'k.A.',
'sitz_land': self._get_cell_value_safe(row_data, "Wiki Sitz Land") or 'k.A.',
'first_paragraph': self._get_cell_value_safe(row_data, "Wiki Absatz") or 'k.A.',
'branche': self._get_cell_value_safe(row_data, "Wiki Branche") or 'k.A.',
'umsatz': self._get_cell_value_safe(row_data, "Wiki Umsatz") or 'k.A.',
'mitarbeiter': self._get_cell_value_safe(row_data, "Wiki Mitarbeiter") or 'k.A.',
'categories': self._get_cell_value_safe(row_data, "Wiki Kategorien") or 'k.A.'
}
final_wiki_data = current_wiki_data.copy()
website_raw = self._get_cell_value_safe(
row_data, "Website Rohtext") or 'k.A.'
website_summary = self._get_cell_value_safe(
row_data, "Website Zusammenfassung") or 'k.A.'
website_meta_details = self._get_cell_value_safe(
row_data, "Website Meta-Details") or 'k.A.'
url_pruefstatus = self._get_cell_value_safe(
row_data, "URL Prüfstatus") or ''
run_website_step = 'web' in steps_to_run
website_processing_needed = self._needs_website_processing(
row_data, force_reeval)
if run_website_step and website_processing_needed:
any_processing_done = True
grund_message = "Re-Eval" if force_reeval else "Timestamp (AJ) leer"
self.logger.info(
f"Zeile {row_num_in_sheet}: Fuehre WEBSITE Schritte aus (Grund: {grund_message})...")
# WICHTIG: url_pruefstatus muss initialisiert werden, um Fehler zu vermeiden
url_pruefstatus = self._get_cell_value_safe(row_data, "URL Prüfstatus")
if not website_url or website_url.lower() == "k.a.":
self.logger.debug(
" -> Website URL (E) leer, suche ueber SERP...")
try:
new_website = serp_website_lookup(company_name)
if new_website and new_website.lower(
) != "k.a." and not new_website.startswith("k.A. (Fehler"):
website_url = new_website
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("CRM Website") + 1)}{row_num_in_sheet}',
'values': [
[website_url]]})
url_pruefstatus = "URL_OK_SERP"
else:
url_pruefstatus = "URL_SERP_FAILED" if not website_url or website_url.lower(
) == "k.a." else url_pruefstatus
except Exception as e_serp_lookup_web:
self.logger.error(
f"FEHLER bei SERP Website Lookup für '{company_name}': {e_serp_lookup_web}")
url_pruefstatus = "URL_SERP_ERROR"
if website_url and website_url.lower() not in ["k.a.", "http:"]:
self.logger.debug(
f" -> Scrape Rohtext & Meta von {website_url[:100]}...")
try:
website_raw = get_website_raw(website_url)
if website_raw == URL_CHECK_MARKER:
url_pruefstatus = URL_CHECK_MARKER
website_summary, website_meta_details = "k.A. (URL prüfen)", "k.A. (URL prüfen)"
elif website_raw and str(website_raw).strip().lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]:
url_pruefstatus = "URL_OK_SCRAPED"
website_meta_details = scrape_website_details(
website_url) or "k.A. (Keine Meta-Details)"
# Verbessert: company_name für besseren Kontext übergeben
website_summary = summarize_website_content(
website_raw, company_name) or "k.A. (Keine Zusammenfassung erhalten)"
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Website Meta-Details") + 1)}{row_num_in_sheet}',
'values': [
[website_meta_details]]})
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Website Zusammenfassung") + 1)}{row_num_in_sheet}',
'values': [
[website_summary]]})
else:
if not str(website_raw).startswith("k.A. (Fehler"):
url_pruefstatus = "URL_SCRAPE_EMPTY_OR_BANNER"
website_summary, website_meta_details = "k.A.", "k.A."
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Website Zusammenfassung") + 1)}{row_num_in_sheet}',
'values': [
[website_summary]]})
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Website Meta-Details") + 1)}{row_num_in_sheet}',
'values': [
[website_meta_details]]})
except Exception as e_scrape_web:
self.logger.error(
f"FEHLER beim Website Scraping für '{company_name}': {e_scrape_web}")
website_raw, website_summary, website_meta_details = f"k.A. (Fehler Scraping: {str(e_scrape_web)[:50]})", "k.A.", "k.A."
url_pruefstatus = "URL_SCRAPE_ERROR"
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Website Zusammenfassung") + 1)}{row_num_in_sheet}',
'values': [
[website_summary]]})
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Website Meta-Details") + 1)}{row_num_in_sheet}',
'values': [
[website_meta_details]]})
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Website Rohtext") + 1)}{row_num_in_sheet}',
'values': [
[website_raw]]})
else:
self.logger.debug(
f" -> Keine gültige Website URL für '{company_name}'. Web-Verarbeitung übersprungen.")
website_raw, website_summary, website_meta_details = "k.A. (Keine URL)", "k.A.", "k.A."
if not url_pruefstatus:
url_pruefstatus = "URL_MISSING"
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Website Rohtext") + 1)}{row_num_in_sheet}',
'values': [
[website_raw]]})
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Website Zusammenfassung") + 1)}{row_num_in_sheet}',
'values': [
[website_summary]]})
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Website Meta-Details") + 1)}{row_num_in_sheet}',
'values': [
[website_meta_details]]})
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("URL Prüfstatus") + 1)}{row_num_in_sheet}',
'values': [
[url_pruefstatus]]})
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Website Scrape Timestamp") + 1)}{row_num_in_sheet}',
'values': [
[now_timestamp]]})
# ======================================================================
# === 2. Wikipedia Handling (Google-First Strategie) =================
# ======================================================================
run_wiki_step = 'wiki' in steps_to_run
wiki_processing_needed = self._needs_wiki_processing(row_data, force_reeval)
if run_wiki_step and wiki_processing_needed:
any_processing_done = True
wiki_data_updated_in_this_run = False # Wird auf True gesetzt, wenn neue Daten extrahiert werden
self.logger.info(f"Zeile {row_num_in_sheet}: Starte Wikipedia-Pipeline (Google-First)...")
page_obj = None
# Stufe 1: Prüfen, ob eine manuelle URL in Spalte R vorhanden und valide ist
manual_url = self._get_cell_value_safe(row_data, "Wiki URL").strip()
if manual_url and "wikipedia.org" in manual_url.lower():
self.logger.debug(f" -> Prüfe manuelle URL aus Spalte R: {manual_url}")
try:
page_title = unquote(manual_url.split('/wiki/')[-1].replace('_', ' '))
page_candidate = wikipedia.page(title=page_title, auto_suggest=False, redirect=True)
# Die Validierung benötigt zusätzliche Daten aus der Zeile
crm_city = self._get_cell_value_safe(row_data, "CRM Ort")
parent_name_for_validation = self._get_cell_value_safe(row_data, "Parent Account Name")
if self.wiki_scraper._validate_article(page_candidate, company_name, website_url, crm_city, parent_name_for_validation):
self.logger.info(f" -> Manuelle URL '{manual_url}' erfolgreich validiert.")
page_obj = page_candidate
else:
self.logger.warning(f" -> Manuelle URL '{manual_url}' konnte nicht validiert werden. Starte Google-Suche als Fallback.")
except Exception as e:
self.logger.error(f" -> Fehler bei Verarbeitung der manuellen URL '{manual_url}': {e}")
# Stufe 2: Wenn keine valide manuelle URL, starte die "Google-First" Suche
if not page_obj:
crm_city = self._get_cell_value_safe(row_data, "CRM Ort")
parent_name_for_search = self._get_cell_value_safe(row_data, "Parent Account Name")
page_obj = self.wiki_scraper.search_company_article(company_name, website_url, crm_city, parent_name_for_search)
# Stufe 3: Ergebnisse verarbeiten und schreiben
if page_obj:
self.logger.debug(f" -> Extrahiere Daten aus validiertem Artikel: '{page_obj.title}'")
extracted_content = self.wiki_scraper.extract_company_data(page_obj)
# Erstelle zusätzlich eine KI-Zusammenfassung des Rohtextes
new_summary = summarize_wikipedia_article(extracted_content.get('full_text'), company_name)
extracted_content['first_paragraph'] = new_summary
final_wiki_data.update(extracted_content)
wiki_data_updated_in_this_run = True
else:
self.logger.warning(f" -> Keine passende & validierte URL gefunden. Setze Wiki-Daten auf 'Kein Artikel gefunden'.")
no_article_data = {key: 'Kein Artikel gefunden' for key in final_wiki_data.keys()}
no_article_data['url'] = 'Kein Artikel gefunden' # Sicherstellen, dass auch die URL zurückgesetzt wird
final_wiki_data.update(no_article_data)
wiki_data_updated_in_this_run = True
# Schreibe die finalen Wiki-Daten (entweder extrahiert oder "Kein Artikel gefunden")
key_mapping = {
'Wiki URL': 'url', 'Wiki Sitz Stadt': 'sitz_stadt', 'Wiki Sitz Land': 'sitz_land',
'Wiki Absatz': 'first_paragraph', 'Wiki Branche': 'branche', 'Wiki Umsatz': 'umsatz',
'Wiki Mitarbeiter': 'mitarbeiter', 'Wiki Kategorien': 'categories'
}
for sheet_col_name, data_key in key_mapping.items():
col_idx = get_col_idx(sheet_col_name)
if col_idx is not None:
updates.append({'range': f'{_get_col_letter(col_idx + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get(data_key, 'k.A.')]]})
# Setze immer den Timestamp, um zu zeigen, dass dieser Prozess gelaufen ist
updates.append({'range': f'{_get_col_letter(get_col_idx("Wikipedia Timestamp") + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]})
# --- 3. ChatGPT Evaluationen (Branch, FSM, etc.) & Plausi ---
run_chat_step = 'chat' in steps_to_run
# KORREKTUR: Initialisiere chat_steps_to_run, um den NameError zu beheben
chat_steps_to_run = set()
chat_processing_needed = self._needs_chat_evaluations(
row_data, force_reeval, wiki_data_updated_in_this_run)
if run_chat_step and chat_processing_needed:
any_processing_done = True
chat_eval_just_ran = True
grund_message_chat = "Re-Eval" if force_reeval else (
"Wiki-Daten aktualisiert" if wiki_data_updated_in_this_run else "Timestamp (BN) leer")
self.logger.info(
f"Zeile {row_num_in_sheet}: Fuehre CHATGPT Evaluationen & Plausi aus (Grund: {grund_message_chat})...")
# --- 3. ChatGPT Evaluationen (Branch, FSM, etc.) & Plausi ---
run_chat_step = 'chat' in steps_to_run
chat_processing_needed = self._needs_chat_evaluations(
row_data, force_reeval, wiki_data_updated_in_this_run)
if run_chat_step and chat_processing_needed:
any_processing_done = True
chat_eval_just_ran = True
grund_message_chat = "Re-Eval" if force_reeval else (
"Wiki-Daten aktualisiert" if wiki_data_updated_in_this_run else "Timestamp (BN) leer")
self.logger.info(
f"Zeile {row_num_in_sheet}: Fuehre CHATGPT Evaluationen & Plausi aus (Grund: {grund_message_chat})...")
# --- 3a. Branchen-Einstufung ---
self.logger.info(f" Zeile {row_num_in_sheet}: Starte Branchen-Einstufung 2.0 ueber ChatGPT...")
try:
# Rufe die neue, kontextbasierte Funktion mit den korrekten Parametern auf
branch_result = evaluate_branche_chatgpt(
company_name=company_name,
website_summary=website_summary,
wiki_absatz=final_wiki_data.get('first_paragraph', 'k.A.')
)
# Das Ergebnis von Version 2.0 ist bereits ein sauberes Dictionary
updates.append({'range': f'{_get_col_letter(get_col_idx("Chat Vorschlag Branche") + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('branch', 'FEHLER')]]})
updates.append({'range': f'{_get_col_letter(get_col_idx("Chat Branche Konfidenz") + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('confidence', 'N/A')]]})
updates.append({'range': f'{_get_col_letter(get_col_idx("Chat Konsistenz Branche") + 1)}{row_num_in_sheet}', 'values': [['V2.0']]}) # Markierung, dass V2.0 lief
updates.append({'range': f'{_get_col_letter(get_col_idx("Chat Begruendung Abweichung Branche") + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('justification', 'k.A.')]]})
except Exception as e_branch_eval:
self.logger.error(f"FEHLER bei Branchen-Einstufung für Zeile {row_num_in_sheet}: {e_branch_eval}", exc_info=True)
error_updates = [
{"key": "Chat Vorschlag Branche", "value": "FEHLER_CALL"},
{"key": "Chat Branche Konfidenz", "value": "N/A"},
{"key": "Chat Begruendung Abweichung Branche", "value": str(e_branch_eval)[:100]}
]
for item in error_updates:
col_idx = get_col_idx(item["key"])
if col_idx is not None:
updates.append({
'range': f'{_get_col_letter(col_idx + 1)}{row_num_in_sheet}',
'values': [[item["value"]]]
})
# --- NEUER SCHRITT: FSM Pitch generieren ---
if 'fsm_pitch' in chat_steps_to_run and self._needs_fsm_pitch(row_data, force_reeval):
self.logger.info(f" -> Generiere FSM Pitch...")
try:
# Daten für den Pitch sammeln, die in diesem Durchlauf relevant sind
# Priorisiere die frisch generierte Branche, falle zurück auf Sheet-Daten
ki_branche = branch_result.get("branch") if branch_result else self._get_cell_value_safe(row_data, "Chat Vorschlag Branche")
if not ki_branche or "FEHLER" in ki_branche:
ki_branche = self._get_cell_value_safe(row_data, "CRM Branche")
# Rufe die neue, verbesserte Pitch-Funktion auf
fsm_pitch_text = generate_fsm_pitch(
company_name=self._get_cell_value_safe(row_data, "CRM Name"),
company_short_name=self._get_cell_value_safe(row_data, "CRM Kurzform"),
ki_branche=ki_branche,
website_summary=self._get_cell_value_safe(row_data, "Website Zusammenfassung"),
wiki_absatz=final_wiki_data.get('first_paragraph'), # Nutze die frischen Wiki-Daten
anzahl_ma=self._get_cell_value_safe(row_data, "Finaler Mitarbeiter (Wiki>CRM)"),
anzahl_techniker=self._get_cell_value_safe(row_data, "CRM Anzahl Techniker"),
techniker_bucket_ml=self._get_cell_value_safe(row_data, "Geschaetzter Techniker Bucket")
)
updates.append({'range': f'{_get_col_letter(get_col_idx("FSM Pitch") + 1)}{row_num_in_sheet}', 'values': [[fsm_pitch_text]]})
updates.append({'range': f'{_get_col_letter(get_col_idx("FSM Pitch Timestamp") + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]})
any_processing_done = True
chat_eval_just_ran = True # Signal, dass eine KI-Aktion stattfand
except Exception as e_fsm_pitch:
self.logger.error(f"FEHLER bei FSM-Pitch-Generierung für Zeile {row_num_in_sheet}: {e_fsm_pitch}")
updates.append({'range': f'{_get_col_letter(get_col_idx("FSM Pitch") + 1)}{row_num_in_sheet}', 'values': [['FEHLER (Generierung)']]} )
# 3b, 3c, 3d: Weitere ChatGPT-Evaluationen (hier nicht detailliert implementiert, aber Platzhalter)
# ... Logik für FSM-Relevanz, Mitarbeiter-Schätzung, Umsatz-Schätzung, etc. ...
# 3e. Konsolidierung Umsatz/Mitarbeiter (BD, BE)
self.logger.debug(f" Zeile {row_num_in_sheet}: Konsolidiere Umsatz (BD) und Mitarbeiter (BE)...")
final_umsatz_str_konsolidiert = "k.A."
final_ma_str_konsolidiert = "k.A."
try:
crm_umsatz_val_str = self._get_cell_value_safe(
row_data, "CRM Umsatz")
wiki_umsatz_val_str = final_wiki_data.get('umsatz', 'k.A.')
crm_ma_val_str = self._get_cell_value_safe(
row_data, "CRM Anzahl Mitarbeiter")
wiki_ma_val_str = final_wiki_data.get('mitarbeiter', 'k.A.')
num_crm_umsatz = get_numeric_filter_value(
crm_umsatz_val_str, is_umsatz=True)
num_wiki_umsatz = get_numeric_filter_value(
wiki_umsatz_val_str, is_umsatz=True)
num_crm_ma = get_numeric_filter_value(
crm_ma_val_str, is_umsatz=False)
num_wiki_ma = get_numeric_filter_value(
wiki_ma_val_str, is_umsatz=False)
if parent_account_name_d and parent_account_name_d.lower() != 'k.a.':
self.logger.info(
f" -> Parent D ('{parent_account_name_d}') gesetzt. Konsolidiere primär mit CRM-Daten der Tochter.")
final_num_umsatz = num_crm_umsatz if num_crm_umsatz > 0 else num_wiki_umsatz
final_num_ma = num_crm_ma if num_crm_ma > 0 else num_wiki_ma
else:
self.logger.debug(
f" -> Parent D leer. Standardkonsolidierung Wiki > CRM.")
final_num_umsatz = num_wiki_umsatz if num_wiki_umsatz > 0 else num_crm_umsatz
final_num_ma = num_wiki_ma if num_wiki_ma > 0 else num_crm_ma
final_umsatz_str_konsolidiert = str(
int(round(final_num_umsatz))) if final_num_umsatz > 0 else 'k.A.'
final_ma_str_konsolidiert = str(
int(round(final_num_ma))) if final_num_ma > 0 else 'k.A.'
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Finaler Umsatz (Wiki>CRM)") + 1)}{row_num_in_sheet}',
'values': [
[final_umsatz_str_konsolidiert]]})
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Finaler Mitarbeiter (Wiki>CRM)") + 1)}{row_num_in_sheet}',
'values': [
[final_ma_str_konsolidiert]]})
except Exception as e_consolidate:
self.logger.error(
f"FEHLER bei Konsolidierung (BD/BE) für Zeile {row_num_in_sheet}: {e_consolidate}")
final_umsatz_str_konsolidiert, final_ma_str_konsolidiert = "FEHLER_KONSO", "FEHLER_KONSO"
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Finaler Umsatz (Wiki>CRM)") + 1)}{row_num_in_sheet}',
'values': [
['FEHLER_KONSO']]})
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Finaler Mitarbeiter (Wiki>CRM)") + 1)}{row_num_in_sheet}',
'values': [
['FEHLER_KONSO']]})
# 3f. Plausibilitäts-Checks (BG-BM)
self.logger.debug(
f" Zeile {row_num_in_sheet}: Führe Plausibilitäts-Checks durch...")
try:
plausi_input_data = {
"Finaler Umsatz (Wiki>CRM)": final_umsatz_str_konsolidiert,
"Finaler Mitarbeiter (Wiki>CRM)": final_ma_str_konsolidiert,
"CRM Umsatz": self._get_cell_value_safe(
row_data,
"CRM Umsatz"),
"Wiki Umsatz": final_wiki_data.get(
'umsatz',
'k.A.'),
"CRM Anzahl Mitarbeiter": self._get_cell_value_safe(
row_data,
"CRM Anzahl Mitarbeiter"),
"Wiki Mitarbeiter": final_wiki_data.get(
'mitarbeiter',
'k.A.'),
"Parent Account Name": parent_account_name_d,
"System Vorschlag Parent Account": self._get_cell_value_safe(
row_data,
"System Vorschlag Parent Account"),
"Parent Vorschlag Status": self._get_cell_value_safe(
row_data,
"Parent Vorschlag Status")}
plausi_results = self._check_financial_plausibility(
plausi_input_data)
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Plausibilität Umsatz") + 1)}{row_num_in_sheet}',
'values': [
[
plausi_results.get(
"plaus_umsatz_flag",
"ERR_FLAG")]]})
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Plausibilität Mitarbeiter") + 1)}{row_num_in_sheet}',
'values': [
[
plausi_results.get(
"plaus_ma_flag",
"ERR_FLAG")]]})
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Plausibilität Umsatz/MA Ratio") + 1)}{row_num_in_sheet}',
'values': [
[
plausi_results.get(
"plaus_ratio_flag",
"ERR_FLAG")]]})
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Abweichung Umsatz CRM/Wiki") + 1)}{row_num_in_sheet}',
'values': [
[
plausi_results.get(
"abweichung_umsatz_flag",
"ERR_FLAG")]]})
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Abweichung MA CRM/Wiki") + 1)}{row_num_in_sheet}',
'values': [
[
plausi_results.get(
"abweichung_ma_flag",
"ERR_FLAG")]]})
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Plausibilität Begründung") + 1)}{row_num_in_sheet}',
'values': [
[
plausi_results.get(
"plausi_begruendung_final",
"Fehler Begr.")]]})
except Exception as e_plausi:
self.logger.error(
f"FEHLER bei Plausi-Checks für Zeile {row_num_in_sheet}: {e_plausi}")
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Plausibilität Prüfdatum") + 1)}{row_num_in_sheet}',
'values': [
[now_timestamp]]})
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Timestamp letzte Pruefung") + 1)}{row_num_in_sheet}',
'values': [
[now_timestamp]]})
# --- 4. Servicetechniker Schaetzung (ML Modell) ---
run_ml_step = 'ml_predict' in steps_to_run
ml_processing_needed = self._needs_ml_prediction(
row_data, force_reeval, chat_eval_just_ran)
if run_ml_step and ml_processing_needed:
any_processing_done = True
self.logger.info(
f"Zeile {row_num_in_sheet}: Fuehre ML-Schaetzung aus...")
try:
predicted_bucket = self._predict_technician_bucket(row_data)
if predicted_bucket and isinstance(
predicted_bucket,
str) and not predicted_bucket.startswith("FEHLER"):
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Geschaetzter Techniker Bucket") + 1)}{row_num_in_sheet}',
'values': [
[predicted_bucket]]})
self.logger.info(
f" -> ML-Schaetzung erfolgreich: Bucket '{predicted_bucket}'.")
else:
self.logger.warning(
f" -> ML-Schaetzung lieferte kein gueltiges Ergebnis: '{predicted_bucket}'.")
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Geschaetzter Techniker Bucket") + 1)}{row_num_in_sheet}',
'values': [
['k.A. (Schaetzung fehlgeschlagen)']]})
except Exception as e_ml:
self.logger.error(
f"FEHLER bei ML-Schaetzung fuer Zeile {row_num_in_sheet}: {e_ml}")
self.logger.debug(traceback.format_exc())
updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Geschaetzter Techniker Bucket") + 1)}{row_num_in_sheet}',
'values': [
[f'FEHLER Schaetzung: {str(e_ml)[:50]}...']]})
# ======================================================================
# === Abschluss der _process_single_row Verarbeitung ==================
# ======================================================================
# --- 5. Abschliessende Updates (Version, Tokens, ReEval Flag) ---
if any_processing_done:
version_col_idx = get_col_idx("Version") # KORRIGIERT
if version_col_idx is not None:
updates.append(
{
'range': f'{_get_col_letter(version_col_idx + 1)}{row_num_in_sheet}',
'values': [
[
getattr(
Config,
'VERSION',
'unknown')]]})
else:
self.logger.error(
"FEHLER: Spaltenschluessel 'Version' nicht in COLUMN_MAP gefunden.")
if force_reeval and clear_x_flag:
# KORRIGIERT: Nutze die sichere get_col_idx Funktion
reeval_col_idx = get_col_idx("ReEval Flag")
if reeval_col_idx is not None:
flag_col_letter = _get_col_letter(
reeval_col_idx + 1)
if flag_col_letter:
updates.append(
{'range': f'{flag_col_letter}{row_num_in_sheet}', 'values': [['']]})
self.logger.debug(
f" -> Update zum Loeschen des ReEval-Flags (A{row_num_in_sheet}) vorgemerkt.")
else:
self.logger.error(
f"FEHLER: Konnte Spaltenbuchstaben fuer 'ReEval Flag' nicht ermitteln.")
else:
self.logger.error(
"FEHLER: 'ReEval Flag' Spaltenindex nicht in COLUMN_MAP gefunden.")
# --- 6. Batch Update fuer diese Zeile ---
if updates:
self.logger.info(
f"Zeile {row_num_in_sheet}: Sende Batch-Update mit {len(updates)} Operationen...")
success = self.sheet_handler.batch_update_cells(updates)
if not success:
self.logger.error(
f"Zeile {row_num_in_sheet}: ENDGUELTIGER FEHLER beim Batch-Update nach Retries.")
else:
if not any_processing_done:
self.logger.debug(
f"Zeile {row_num_in_sheet}: Keine Updates zum Schreiben.")
pause_duration = max(0.05, getattr(Config, 'RETRY_DELAY', 5) / 20.0)
time.sleep(pause_duration)
self.logger.info(
f"--- Verarbeitung fuer Zeile {row_num_in_sheet} abgeschlossen ---")
# ==========================================================================
# === Prozess Methoden (Sequentiell & Re-Evaluation) =====================
# ==========================================================================
def process_website_scraping(self, start_sheet_row=None, end_sheet_row=None, limit=None):
"""
Batch-Prozess NUR für Website-Scraping (Rohtext & Meta-Details).
Diese Version ist stabilisiert, nutzt einen robusten Worker (_scrape_website_task_batch)
und verarbeitet strukturierte Dictionary-Ergebnisse, inklusive des URL-Prüfstatus.
"""
self.logger.info(f"Starte Website-Scraping (Batch, v2.0.7). Bereich: {start_sheet_row or 'Start'}-{end_sheet_row or 'Ende'}, Limit: {limit or 'Unbegrenzt'}")
# --- 1. Daten laden und Startzeile ermitteln ---
if start_sheet_row is None:
self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren 'Website Scrape Timestamp'...")
start_data_idx = self.sheet_handler.get_start_row_index(check_column_key="Website Scrape Timestamp")
if start_data_idx == -1:
self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche Batch ab.")
return
start_sheet_row = start_data_idx + self.sheet_handler._header_rows + 1
self.logger.info(f"Automatisch ermittelte Startzeile: {start_sheet_row}")
if not self.sheet_handler.load_data():
self.logger.error("FEHLER beim Laden der Daten für Batch-Verarbeitung.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = self.sheet_handler._header_rows
total_sheet_rows = len(all_data)
effective_end_row = end_sheet_row if end_sheet_row is not None else total_sheet_rows
self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {effective_end_row}.")
if start_sheet_row > effective_end_row:
self.logger.info("Start liegt nach dem Ende. Keine Zeilen zu verarbeiten.")
return
# --- 2. Spalten-Indizes und Buchstaben vorbereiten ---
rohtext_col_letter = _get_col_letter(get_col_idx("Website Rohtext") + 1)
metadetails_col_letter = _get_col_letter(get_col_idx("Website Meta-Details") + 1)
pruefstatus_col_letter = _get_col_letter(get_col_idx("URL Prüfstatus") + 1) # NEU
version_col_letter = _get_col_letter(get_col_idx("Version") + 1)
timestamp_col_letter = _get_col_letter(get_col_idx("Website Scrape Timestamp") + 1)
# --- 3. Tasks sammeln ---
processing_batch_size = getattr(Config, 'PROCESSING_BATCH_SIZE', 20)
max_scraping_workers = getattr(Config, 'MAX_SCRAPING_WORKERS', 10)
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)
tasks_for_processing_batch = []
all_sheet_updates = []
processed_count = 0
skipped_count = 0
for i in range(start_sheet_row, effective_end_row + 1):
row_index_in_list = i - 1
if row_index_in_list >= total_sheet_rows: break
row = all_data[row_index_in_list]
if not any(cell and str(cell).strip() for cell in row):
skipped_count += 1
continue
if self._needs_website_processing(row, force_reeval=False):
website_url = self._get_cell_value_safe(row, "CRM Website").strip()
if website_url and website_url.lower() not in ["k.a.", "http:"]:
if limit is not None and processed_count >= limit:
self.logger.info(f"Verarbeitungslimit ({limit}) erreicht.")
break
tasks_for_processing_batch.append({"row_num": i, "url": website_url})
processed_count += 1
else:
skipped_count += 1
else:
skipped_count += 1
# --- 4. Batch-Verarbeitung auslösen ---
if len(tasks_for_processing_batch) >= processing_batch_size or (i == effective_end_row and tasks_for_processing_batch):
self.logger.info(f"--- Starte Website-Scraping Batch für {len(tasks_for_processing_batch)} Tasks (max. {max_scraping_workers} Worker) ---")
scraping_results = {}
batch_error_count = 0
with ThreadPoolExecutor(max_workers=max_scraping_workers) as executor:
future_to_task = {executor.submit(self._scrape_website_task_batch, task): task for task in tasks_for_processing_batch}
for future in as_completed(future_to_task):
task = future_to_task[future]
try:
result_dict = future.result()
if isinstance(result_dict, dict) and 'row_num' in result_dict:
scraping_results[result_dict['row_num']] = result_dict
if result_dict.get('error'):
batch_error_count += 1
self.logger.warning(f"Worker meldete Fehler für Zeile {result_dict['row_num']}: {result_dict.get('status_message')}")
else:
self.logger.error(f"Inkonsistentes Ergebnis für Zeile {task['row_num']}: Erwartete dict mit 'row_num', bekam {type(result_dict)}. Überspringe.")
scraping_results[task['row_num']] = {'raw_text': "FEHLER (Inkonsistenter Rückgabetyp)", 'meta_details': 'k.A.', 'url_pruefstatus': 'URL_SCRAPE_ERROR', 'error': True}
batch_error_count += 1
except Exception as exc:
self.logger.error(f"Unerwarteter Fehler bei Ergebnisabfrage für Zeile {task['row_num']}: {exc}", exc_info=True)
scraping_results[task['row_num']] = {'raw_text': "FEHLER (Task Exception)", 'meta_details': 'k.A.', 'url_pruefstatus': 'URL_SCRAPE_ERROR', 'error': True}
batch_error_count += 1
self.logger.info(f" -> Scraping für Batch beendet. {len(scraping_results)} Ergebnisse erhalten ({batch_error_count} davon mit Fehlern).")
# --- 5. Updates für das Google Sheet vorbereiten ---
if scraping_results:
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
current_version = getattr(Config, 'VERSION', 'unknown')
for row_num, res_dict in scraping_results.items():
all_sheet_updates.append({'range': f'{rohtext_col_letter}{row_num}', 'values': [[res_dict.get('raw_text', 'k.A.')]]})
all_sheet_updates.append({'range': f'{metadetails_col_letter}{row_num}', 'values': [[res_dict.get('meta_details', 'k.A.')]]})
all_sheet_updates.append({'range': f'{pruefstatus_col_letter}{row_num}', 'values': [[res_dict.get('url_pruefstatus', 'URL_SCRAPE_ERROR')]]}) # NEU
all_sheet_updates.append({'range': f'{timestamp_col_letter}{row_num}', 'values': [[current_timestamp]]})
all_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]})
tasks_for_processing_batch = []
# --- 6. Sheet-Update auslösen, wenn Update-Batch voll ist ---
if len(all_sheet_updates) >= (update_batch_row_limit * 5): # NEU: 5 Updates pro Zeile
self.logger.info(f"Sende gesammelte Sheet-Updates ({len(all_sheet_updates) // 5} Zeilen)...")
self.sheet_handler.batch_update_cells(all_sheet_updates)
all_sheet_updates = []
time.sleep(1)
# --- 7. Finale Updates senden ---
if all_sheet_updates:
self.logger.info(f"Sende finale gesammelte Sheet-Updates ({len(all_sheet_updates) // 5} Zeilen)...")
self.sheet_handler.batch_update_cells(all_sheet_updates)
self.logger.info(f"Website-Scraping (Batch) abgeschlossen. {processed_count} Zeilen zur Verarbeitung ausgewählt, {skipped_count} Zeilen übersprungen.")
def _scrape_website_task_batch(self, task_info):
"""
Robuste Worker-Funktion für das parallele Scrapen von Websites im Batch-Modus.
Diese Funktion holt Rohtext sowie Meta-Details und gibt IMMER ein strukturiertes
Dictionary zurück, das auch den programmatischen URL-Prüfstatus enthält.
NEU: Enthält Logik zur Erkennung von "Thin Content" und Cookie-Bannern.
"""
row_num = task_info['row_num']
url = task_info['url']
self.logger.debug(f" -> Batch-Scrape-Task gestartet für Zeile {row_num}: {url}")
result = {
'row_num': row_num,
'raw_text': 'k.A. (Fehler im Task)',
'meta_details': 'k.A. (Fehler im Task)',
'error': True,
'status_message': 'Unbekannter Task-Fehler',
'url_pruefstatus': 'URL_SCRAPE_ERROR' # Default-Fehlerstatus
}
try:
# 1. Rohtext abrufen (get_website_raw aus helpers.py)
raw_text_result = get_website_raw(url)
# 2. Ergebnis des Rohtext-Abrufs auswerten
if raw_text_result == URL_CHECK_MARKER:
result.update({
'raw_text': "k.A. (URL prüfen)", 'meta_details': "k.A.", 'error': True,
'status_message': "URL als nicht erreichbar markiert", 'url_pruefstatus': URL_CHECK_MARKER
})
elif raw_text_result and not str(raw_text_result).strip().lower().startswith('k.a.'):
# --- NEUER BLOCK: Thin Content & Cookie-Banner-Erkennung ---
text_len = len(raw_text_result)
text_lower_sample = raw_text_result[:600].lower() # Prüfe nur den Anfang
cookie_keywords = ['cookie', 'zustimmen', 'akzeptieren', 'einwilligung', 'datenschutz', 'ablehnen', 'einstellungen']
keyword_hits = sum(1 for keyword in cookie_keywords if keyword in text_lower_sample)
if text_len < 500 and keyword_hits >= 3:
self.logger.warning(f"Zeile {row_num}: Potenzieller Cookie-Banner erkannt (Länge: {text_len}, Keyword-Treffer: {keyword_hits}).")
result.update({
'raw_text': raw_text_result, 'meta_details': "k.A.", 'error': True,
'status_message': "Nur Cookie-Banner erkannt", 'url_pruefstatus': 'URL_SCRAPE_COOKIE_BANNER'
})
elif text_len < 200:
self.logger.warning(f"Zeile {row_num}: 'Thin Content' erkannt (Länge: {text_len}).")
result.update({
'raw_text': raw_text_result, 'meta_details': "k.A.", 'error': True,
'status_message': "Inhalt zu kurz", 'url_pruefstatus': 'URL_SCRAPE_THIN_CONTENT'
})
else:
# Dies ist der reguläre Erfolgsfall
meta_details_result = scrape_website_details(url)
result.update({
'raw_text': raw_text_result, 'meta_details': meta_details_result if meta_details_result else "k.A.",
'error': False, 'status_message': 'Erfolgreich gescraped', 'url_pruefstatus': 'URL_OK_SCRAPED'
})
# --- ENDE NEUER BLOCK ---
elif str(raw_text_result).strip().lower().startswith('k.a.'):
result['raw_text'] = raw_text_result # Fehlerstring übernehmen
result['meta_details'] = "k.A."
result['error'] = True
match = re.search(r'\((.*?)\)', raw_text_result)
result['status_message'] = match.group(1) if match else "Scraping fehlgeschlagen"
result['url_pruefstatus'] = "URL_SCRAPE_ERROR"
else: # Fallback für unerwartete leere Ergebnisse
result.update({
'raw_text': 'k.A. (Extraktion leer)', 'meta_details': 'k.A.', 'error': True,
'status_message': 'Extraktion lieferte leeren Text', 'url_pruefstatus': "URL_SCRAPE_EMPTY_OR_BANNER"
})
return result
except Exception as e:
self.logger.error(f" -> Kritischer Fehler im Worker-Task `_scrape_website_task_batch` für Zeile {row_num}: {e}")
# Das `result` Dictionary wird mit den initialen Fehlerwerten zurückgegeben.
return result
def _summarize_task_batch(self, task_info):
"""
Robuste Worker-Funktion für die parallele Website-Zusammenfassung.
Wird vom ThreadPoolExecutor in `process_summarize_website` aufgerufen.
Gibt IMMER ein strukturiertes Dictionary zurück.
"""
row_num = task_info['row_num']
raw_text = task_info['raw_text']
company_name = task_info.get('company_name', 'einem Unternehmen')
self.logger.debug(f" -> Batch-Summarize-Task gestartet für Zeile {row_num}...")
result = {
'row_num': row_num,
'summary': 'k.A. (Fehler im Task)',
'error': True,
'status_message': 'Unbekannter Task-Fehler'
}
try:
# Ruft die gehärtete Single-Item-Funktion aus helpers.py auf
summary_text = summarize_website_content(raw_text, company_name)
if summary_text and not summary_text.lower().startswith('k.a.'):
result['summary'] = summary_text
result['error'] = False
result['status_message'] = 'Erfolgreich zusammengefasst'
else:
result['summary'] = summary_text # Fehlergrund übernehmen
result['error'] = True
result['status_message'] = 'Zusammenfassung fehlgeschlagen oder Text zu kurz'
return result
except Exception as e:
self.logger.error(f" -> Kritischer Fehler im Worker-Task `_summarize_task_batch` für Zeile {row_num}: {e}")
result['summary'] = f"FEHLER (API-Fehler bei Zusammenfassung)"
result['status_message'] = f"Kritischer Task-Fehler: {type(e).__name__}"
return result
def process_rows_sequentially(
self,
start_sheet_row,
num_to_process,
process_wiki_steps=True,
process_chatgpt_steps=True,
process_website_steps=True,
process_ml_steps=True,
force_reeval_in_single_row=False):
"""
Verarbeitet eine feste Anzahl von Zeilen beginnend bei einer bestimmten
Sheet-Zeilennummer sequentiell.
"""
header_rows = self.sheet_handler._header_rows
if num_to_process is None or not isinstance(
num_to_process, int) or num_to_process <= 0:
self.logger.info(
"Sequentielle Verarbeitung uebersprungen: num_to_process ist ungueltig oder <= 0.")
return
self.logger.info(
f"Starte sequentielle Verarbeitung von {num_to_process} Zeilen ab Sheet-Zeile {start_sheet_row}...")
selected_steps_log = []
if process_wiki_steps:
selected_steps_log.append("Wiki (wiki)")
if process_chatgpt_steps:
selected_steps_log.append("ChatGPT (chat)")
if process_website_steps:
selected_steps_log.append("Website (web)")
if process_ml_steps:
selected_steps_log.append("ML Predict (ml_predict)")
self.logger.info(
f" Ausgewaehlte Schritte: {', '.join(selected_steps_log) if selected_steps_log else 'Keine'}")
if force_reeval_in_single_row:
self.logger.warning(
" !!! force_reeval=True wird fuer alle Zeilen in _process_single_row gesetzt !!!")
steps_to_run_set = set()
if process_wiki_steps:
steps_to_run_set.add('wiki')
if process_chatgpt_steps:
steps_to_run_set.add('chat')
if process_website_steps:
steps_to_run_set.add('web')
if process_ml_steps:
steps_to_run_set.add('ml_predict')
if not steps_to_run_set:
self.logger.warning(
"Keine Verarbeitungsschritte ausgewaehlt. Modus wird uebersprungen.")
return
if not self.sheet_handler.load_data():
self.logger.error(
"Fehler beim Laden der Daten fuer sequentielle Verarbeitung.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
total_sheet_rows = len(all_data)
start_index_in_all_data = start_sheet_row - 1
if start_index_in_all_data >= total_sheet_rows:
self.logger.warning(
f"Start-Sheet-Zeile {start_sheet_row} liegt ausserhalb der Daten. Keine Verarbeitung.")
return
if start_index_in_all_data < header_rows:
self.logger.warning(
f"Start-Sheet-Zeile {start_sheet_row} liegt in Headern. Starte ab Zeile {header_rows + 1}.")
start_index_in_all_data = header_rows
end_index_in_all_data = min(
start_index_in_all_data +
num_to_process,
total_sheet_rows)
self.logger.info(
f"Sequentielle Verarbeitung: Index-Bereich [{start_index_in_all_data}, {end_index_in_all_data}).")
if start_index_in_all_data >= end_index_in_all_data:
self.logger.info(
f"Keine Zeilen im definierten Bereich zu verarbeiten.")
return
processed_count = 0
for i in range(start_index_in_all_data, end_index_in_all_data):
row_num_in_sheet = i + 1
row_data = all_data[i]
if row_num_in_sheet <= header_rows:
continue
if not any(cell and isinstance(cell, str) and cell.strip()
for cell in row_data):
self.logger.debug(
f"Ueberspringe leere Zeile {row_num_in_sheet}.")
continue
try:
self._process_single_row(
row_num_in_sheet=row_num_in_sheet,
row_data=row_data,
steps_to_run=steps_to_run_set,
force_reeval=force_reeval_in_single_row,
clear_x_flag=False
)
processed_count += 1
except Exception as e_proc:
self.logger.exception(
f"FEHLER bei sequentieller Verarbeitung von Zeile {row_num_in_sheet}: {e_proc}")
self.logger.info(
f"Sequentielle Verarbeitung abgeschlossen. {processed_count} Zeilen bearbeitet.")
def process_reevaluation_rows(
self,
row_limit=None,
clear_flag=True,
process_wiki_steps=True,
process_chatgpt_steps=True,
process_website_steps=True,
process_ml_steps=True):
"""
Verarbeitet nur Zeilen, die in Spalte A mit 'x' markiert sind.
Leert zuerst alle abgeleiteten Spalten für eine saubere Neubewertung.
"""
self.logger.info(
f"Starte Re-Evaluierungsmodus (Spalte A = 'x'). Max. Zeilen: {row_limit if row_limit is not None else 'Unbegrenzt'}")
if not self.sheet_handler.load_data():
self.logger.error("Fehler beim Laden der Daten fuer Re-Evaluation.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = self.sheet_handler._header_rows
if not all_data or len(all_data) <= header_rows:
self.logger.warning("Keine Datenzeilen fuer Re-Evaluation gefunden.")
return
column_names = list(COLUMN_MAP.keys())
try:
reeval_col_idx = column_names.index("ReEval Flag")
except ValueError:
self.logger.critical("FEHLER: 'ReEval Flag' nicht in COLUMN_MAP. Breche ab.")
return
rows_to_process = []
for idx, row_data in enumerate(all_data):
if idx < header_rows: continue
if self._get_cell_value_safe(row_data, "ReEval Flag").strip().lower() == "x":
rows_to_process.append({'row_num': idx + 1, 'data': row_data})
found_count = len(rows_to_process)
self.logger.info(f"{found_count} Zeilen mit ReEval-Flag 'x' gefunden.")
if found_count == 0: return
# Spalten definieren, die vor der Neubewertung geleert werden sollen
cols_to_clear_keys = [
"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", "Finaler Umsatz (Wiki>CRM)", "Finaler Mitarbeiter (Wiki>CRM)",
"Geschaetzter Techniker Bucket", "Plausibilität Umsatz", "Plausibilität Mitarbeiter",
"Plausibilität Umsatz/MA Ratio", "Abweichung Umsatz CRM/Wiki", "Abweichung MA CRM/Wiki",
"Plausibilität Begründung", "Plausibilität Prüfdatum", "Timestamp letzte Pruefung", "Version", "Tokens",
"FSM Pitch", "FSM Pitch Timestamp"
]
clear_updates = []
for task in rows_to_process:
row_num = task['row_num']
for key in cols_to_clear_keys:
try:
col_idx = column_names.index(key)
col_letter = _get_col_letter(col_idx + 1)
clear_updates.append({'range': f'{col_letter}{row_num}', 'values': [['']]})
except ValueError:
self.logger.warning(f"Spalte '{key}' zum Leeren im Re-Eval-Modus nicht in COLUMN_MAP gefunden. Überspringe.")
if clear_updates:
self.logger.info(f"Leere {len(clear_updates)} Zellen für {found_count} Re-Eval-Zeilen zur Vorbereitung...")
self.sheet_handler.batch_update_cells(clear_updates)
self.logger.info("Vorbereitung abgeschlossen. Starte eigentliche Verarbeitung...")
time.sleep(2)
self.sheet_handler.load_data()
processed_count_actual = 0
steps_to_run_set = set(key for key, value in {'wiki': process_wiki_steps, 'chat': process_chatgpt_steps, 'web': process_website_steps, 'ml_predict': process_ml_steps}.items() if value)
fresh_data = self.sheet_handler.get_all_data_with_headers()
for task in rows_to_process:
if row_limit is not None and processed_count_actual >= row_limit:
self.logger.info(f"Zeilenlimit ({row_limit}) fuer Re-Evaluation erreicht.")
break
current_row_data = fresh_data[task['row_num'] - 1]
self.logger.info(f"Bearbeite Re-Eval Zeile {task['row_num']}...")
processed_count_actual += 1
try:
self._process_single_row(
row_num_in_sheet=task['row_num'],
row_data=current_row_data,
steps_to_run=steps_to_run_set,
force_reeval=True,
clear_x_flag=clear_flag
)
except Exception as e_proc_reval:
self.logger.exception(f"FEHLER bei Re-Evaluation von Zeile {task['row_num']}: {e_proc_reval}")
self.logger.info(f"Re-Evaluierung abgeschlossen. {processed_count_actual} Zeilen verarbeitet.")
def process_wiki_verify(self, limit=None, start_sheet_row=None, end_sheet_row=None):
"""
Iteriert durch die Zeilen und führt eine ChatGPT-basierte Verifizierung des
in Spalte R ("Wiki URL") gefundenen Artikels durch.
Validiert von der KI vorgeschlagene URLs auf Existenz.
"""
BATCH_SIZE = 20
self.logger.info(f"Starte Modus: Wiki-Verifizierung via ChatGPT (Batch-Größe: {BATCH_SIZE})...")
if not self.sheet_handler.load_data():
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = self.sheet_handler._header_rows
start_row_idx = (start_sheet_row - 1) if start_sheet_row is not None else header_rows
end_row_idx = (end_sheet_row - 1) if end_sheet_row is not None else len(all_data) - 1
rows_to_process = all_data[start_row_idx : end_row_idx + 1]
processed_count_in_batch = 0
total_processed_since_start = 0
all_updates = []
for idx, row_data in enumerate(rows_to_process):
current_row_num = start_row_idx + idx + 1
if limit is not None and total_processed_since_start >= limit:
self.logger.info(f"Globales Limit von {limit} Zeilen erreicht. Stoppe.")
break
verif_timestamp = self._get_cell_value_safe(row_data, "Wiki Verif. Timestamp").strip()
wiki_url = self._get_cell_value_safe(row_data, "Wiki URL").strip()
if not verif_timestamp and wiki_url and "wikipedia.org" in wiki_url.lower():
self.logger.info(f"Zeile {current_row_num}: Verifizierung nötig, füge zur Queue hinzu.")
try:
# --- START KORREKTURBLOCK ---
# 1. Sammle alle notwendigen Daten für den neuen, kontextreichen Aufruf
company_name = self._get_cell_value_safe(row_data, "CRM Name")
website = self._get_cell_value_safe(row_data, "CRM Website")
parent_name = self._get_cell_value_safe(row_data, "System Vorschlag Parent Account")
# 2. Lade den Artikel-Kontext
self.logger.debug(f" Lade Kontext für Artikel: {wiki_url}")
page_content = self.wiki_scraper.extract_company_data(wiki_url)
if not page_content or 'FEHLER' in str(page_content.get('title')):
self.logger.error(f" Konnte Kontext für URL {wiki_url} nicht laden. Überspringe KI-Verifizierung für diese Zeile.")
continue # Gehe zur nächsten Zeile
# 3. Rufe die neue, korrekte Funktion auf
verification_result = verify_wiki_article_chatgpt(
company_name=company_name,
parent_name=parent_name,
website=website,
wiki_title=page_content.get('title', 'k.A.'),
wiki_summary=page_content.get('first_paragraph', 'k.A.')
)
# --- ENDE KORREKTURBLOCK ---
# Ihre bestehende Logik zur Validierung der vorgeschlagenen URL bleibt erhalten
final_suggested_url = verification_result.get("suggestion", "") # Geändert von "suggested_url"
if final_suggested_url and "wikipedia.org" in final_suggested_url.lower():
if not is_valid_wikipedia_article_url(final_suggested_url):
self.logger.warning(f" -> KI-Vorschlag '{final_suggested_url}' ist eine ungültige URL.")
final_suggested_url = f"Vorschlag (ungültig): {final_suggested_url}"
else:
self.logger.info(f" -> KI-Vorschlag '{final_suggested_url}' ist eine gültige URL.")
now_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Ihre bestehende Update-Logik, aber mit der sicheren get_col_idx Funktion
updates_for_row = [
{'range': f'{_get_col_letter(get_col_idx("Chat Wiki Konsistenzpruefung") + 1)}{current_row_num}', 'values': [[verification_result.get("consistency")]]},
{'range': f'{_get_col_letter(get_col_idx("Chat Begründung Wiki Inkonsistenz") + 1)}{current_row_num}', 'values': [[verification_result.get("justification")]]},
{'range': f'{_get_col_letter(get_col_idx("Chat Vorschlag Wiki Artikel") + 1)}{current_row_num}', 'values': [[final_suggested_url]]},
{'range': f'{_get_col_letter(get_col_idx("Wiki Verif. Timestamp") + 1)}{current_row_num}', 'values': [[now_timestamp]]}
]
all_updates.extend(updates_for_row)
processed_count_in_batch += 1
total_processed_since_start += 1
if processed_count_in_batch >= BATCH_SIZE:
self.logger.info(f"Batch-Größe ({BATCH_SIZE}) erreicht. Schreibe {len(all_updates)} Zell-Updates...")
self.sheet_handler.batch_update_cells(all_updates)
self.logger.info("Batch erfolgreich geschrieben.")
all_updates = []
processed_count_in_batch = 0
time.sleep(1)
except Exception as e:
self.logger.error(f"Unerwarteter Fehler bei Verarbeitung von Zeile {current_row_num}: {e}", exc_info=True)
else:
self.logger.debug(f"Zeile {current_row_num}: Übersprungen (Timestamp vorhanden oder keine URL).")
if all_updates:
self.logger.info(f"Schleife beendet. Schreibe die letzten {len(all_updates)} Zell-Updates...")
self.sheet_handler.batch_update_cells(all_updates)
self.logger.info("Finaler Batch erfolgreich geschrieben.")
self.logger.info(f"Wiki-Verifizierung abgeschlossen. {total_processed_since_start} Zeilen insgesamt verarbeitet.")
def process_fsm_pitch(self, start_sheet_row=None, end_sheet_row=None, limit=None):
"""
Generiert FSM-Pitches für alle Zeilen, bei denen der Pitch oder der Timestamp fehlt.
Nutzt die neue, verbesserte generate_fsm_pitch Funktion.
"""
self.logger.info(f"Starte Batch-Modus 'fsm_pitch'. Bereich: {start_sheet_row or 'Start'}-{end_sheet_row or 'Ende'}, Limit: {limit or 'Unbegrenzt'}")
if not self.sheet_handler.load_data():
self.logger.error("Fehler beim Laden der Daten für FSM-Pitch-Generierung.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = self.sheet_handler._header_rows
effective_start = start_sheet_row if start_sheet_row is not None else header_rows + 1
effective_end = end_sheet_row if end_sheet_row is not None else len(all_data)
tasks = []
for i in range(effective_start - 1, effective_end):
if limit is not None and len(tasks) >= limit:
break
row_data = all_data[i]
timestamp = self._get_cell_value_safe(row_data, "FSM Pitch Timestamp").strip()
pitch = self._get_cell_value_safe(row_data, "FSM Pitch").strip()
if not timestamp or "fehler" in pitch.lower():
company_name = self._get_cell_value_safe(row_data, "CRM Name").strip()
if company_name:
tasks.append({'row_num': i + 1, 'data': row_data})
if not tasks:
self.logger.info("Keine Zeilen gefunden, die eine FSM-Pitch-Generierung erfordern.")
return
self.logger.info(f"{len(tasks)} Zeilen für FSM-Pitch-Generierung identifiziert. Starte Verarbeitung...")
all_sheet_updates = []
processed_count = 0
update_batch_size = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)
now_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for idx, task in enumerate(tasks):
row_num = task['row_num']
row_data = task['data']
self.logger.debug(f"Verarbeite FSM-Pitch für Zeile {row_num}...")
try:
# Rufe die neue, verbesserte Pitch-Funktion mit Daten aus dem Sheet auf
fsm_pitch_text = generate_fsm_pitch(
company_name=self._get_cell_value_safe(row_data, "CRM Name"),
company_short_name=self._get_cell_value_safe(row_data, "CRM Kurzform"),
ki_branche=self._get_cell_value_safe(row_data, "Chat Vorschlag Branche"),
website_summary=self._get_cell_value_safe(row_data, "Website Zusammenfassung"),
wiki_absatz=self._get_cell_value_safe(row_data, "Wiki Absatz"),
anzahl_ma=self._get_cell_value_safe(row_data, "Finaler Mitarbeiter (Wiki>CRM)"),
anzahl_techniker=self._get_cell_value_safe(row_data, "CRM Anzahl Techniker"),
techniker_bucket_ml=self._get_cell_value_safe(row_data, "Geschaetzter Techniker Bucket")
)
all_sheet_updates.append({'range': f'{_get_col_letter(get_col_idx("FSM Pitch") + 1)}{row_num}', 'values': [[fsm_pitch_text]]})
except Exception as e:
self.logger.error(f"FEHLER bei FSM-Pitch-Generierung für Zeile {row_num}: {e}")
all_sheet_updates.append({'range': f'{_get_col_letter(get_col_idx("FSM Pitch") + 1)}{row_num}', 'values': [['FEHLER (Generierung)']]} )
# IMMER den Timestamp setzen, auch bei Fehler, um Endlosschleifen zu vermeiden
all_sheet_updates.append({'range': f'{_get_col_letter(get_col_idx("FSM Pitch Timestamp") + 1)}{row_num}', 'values': [[now_timestamp]]})
processed_count += 1
# Batch-Update Logik
if (idx + 1) % update_batch_size == 0 or (idx + 1) == len(tasks):
if all_sheet_updates:
num_rows_in_batch = len(all_sheet_updates) // 2
self.logger.info(f"Sende Batch-Update für {num_rows_in_batch} FSM-Pitches (Verarbeitet bisher: {processed_count}/{len(tasks)})...")
self.sheet_handler.batch_update_cells(all_sheet_updates)
all_sheet_updates = []
time.sleep(1)
self.logger.info(f"FSM-Pitch-Generierung abgeschlossen. {processed_count} Zeilen bearbeitet.")
def reclassify_all_branches(self, start_sheet_row=None, limit=None, batch_size=50):
"""
Führt für alle relevanten Zeilen eine neue Brancheneinstufung (v2.0) in Batches durch.
Nutzt nun auch die externe Branchenbeschreibung.
"""
self.logger.info(f"Starte Modus 'reclassify_branches' im Batch-Modus (Größe: {batch_size}). Bereich: {start_sheet_row or 'Start'}, Limit: {limit or 'Unbegrenzt'}")
if not self.sheet_handler.load_data():
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = self.sheet_handler._header_rows
# Definiere den Startpunkt. Wenn kein start_sheet_row übergeben wird, starte nach dem Header.
effective_start = start_sheet_row if start_sheet_row is not None else header_rows + 1
tasks = []
# Die Schleife beginnt beim korrekten Startpunkt
for i in range(effective_start - 1, len(all_data)):
# Das Limit wird HIER geprüft, nicht vorher
if limit is not None and len(tasks) >= limit:
self.logger.info(f"Limit von {limit} Zeilen erreicht. Stoppe das Sammeln von Tasks.")
break
row_data = all_data[i]
company_name = self._get_cell_value_safe(row_data, "CRM Name").strip()
# Der Check auf "firmennamen" ist gut und bleibt
if company_name and "firmennamen" not in company_name.lower():
tasks.append({'row_num': i + 1, 'data': row_data})
if not tasks:
self.logger.info("Keine Zeilen zur Neubewertung gefunden.")
return
self.logger.info(f"{len(tasks)} Zeilen für die Neubewertung der Branche identifiziert.")
all_sheet_updates = []
now_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for i in range(0, len(tasks), batch_size):
batch_tasks = tasks[i:i + batch_size]
self.logger.info(f"Verarbeite Batch {i//batch_size + 1}/{(len(tasks) + batch_size - 1)//batch_size} (Zeilen {batch_tasks[0]['row_num']} bis {batch_tasks[-1]['row_num']})...")
companies_data_for_prompt = []
for task in batch_tasks:
row_data = task['data']
companies_data_for_prompt.append({
"row_num": task['row_num'],
"name": self._get_cell_value_safe(row_data, "CRM Name"),
# NEU: Spalte J hinzufügen
"external_branch_desc": self._get_cell_value_safe(row_data, "CRM Beschreibung Branche extern"),
"summary": self._get_cell_value_safe(row_data, "Website Zusammenfassung"),
"wiki": self._get_cell_value_safe(row_data, "Wiki Absatz")
})
batch_results = evaluate_branches_batch(companies_data_for_prompt)
# ... (Rest der Funktion zum Verarbeiten der Ergebnisse und Schreiben der Updates bleibt unverändert) ...
if batch_results:
results_by_row = {res['row_num']: res for res in batch_results}
for task in batch_tasks:
row_num = task['row_num']
result = results_by_row.get(row_num)
if result:
all_sheet_updates.append({'range': f'{_get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"]["index"] + 1)}{row_num}', 'values': [[result.get('Branche')]]})
all_sheet_updates.append({'range': f'{_get_col_letter(COLUMN_MAP["Chat Branche Konfidenz"]["index"] + 1)}{row_num}', 'values': [[result.get('Konfidenz')]]})
all_sheet_updates.append({'range': f'{_get_col_letter(COLUMN_MAP["Chat Begruendung Abweichung Branche"]["index"] + 1)}{row_num}', 'values': [[result.get('Begruendung')]]})
else:
all_sheet_updates.append({'range': f'{_get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"]["index"] + 1)}{row_num}', 'values': [['FEHLER (Batch-Antwort)']]} )
all_sheet_updates.append({'range': f'{_get_col_letter(COLUMN_MAP["Timestamp letzte Pruefung"]["index"] + 1)}{row_num}', 'values': [[now_timestamp]]})
else:
self.logger.error(f"Batch-Verarbeitung fehlgeschlagen. Setze Fehlerstatus.")
for task in batch_tasks:
row_num = task['row_num']
all_sheet_updates.append({'range': f'{_get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"]["index"] + 1)}{row_num}', 'values': [['FEHLER (Batch-API)']]} )
all_sheet_updates.append({'range': f'{_get_col_letter(COLUMN_MAP["Timestamp letzte Pruefung"]["index"] + 1)}{row_num}', 'values': [[now_timestamp]]})
if all_sheet_updates:
self.logger.info(f"Sende finales Batch-Update für {len(tasks)} bewertete Branchen...")
self.sheet_handler.batch_update_cells(all_sheet_updates)
self.logger.info("Branchen-Neubewertung im Batch-Modus abgeschlossen.")
# ==========================================================================
# === Batch Processing Methods ===========================================
# ==========================================================================
@retry_on_failure
def _process_verification_openai_batch(self, batch_data):
"""
Verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen ueber OpenAI.
"""
if not batch_data:
return {}
self.logger.debug(
f"Sende OpenAI-Batch fuer Wiki-Verifizierung ({len(batch_data)} Zeilen)...")
aggregated_prompt = (
"Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln fuer Unternehmen. "
"Fuer jeden der folgenden Eintraege pruefe, ob der vorhandene Wikipedia-Artikel plausibel zum Firmennamen und zur Beschreibung passt. "
"Gib das Ergebnis fuer jeden Eintrag ausschliesslich im folgenden Format auf einer neuen Zeile aus:\n"
"Eintrag <Zeilennummer>: <Antwort>\n\n"
"Moegliche Antworten:\n"
"- 'OK' (wenn der Artikel gut passt)\n"
"- 'X | Alternativer Artikel: <URL> | Begruendung: <Kurze Begruendung>'\n"
"- 'X | Kein passender Artikel gefunden | Begruendung: <Kurze Begruendung>'\n\n"
"Eintraege zur Pruefung:\n--------------------\n")
max_desc_length = 200
for item in batch_data:
entry_text = (
f"Eintrag {item['row_num']}:\n"
f" Firmenname: {str(item.get('company_name', 'k.A.'))}\n"
f" CRM-Beschreibung: {str(item.get('crm_desc', 'k.A.'))[:max_desc_length]}\n"
f" Wikipedia-URL: {str(item.get('wiki_url', 'k.A.'))}\n"
f" Wiki-Absatz: {str(item.get('wiki_paragraph', 'k.A.'))[:max_desc_length]}\n"
f" Wiki-Kategorien: {str(item.get('wiki_categories', 'k.A.'))[:max_desc_length]}\n----\n")
aggregated_prompt += entry_text
try:
# KORREKTUR: Direkter Aufruf des robusten Helpers 'call_openai_chat' aus helpers.py
from helpers import call_openai_chat
chat_response = call_openai_chat(
aggregated_prompt, temperature=0.0)
if not chat_response:
raise APIError(
"Keine Antwort von OpenAI erhalten.", request=None, body=None)
except Exception as e:
self.logger.error(
f"Endgueltiger FEHLER beim OpenAI-Batch-Aufruf fuer Wiki-Verifizierung: {e}")
return {
item['row_num']: f"FEHLER API: {str(e)[:100]}" for item in batch_data}
answers = {}
original_batch_row_nums = {item['row_num'] for item in batch_data}
lines = chat_response.strip().split('\n')
for line in lines:
match = re.match(r"Eintrag (\d+): (.*)", line.strip())
if match:
row_num, answer_text = int(
match.group(1)), match.group(2).strip()
if row_num in original_batch_row_nums:
answers[row_num] = answer_text
for row_num in original_batch_row_nums:
if row_num not in answers:
answers[row_num] = "FEHLER: Antwort nicht geparst"
return answers
def process_verification_batch(
self,
start_sheet_row=None,
end_sheet_row=None,
limit=None):
"""
Batch-Prozess nur fuer Wikipedia-Verifizierung.
"""
self.logger.info(
f"Starte Wikipedia-Verifizierungsmodus (Batch). Bereich: {start_sheet_row if start_sheet_row else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit else 'Unbegrenzt'}")
if start_sheet_row is None:
start_data_index = self.sheet_handler.get_start_row_index(
check_column_key="Wiki Verif. Timestamp")
if start_data_index == -1:
return
start_sheet_row = start_data_index + self.sheet_handler._header_rows + 1
else:
if not self.sheet_handler.load_data():
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = self.sheet_handler._header_rows
total_sheet_rows = len(all_data)
if end_sheet_row is None:
end_sheet_row = total_sheet_rows
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
return
# ... (Implementation of task collection, batching, and updating remains similar to the original code) ...
# This is a simplified placeholder as the logic is very long and
# already captured in the original file.
"""
Verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen ueber OpenAI.
Sammelt die Ergebnisse und gibt sie zurueck. Aktualisiert NICHT das Sheet direkt.
Args:
batch_data (list): Liste von Dictionaries, jedes enthaelt:
{'row_num': int, 'company_name': str, 'crm_desc': str,
'wiki_url': str, 'wiki_paragraph': str, 'wiki_categories': str}
Returns:
dict: Ein Dictionary, das Zeilennummern auf die rohe ChatGPT-Antwort mappt.
z.B. {2122: "OK", 2123: "X | ..."}
Bei Fehlern oder fehlenden Antworten wird ein Fehlerstring verwendet.
Wirft Exception bei endgueltigen API-Fehlern nach Retries.
"""
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
if not batch_data:
return {} # Gebe leeres Dictionary zurueck, wenn keine Tasks da sind
self.logger.debug(
f"Sende OpenAI-Batch fuer Wiki-Verifizierung ({len(batch_data)} Zeilen, Start: {batch_data[0]['row_num'] if batch_data else 'N/A'})...") # <<< GEÄNDERT
# --- Prompt Erstellung ---
# Verwenden Sie klare Anweisungen und das definierte Antwortformat.
# Vermeiden Sie Umlaute im Prompt, um Encoding-Probleme zu minimieren.
aggregated_prompt = (
"Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln fuer Unternehmen. "
"Fuer jeden der folgenden Eintraege pruefe, ob der vorhandene Wikipedia-Artikel (URL, Absatz, Kategorien) plausibel zum Firmennamen und zur Beschreibung passt. "
"Gib das Ergebnis fuer jeden Eintrag ausschliesslich im folgenden Format auf einer neuen Zeile aus:\n"
"Eintrag <Zeilennummer>: <Antwort>\n\n"
"Moegliche Antworten:\n"
"- 'OK' (wenn der Artikel gut passt)\n"
"- 'X | Alternativer Artikel: <URL> | Begruendung: <Kurze Begruendung>' (wenn der Artikel nicht passt, aber ein besserer gefunden wurde)\n"
"- 'X | Kein passender Artikel gefunden | Begruendung: <Kurze Begruendung>' (wenn der Artikel nicht passt und kein besserer gefunden wurde)\n"
# Der Fall "Kein Wikipedia-Eintrag vorhanden" wird vom Skript VOR diesem Call behandelt
# und sollte hier nicht vom KI-Modell generiert werden.
"Stelle sicher, dass du nur EINE Zeile pro Eintrag im Format 'Eintrag X: Antwort' ausgibst.\n\n"
"Eintraege zur Pruefung:\n"
"--------------------\n"
)
# Fuegen Sie die Daten fuer jeden Eintrag im Batch hinzu.
# Kuerzen Sie die Beschreibungen und Kategorien, um das Prompt-Limit zu reduzieren.
# Stellen Sie sicher, dass die Werte Strings sind und "k.A." richtig
# behandelt wird.
max_desc_length = 200 # Maximale Laenge fuer Beschreibungsteile im Prompt
for item in batch_data:
row_num = item['row_num']
# Holen und Kuerzen Sie die Werte sicher. Ersetzen Sie None durch
# "k.A.".
company_name = str(item.get('company_name', 'k.A.'))
crm_desc = str(item.get('crm_desc', 'k.A.'))
wiki_url = str(item.get('wiki_url', 'k.A.'))
wiki_paragraph = str(item.get('wiki_paragraph', 'k.A.'))
wiki_categories = str(item.get('wiki_categories', 'k.A.'))
# Kuerzen Sie die Laengen und fuegen Sie "..." hinzu, wenn gekuerzt
# wurde.
crm_desc_short = crm_desc[:max_desc_length] + \
'...' if len(crm_desc) > max_desc_length else crm_desc
wiki_paragraph_short = wiki_paragraph[:max_desc_length] + '...' if len(
wiki_paragraph) > max_desc_length else wiki_paragraph
wiki_categories_short = wiki_categories[:max_desc_length] + '...' if len(
wiki_categories) > max_desc_length else wiki_categories
entry_text = (
f"Eintrag {row_num}:\n"
f" Firmenname: {company_name}\n"
f" CRM-Beschreibung: {crm_desc_short}\n"
f" Wikipedia-URL: {wiki_url}\n"
f" Wiki-Absatz: {wiki_paragraph_short}\n"
f" Wiki-Kategorien: {wiki_categories_short}\n"
f"----\n"
)
aggregated_prompt += entry_text
# Fuegen Sie den Abschluss des Prompts hinzu.
aggregated_prompt += "--------------------\nBitte nur die 'Eintrag X: Antwort'-Zeilen ausgeben."
# Optional: Token zaehlen fuer den Prompt.
# try: prompt_tokens = token_count(aggregated_prompt, model=getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo')); self.logger.debug(f"Geschaetzt Prompt-Tokens fuer Batch: {prompt_tokens}.");
# except Exception as e_tc: self.logger.debug(f"Fehler beim
# Token-Zaehlen: {e_tc}");
# --- ChatGPT Aufruf ---
# call_openai_chat (Block 8) nutzt den retry_on_failure Decorator und wirft bei endgueltigem Fehler eine Exception.
# Der retry_on_failure Decorator auf dieser Funktion faengt die Exception
# von call_openai_chat und fuehrt die Retries fuer die GESAMTE
# Batch-Funktion durch.
chat_response = None
try:
# Rufe die zentrale OpenAI Chat API Funktion auf (Block 8).
# Standard Temperatur 0.0 fuer Klassifizierung/Verifizierung.
chat_response = call_openai_chat(
aggregated_prompt, temperature=0.0)
# Wenn call_openai_chat erfolgreich ist, gibt es den String zurueck.
# Exceptions werden nach Retries von call_openai_chat geworfen und
# vom aeusseren retry_on_failure dieser Funktion gefangen.
if not chat_response:
# Dieser Fall sollte nach der Aenderung in call_openai_chat
# (wirft Exception) nicht mehr auftreten.
self.logger.error(
"call_openai_chat gab unerwarteterweise None zurueck fuer Wiki-Verifizierungs-Batch.") # <<< GEÄNDERT
# Werfen Sie eine spezifische Exception, damit der aeussere
# Decorator sie faengt.
raise openai.error.APIError(
"Keine Antwort von OpenAI erhalten fuer Wiki-Verifizierungs-Batch.")
except Exception as e:
# Wenn call_openai_chat oder der aeussere retry_on_failure eine Exception wirft (nach Retries)
# Die Exception wird hier gefangen, bevor sie an den Aufrufer
# (process_verification_batch) weitergeleitet wird.
self.logger.error(
f"Endgueltiger FEHLER beim OpenAI-Batch-Aufruf fuer Wiki-Verifizierung (innerhalb Batch Decorator): {e}") # <<< GEÄNDERT
# Logge den Traceback
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
# Geben Sie ein Dictionary zurueck, das signalisiert, dass fuer
# alle Zeilen im Batch ein Fehler aufgetreten ist
return {
item['row_num']: f"FEHLER API: {str(e)[:100]}" for item in batch_data}
# --- Antwort parsen ---
answers = {} # Initialisieren Sie das Ergebnis-Dictionary
# Liste der Zeilennummern, die im ursprünglichen Batch angefragt wurden
original_batch_row_nums = {item['row_num'] for item in batch_data}
lines = chat_response.strip().split('\n')
parsed_count = 0
for line in lines:
# Matcht "Eintrag <Zeilennummer>:" und den Rest der Zeile
match = re.match(r"Eintrag (\d+): (.*)", line.strip())
if match:
row_num = int(match.group(1))
answer_text = match.group(2).strip()
# Pruefen Sie, ob die Zeilennummer im urspruenglichen Batch
# angefragt wurde
if row_num in original_batch_row_nums:
answers[row_num] = answer_text
parsed_count += 1
# else: self.logger.debug(f"Warnung: Antwort fuer unerwartete
# Zeilennummer {row_num} im Batch erhalten:
# {answer_text[:100]}...") # Zu viel Laerm (gekuerzt loggen)
# Logge das Ergebnis des Parsens
self.logger.debug(
f"OpenAI-Batch-Antwort geparst: {parsed_count} von {len(original_batch_row_nums)} Zeilen erfolgreich zugeordnet.") # <<< GEÄNDERT
# Fuegen Sie einen Fehlerwert fuer Zeilen hinzu, die nicht geparst
# werden konnten (z.B. falsches Antwortformat)
if parsed_count < len(original_batch_row_nums):
self.logger.warning(
f"Nicht alle Zeilen aus dem Batch ({len(original_batch_row_nums)}) konnten in der OpenAI-Antwort ({len(lines)} Zeilen) geparst werden.") # <<< GEÄNDERT
# Logge den Anfang der unvollstaendigen Antwort auf Debug
self.logger.debug(
f"Unerwartete Antwortteile (erste 500 Zeichen): {chat_response[:500]}") # <<< GEÄNDERT
for row_num in original_batch_row_nums:
if row_num not in answers:
answers[row_num] = "FEHLER: Antwort nicht geparst"
# Die 'answers' Dictionary enthaelt nun Ergebnisse fuer alle Zeilen,
# entweder geparst oder mit einem Fehlerstring.
return answers # Rueckgabe des Dictionarys mit Ergebnissen oder Fehlern
# --- Methode fuer den Wiki-Verifizierungs-Batchmodus (AX) ---
# Diese Methode koordiniert die Auswahl der Zeilen, die Batch-Verarbeitung durch OpenAI,
# und das Schreiben der Ergebnisse (S, T, U, V-Y, AX, AP) ins Sheet.
# Basierend auf process_verification_only und _process_batch aus Teil 8.
# Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter, _process_verification_openai_batch (derselben Block).
# Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), datetime, time.
# Nutzt die uebergeordnete sheet_handler Instanz (Block 14).
def process_verification_batch(
self,
start_sheet_row=None,
end_sheet_row=None,
limit=None):
"""
Batch-Prozess nur fuer Wikipedia-Verifizierung (Spalten S-U, V-Y werden geleert).
Laedt Daten neu, prueft fuer jede Zeile im Bereich, ob Timestamp AX (Wiki Verif.)
bereits gesetzt ist, ob eine Wiki URL (M) vorhanden ist und ob Status S
nicht bereits 'OK', 'X (URL Copied)' oder 'X (Invalid Suggestion)' ist.
Setzt AX + AP fuer bearbeitete Zeilen und schreibt S-U in Batches.
Args:
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AX).
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht uebersprungener) Zeilen. Defaults to None (Unbegrenzt).
"""
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
# Logge die Konfiguration des Batch-Laufs
self.logger.info(
f"Starte Wikipedia-Verifizierungsmodus (Batch S-U, AX). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT
# --- Daten laden und Startzeile ermitteln ---
# Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt
if start_sheet_row is None:
self.logger.info(
"Automatische Ermittlung der Startzeile basierend auf leeren AX...") # <<< GEÄNDERT
# Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AX (Block 1 Column Map).
# Standardmaessig ab Zeile 7
start_data_index_no_header = self.sheet_handler.get_start_row_index(
check_column_key="Wiki Verif. Timestamp", min_sheet_row=7)
# Wenn get_start_row_index -1 zurueckgibt (Fehler)
if start_data_index_no_header == -1:
self.logger.error(
"FEHLER bei automatischer Ermittlung der Startzeile. Breche Batch ab.") # <<< GEÄNDERT
return # Beende die Methode
# Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten
# Daten-Index
start_sheet_row = start_data_index_no_header + \
self.sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut
self.logger.info(
f"Automatisch ermittelte Startzeile (erste leere AX Zelle): {start_sheet_row}") # <<< GEÄNDERT
else:
# Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein.
# Der load_data Aufruf ist mit retry_on_failure dekoriert (Block
# 2).
if not self.sheet_handler.load_data():
self.logger.error(
"FEHLER beim Laden der Daten fuer process_verification_batch.") # <<< GEÄNDERT
return # Beende die Methode, wenn das Laden fehlschlaegt
# Holen Sie die gesamte Datenliste (inklusive Header) aus dem
# SheetHandler.
all_data = self.sheet_handler.get_all_data_with_headers()
# Annahme: header_rows ist als Attribut im SheetHandler verfuegbar
# (Block 14).
header_rows = self.sheet_handler._header_rows
total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet
# Berechne Endzeile, wenn nicht manuell gesetzt
if end_sheet_row is None:
end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
# Logge den verarbeitungsbereich
self.logger.info(
f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT
# Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht
# ueber Gesamtzeilen)
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
self.logger.info(
"Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
return # Beende die Methode, wenn der Bereich leer ist
# --- Indizes und Buchstaben ---
# Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP
# (Block 1) vorhanden sind
required_keys = [
# Pruefkriterien / Timestamp (AX, M, S)
"Wiki Verif. Timestamp", "Wiki URL", "Chat Wiki Konsistenzpruefung",
# Daten fuer Prompt (B, F, N, R)
"CRM Name", "CRM Beschreibung", "Wiki Absatz", "Wiki Kategorien",
# Ergebnisspalten (T, U)
"Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel",
# Spalten V-Y zum Leeren
"Begruendung bei Abweichung", "Chat Begruendung Abweichung Branche",
"Wikipedia Timestamp", "Timestamp letzte Pruefung", # Spalten AN, AO zum Leeren
"Version", "SerpAPI Wiki Search Timestamp" # Spalten AP, AY zum Leeren
]
# Erstellen Sie ein Dictionary mit Schluesseln und Indizes
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
# Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden
# wurden
if None in col_indices.values():
missing = [k for k, v in col_indices.items() if v is None]
self.logger.critical(
f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_verification_batch: {missing}. Breche ab.") # <<< GEÄNDERT
return # Beende die Methode bei kritischem Fehler
# Ermitteln Sie die Spaltenbuchstaben fuer Updates und Leerung (nutzt
# interne Helfer _get_col_letter Block 14)
ts_ax_letter = _get_col_letter(
col_indices["Wiki Verif. Timestamp"] + 1) # Timestamp zu setzen (AX)
s_letter = _get_col_letter(
col_indices["Chat Wiki Konsistenzpruefung"] + 1) # Status S
t_letter = _get_col_letter(
col_indices["Chat Begründung Wiki Inkonsistenz"] + 1) # Begruendung T
u_letter = _get_col_letter(
col_indices["Chat Vorschlag Wiki Artikel"] + 1) # Vorschlag U
# Spalten V-Y leeren (werden in diesem Modus nicht neu befuellt).
# V ist Begruendung bei Abweichung (von Wiki-URL Pruefung CRM vs Wiki).
# Y ist Begruendung Abweichung Branche (von Chat).
v_idx = col_indices["Begruendung bei Abweichung"]
# Block 1 Column Map
y_idx = col_indices["Chat Begruendung Abweichung Branche"]
# Erstellen Sie den Bereichsnamen (z.B. "V:Y")
v_letter = _get_col_letter(v_idx + 1)
y_letter = _get_col_letter(y_idx + 1)
v_y_range_letter = f'{v_letter}:{y_letter}' # z.B. V:Y
# Erstellen Sie eine Liste von leeren Strings fuer diesen Bereich
# Anzahl der Spalten = Y_Index - V_Index + 1
empty_vy_values = [''] * (y_idx - v_idx + 1)
# Timestamps AN, AO, AP, AY leeren.
# Diese werden von anderen Schritten gesetzt und sollen hier zurueckgesetzt werden,
# um sicherzustellen, dass die Zeile bei Bedarf von diesen anderen
# Schritten erneut bearbeitet wird.
an_letter = _get_col_letter(
col_indices["Wikipedia Timestamp"] + 1) # AN (Wiki Extraction TS)
ao_letter = _get_col_letter(
col_indices["Timestamp letzte Pruefung"] +
1) # AO (Chat Evaluation TS)
ap_letter = _get_col_letter(
col_indices["Version"] + 1) # AP (Version)
ay_letter = _get_col_letter(
col_indices["SerpAPI Wiki Search Timestamp"] +
1) # AY (SerpAPI Wiki TS)
# --- Verarbeitung ---
# Holen Sie die Batch-Groesse fuer OpenAI-Aufrufe aus Config (Block 1)
# Nutzt dieselbe Batch-Groesse wie Scraping/Summarization
openai_batch_size = getattr(Config, 'PROCESSING_BATCH_SIZE', 20)
# Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1)
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)
# Daten fuer den aktuellen OpenAI Batch (Liste von Dicts)
current_openai_batch_data = []
# 1-basierte Zeilennummern im aktuellen OpenAI Batch
rows_in_current_openai_batch = []
# Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts)
all_sheet_updates = []
# Zaehlt Zeilen, die fuer die Verarbeitung in Frage kommen und in den
# Batch aufgenommen werden (im Rahmen des Limits).
processed_count = 0
# Zaehlt Zeilen, die uebersprungen wurden (wegen Status, fehlender
# Daten etc.).
skipped_count = 0
# Zaehlt Zeilen, die speziell wegen fehlender M-URL uebersprungen
# wurden.
skipped_no_wiki_url = 0
# Iteriere ueber die Sheet-Zeilen im definierten Bereich (1-basierte
# Sheet-Zeilennummer)
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
# Pruefen Sie, ob das Ende des Sheets erreicht wurde
if row_index_in_list >= total_sheet_rows:
break # Ende des Sheets erreicht
row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile
# Stellen Sie sicher, dass die Zeile nicht leer ist (mindestens Name vorhanden)
# Nutzt interne Helfer _get_cell_value_safe
company_name = self._get_cell_value_safe(
row, "CRM Name").strip() # Block 1 Column Map
if not company_name:
self.logger.debug(
f"Zeile {i}: Uebersprungen (Kein Firmenname in Spalte B).") # <<< GEÄNDERT
skipped_count += 1 # Zaehlen als uebersprungen
continue # Springe zur naechsten Zeile
# --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
# Kriterium: Wiki Verif. Timestamp (AX) ist leer
# UND Wiki URL (M) ist gefuellt und gueltig aussehend (nicht k.A., Fehler etc.)
# UND Status S ist NICHT bereits in einem Endzustand (OK, X
# (UPDATED/COPIED/INVALID)).
# Holen Sie die Werte aus den entsprechenden Spalten (nutzt interne
# Helfer)
ax_value = self._get_cell_value_safe(
row, "Wiki Verif. Timestamp").strip() # Block 1 Column Map
m_value = self._get_cell_value_safe(
row, "Wiki URL").strip() # Block 1 Column Map
s_value_upper = self._get_cell_value_safe(
row, "Chat Wiki Konsistenzpruefung").strip().upper() # Block 1 Column Map
# Pruefen Sie, ob die Wiki URL (M) gueltig aussieht
is_wiki_url_valid_looking = m_value and isinstance(
m_value,
str) and "wikipedia.org/wiki/" in m_value.lower() and m_value.lower() not in [
"k.a.",
"kein artikel gefunden",
"fehler bei suche",
"http:"] # Fuege "http:" hinzu basierend auf Log
# Definieren Sie die Endzustaende von Status S (Grossbuchstaben)
s_end_states = [
"OK",
"X (UPDATED)",
"X (URL COPIED)",
"X (INVALID SUGGESTION)"]
# Pruefen Sie, ob Status S in einem Endzustand ist
is_s_in_endstate = s_value_upper in s_end_states # Bugfix: Korrekte Zuweisung
# Verarbeitung ist noetig, wenn AX leer UND M gefuellt/gueltig
# aussieht UND S NICHT im Endzustand ist.
processing_needed_for_row = not ax_value and is_wiki_url_valid_looking and not is_s_in_endstate
# Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
log_check = (
i < start_sheet_row +
5) or (
i %
100 == 0) or (processing_needed_for_row)
if log_check:
self.logger.debug(
f"Zeile {i} ({company_name[:50]}... Wiki Verif. Check): AX leer? {not ax_value}, M gueltig? {is_wiki_url_valid_looking}, S ('{s_value_upper}') Endzustand? {is_s_in_endstate}. Benötigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT
# Wenn die Verarbeitung fuer diese Zeile nicht noetig ist
if not processing_needed_for_row:
skipped_count += 1 # Zaehlen als uebersprungene Zeile
# Zaehlen Sie separat, wenn die Zeile speziell wegen fehlender
# M-URL uebersprungen wurde
if not is_wiki_url_valid_looking:
skipped_no_wiki_url += 1
continue # Springe zur naechsten Zeile
# --- Wenn Verarbeitung noetig: Fuege zur Batch-Liste fuer OpenAI hinzu ---
# Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im
# Rahmen des Limits zaehlen)
processed_count += 1
# Pruefe das Limit fuer verarbeitete Zeilen
if limit is not None and isinstance(
limit, int) and limit > 0 and processed_count > limit:
# Wenn das Limit erreicht ist und es ein positives Limit gibt
self.logger.info(
f"Verarbeitungslimit ({limit}) fuer process_verification_batch erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
break # Brich die Schleife ab
# Sammle die benoetigten Daten fuer den OpenAI Prompt (Block 26 - _process_verification_openai_batch).
# Diese Daten werden in einem Dictionary fuer den Batch gesammelt.
crm_desc = self._get_cell_value_safe(
row, "CRM Beschreibung") # Block 1 Column Map
wiki_paragraph = self._get_cell_value_safe(
row, "Wiki Absatz") # Block 1 Column Map
wiki_categories = self._get_cell_value_safe(
row, "Wiki Kategorien") # Block 1 Column Map
# Fuege die Daten dieser Zeile zur aktuellen Batch-Liste fuer
# OpenAI hinzu
current_openai_batch_data.append({
'row_num': i, # Die 1-basierte Sheet-Zeilennummer
'company_name': company_name, # Nutzt den initial geladenen Namen
'crm_desc': crm_desc,
'wiki_url': m_value, # Nutzt die M-URL aus dem Sheet
'wiki_paragraph': wiki_paragraph,
'wiki_categories': wiki_categories
})
# Fuege die Zeilennummer zur Liste der Zeilennummern im Batch hinzu
rows_in_current_openai_batch.append(i)
# --- Verarbeite den Batch, wenn voll ---
# Pruefe, ob die aktuelle Batch-Liste die definierte Groesse erreicht hat.
# openai_batch_size wird aus Config geholt (Block 1).
if len(current_openai_batch_data) >= openai_batch_size:
# Logge den Start der Batch-Verarbeitung
batch_start_row = current_openai_batch_data[0]['row_num']
batch_end_row = current_openai_batch_data[-1]['row_num']
self.logger.debug(
f"\n--- Starte Wiki-Verifizierungs-Batch ({len(current_openai_batch_data)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") # <<< GEÄNDERT
# Rufe die interne Methode auf, die den OpenAI Call fuer den Batch macht.
# _process_verification_openai_batch (Block 26) ist mit retry_on_failure dekoriert.
# Wenn _process_verification_openai_batch eine Exception wirft
# (nach Retries), wird diese hier gefangen.
batch_results = self._process_verification_openai_batch(
current_openai_batch_data)
# Ergebnisse sollten ein Dictionary {row_num:
# raw_chatgpt_answer} sein, auch bei Fehlern.
# Sammle Sheet Updates basierend auf den Batch-Ergebnissen.
# Setze immer den Timestamp AX und die Werte in S, T, U und V-Y.
# Der aktuelle Zeitstempel fuer den Batch
current_batch_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
batch_sheet_updates = [] # Updates fuer DIESEN spezifischen Batch von Zeilen
# Iteriere ueber die Zeilennummern, die in DIESEM OpenAI Batch
# waren
for row_num in rows_in_current_openai_batch:
# Hole das Ergebnis fuer diese Zeile aus dem Ergebnis-Dictionary.
# Fallback auf einen Fehlerstring, wenn das Ergebnis fehlt
# (sollte nicht passieren, wenn
# _process_verification_openai_batch korrekt ist).
answer = batch_results.get(
row_num, "FEHLER: Batch-Ergebnis fehlt")
# self.logger.debug(f"Zeile {row_num}
# Verifizierungsantwort: '{answer[:100]}...'") # Zu viel
# Laerm (gekuerzt)
# Logik zur Bestimmung der Werte fuer S, T, U basierend auf
# 'answer' (aehnlich wie in altem _process_batch)
# Initialisiere mit leeren Strings
wiki_confirm, alt_article, wiki_explanation = "", "", ""
# Pruefe auf Standard-Antworten und Fehler-Antworten
if isinstance(answer, str) and answer.upper() == "OK":
wiki_confirm = "OK"
wiki_explanation = "Passt laut KI zur Firma." # Standard Begruendung bei OK
elif isinstance(answer, str) and answer.startswith("X |"):
# Parse die Antwort im Format "X | <Detail> |
# <Begruendung>"
# Teile maximal in 3 Teile
parts = answer.split("|", 2)
wiki_confirm = "X" # Status ist X
if len(parts) > 1:
# Zweiter Teil ist Detail (Alternative URL oder
# "Kein passender Artikel gefunden")
detail = parts[1].strip()
if detail.lower().startswith("alternativer artikel:"):
alt_article = detail.split(
":", 1)[1].strip() # Extrahiere URL
elif detail.lower() == "kein passender artikel gefunden":
alt_article = detail # Text "Kein passender Artikel gefunden"
else:
alt_article = detail # Unbekanntes Detail
if len(parts) > 2:
# Dritter Teil ist Begruendung
reason_part = parts[2].strip()
if reason_part.lower().startswith("begruendung:"):
wiki_explanation = reason_part.split(
":", 1)[1].strip() # Extrahiere Begruendungstext
else:
wiki_explanation = reason_part # Unbekannte Begruendung
# Fuege ggf. den rohen Antworttext zur Begruendung
# hinzu, wenn Parsing unvollstaendig war
if not alt_article or not wiki_explanation:
wiki_explanation += f" (Rohantwort: {answer[:100]}...)"
elif isinstance(answer, str) and answer.startswith("FEHLER"):
# Wenn die Batch-Verarbeitung einen Fehler
# zurueckgegeben hat
wiki_confirm = "FEHLER"
wiki_explanation = answer # Fehlermeldung in Begruendung schreiben
alt_article = "Siehe Begruendung" # Verweis auf Begruendung
# Unerwartetes Format der Antwort (weder OK noch X | noch
# FEHLER)
else:
wiki_confirm = "?" # Setze Status auf unbekannt
# Speichere Anfang der Antwort in Begruendung
# (gekuerzt)
wiki_explanation = f"Unerwartetes Format: {str(answer)[:100]}..."
alt_article = "Siehe Begruendung" # Verweis auf Begruendung
# Spalten V-Y (Begruendung bei Abweichung etc.) werden in diesem Modus geleert
# Fuer jede Zeile im Batch fuegen wir das Update hinzu.
# empty_vy_values wurde oben vorbereitet.
v_y_values = empty_vy_values # Liste von leeren Strings
# Fuege Update zum Leeren von V-Y hinzu, falls Index
# gefunden wurde
if v_y_range_letter: # Pruefe, ob der Bereichsname ermittelt werden konnte
batch_sheet_updates.append({'range': f'{v_letter}{row_num}:{y_letter}{row_num}', 'values': [
v_y_values]}) # Block 1 Column Map, interne Helfer
# Fuege Updates fuer S, T, U und AX hinzu (nutzt interne
# Helfer)
batch_sheet_updates.append({'range': f'{s_letter}{row_num}', 'values': [
[wiki_confirm]]}) # Block 1 Column Map
batch_sheet_updates.append({'range': f'{t_letter}{row_num}', 'values': [
[alt_article]]}) # Block 1 Column Map
batch_sheet_updates.append({'range': f'{u_letter}{row_num}', 'values': [
[wiki_explanation]]}) # Block 1 Column Map
# Setze AX Timestamp fuer diese Zeile
batch_sheet_updates.append({'range': f'{ts_ax_letter}{row_num}', 'values': [
[current_batch_timestamp]]}) # Block 1 Column Map
# --- Sende gesammelte Updates fuer diesen Batch ---
# Sammle die Updates fuer diesen Batch in der globalen Liste.
# all_sheet_updates.extend(batch_sheet_updates) # Nicht hier
# sammeln, sondern direkt senden
# Sende die gesammelten Updates fuer DIESEN Batch sofort.
if batch_sheet_updates:
self.logger.debug(
f" Sende Sheet-Update fuer {len(rows_in_current_openai_batch)} Zeilen ({len(batch_sheet_updates)} Zellen) dieses Batches...") # <<< GEÄNDERT
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
# Wenn es fehlschlaegt, wird es intern geloggt.
success = self.sheet_handler.batch_update_cells(
batch_sheet_updates)
if success:
self.logger.info(
f" Sheet-Update fuer Wiki-Verifizierungs-Batch Zeilen {batch_start_row}-{batch_end_row} erfolgreich.") # <<< GEÄNDERT
# Der Fehlerfall wird von batch_update_cells geloggt
# Setze Batch-Listen zurueck fuer die naechste Iteration
current_openai_batch_data = []
rows_in_current_openai_batch = []
# Pause nach jedem OpenAI Batch (nutzt Config Block 1).
# Dies ist wichtig, um Rate Limits zu vermeiden.
# Nutze Config.RETRY_DELAY, ggf. kuerzer, da es ein Batch war
pause_duration = getattr(
Config, 'RETRY_DELAY', 5) * 0.5 # 50% der Retry-Wartezeit
self.logger.debug(
f"--- Batch Zeilen {batch_start_row}-{batch_end_row} abgeschlossen. Warte {pause_duration:.2f}s vor naechstem Batch ---") # <<< GEÄNDERT
time.sleep(pause_duration)
# --- Verarbeitung des letzten unvollstaendigen Batches nach der Schleife ---
# Wenn nach der Hauptschleife noch Tasks in der Batch-Liste sind
if current_openai_batch_data:
# Logge den Start des finalen Batches
batch_start_row = current_openai_batch_data[0]['row_num']
batch_end_row = current_openai_batch_data[-1]['row_num']
self.logger.debug(
f"\n--- Starte FINALEN Wiki-Verifizierungs-Batch ({len(current_openai_batch_data)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") # <<< GEÄNDERT
# Rufe die interne Methode auf, die den OpenAI Call macht
batch_results = self._process_verification_openai_batch(
current_openai_batch_data)
# Ergebnisse sollten ein Dictionary {row_num: raw_chatgpt_answer}
# sein, auch bei Fehlern.
# Sammle Sheet Updates (S, T, U, V-Y, AX) fuer diesen finalen Batch
current_batch_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
batch_sheet_updates = [] # Updates fuer DIESEN spezifischen Batch von Zeilen
# Iteriere ueber die Zeilennummern, die in DIESEM finalen OpenAI
# Batch waren
for row_num in rows_in_current_openai_batch:
# Hole das Ergebnis fuer diese Zeile aus dem
# Ergebnis-Dictionary.
answer = batch_results.get(
row_num, "FEHLER: Batch-Ergebnis fehlt") # Fallback
# Logik zur Bestimmung der Werte fuer S, T, U basierend auf
# 'answer'
wiki_confirm, alt_article, wiki_explanation = "", "", ""
# Leere V-Y Spalten
v_y_values = empty_vy_values # Liste von leeren Strings
if isinstance(answer, str) and answer.upper() == "OK":
wiki_confirm = "OK"
wiki_explanation = "Passt laut KI zur Firma."
elif isinstance(answer, str) and answer.startswith("X |"):
parts = answer.split("|", 2)
wiki_confirm = "X"
if len(parts) > 1:
detail = parts[1].strip()
alt_article = detail.split(":", 1)[1].strip() if detail.lower(
).startswith("alternativer artikel:") else detail
if len(parts) > 2:
reason_part = parts[2].strip()
wiki_explanation = reason_part.split(":", 1)[1].strip(
) if reason_part.lower().startswith("begruendung:") else reason_part
if not alt_article or not wiki_explanation:
wiki_explanation += f" (Rohantwort: {answer[:100]}...)"
elif isinstance(answer, str) and answer.startswith("FEHLER"):
wiki_confirm = "FEHLER"
wiki_explanation = answer
alt_article = "Siehe Begruendung"
else:
wiki_confirm = "?"
wiki_explanation = f"Unerwartetes Format: {str(answer)[:100]}..."
alt_article = "Siehe Begruendung"
# Fuege Updates fuer S, T, U und AX hinzu
batch_sheet_updates.append({'range': f'{s_letter}{row_num}', 'values': [
[wiki_confirm]]}) # Block 1 Column Map
batch_sheet_updates.append({'range': f'{t_letter}{row_num}', 'values': [
[alt_article]]}) # Block 1 Column Map
batch_sheet_updates.append({'range': f'{u_letter}{row_num}', 'values': [
[wiki_explanation]]}) # Block 1 Column Map
# Setze AX Timestamp
batch_sheet_updates.append({'range': f'{ts_ax_letter}{row_num}', 'values': [
[current_batch_timestamp]]}) # Block 1 Column Map
# Fuege Update zum Leeren von V-Y hinzu, falls Index gefunden
# wurde
if v_y_range_letter:
batch_sheet_updates.append({'range': f'{v_letter}{row_num}:{y_letter}{row_num}', 'values': [
v_y_values]}) # Block 1 Column Map, interne Helfer
# Sende die gesammelten Updates fuer DIESEN finalen Batch.
if batch_sheet_updates:
self.logger.debug(
f" Sende FINALES Sheet-Update fuer {len(rows_in_current_openai_batch)} Zeilen ({len(batch_sheet_updates)} Zellen)...") # <<< GEÄNDERT
# Nutzt die batch_update_cells Methode des Sheet Handlers
# (Block 14) mit Retry.
success = self.sheet_handler.batch_update_cells(
batch_sheet_updates)
if success:
self.logger.info(
f" FINALES Sheet-Update fuer Wiki-Verifizierungs-Batch erfolgreich.") # <<< GEÄNDERT
# Der Fehlerfall wird von batch_update_cells geloggt
# Logge den Abschluss des Modus
self.logger.info(
f"Wikipedia-Verifizierungs-Batch abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {skipped_count} Zeilen uebersprungen ({skipped_no_wiki_url} wegen fehlender M-URL).") # <<< GEÄNDERT
def process_summarize_website(self, start_sheet_row=None, end_sheet_row=None, limit=None):
"""
Batch-Prozess NUR für Website-Zusammenfassung.
Nutzt einen ThreadPoolExecutor für echte parallele Verarbeitung und verbesserte Stabilität.
"""
self.logger.info(f"Starte Website-Zusammenfassung (Parallel Batch, v2.0.3). Bereich: {start_sheet_row or 'Start'}-{end_sheet_row or 'Ende'}, Limit: {limit or 'Unbegrenzt'}")
# --- 1. Daten laden und Startzeile ermitteln ---
if start_sheet_row is None:
self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren 'Website Zusammenfassung'...")
start_data_idx = self.sheet_handler.get_start_row_index(check_column_key="Website Zusammenfassung")
if start_data_idx == -1:
self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche Batch ab.")
return
start_sheet_row = start_data_idx + self.sheet_handler._header_rows + 1
self.logger.info(f"Automatisch ermittelte Startzeile: {start_sheet_row}")
if not self.sheet_handler.load_data():
self.logger.error("FEHLER beim Laden der Daten für Summarization-Batch.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = self.sheet_handler._header_rows
total_sheet_rows = len(all_data)
effective_end_row = end_sheet_row if end_sheet_row is not None else total_sheet_rows
self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {effective_end_row}.")
if start_sheet_row > effective_end_row:
self.logger.info("Start liegt nach dem Ende. Keine Zeilen zu verarbeiten.")
return
# --- 2. Spalten-Indizes und Buchstaben vorbereiten ---
summary_col_letter = _get_col_letter(get_col_idx("Website Zusammenfassung") + 1)
version_col_letter = _get_col_letter(get_col_idx("Version") + 1)
# Wir benötigen auch einen Timestamp für die Zusammenfassung. Da keiner existiert, nutzen wir den Scrape-Timestamp neu.
timestamp_col_letter = _get_col_letter(get_col_idx("Website Scrape Timestamp") + 1)
# --- 3. Tasks sammeln ---
openai_batch_size = getattr(Config, 'OPENAI_BATCH_SIZE_LIMIT', 10) # Kann höher sein, da parallel
max_openai_workers = getattr(Config, 'MAX_SCRAPING_WORKERS', 10) # Gleiche Worker-Anzahl wie beim Scraping
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)
tasks_for_processing_batch = []
all_sheet_updates = []
processed_count = 0
skipped_count = 0
for i in range(start_sheet_row, effective_end_row + 1):
row_index_in_list = i - 1
if row_index_in_list >= total_sheet_rows: break
row = all_data[row_index_in_list]
if not any(cell and str(cell).strip() for cell in row):
skipped_count += 1
continue
# Kriterium: Zusammenfassung ist leer/default UND Rohtext ist valide
summary_value = self._get_cell_value_safe(row, "Website Zusammenfassung")
summary_is_empty_or_default = not summary_value or str(summary_value).strip().lower() in ["k.a.", "k.a. (keine zusammenfassung erhalten)"]
raw_text = self._get_cell_value_safe(row, "Website Rohtext")
raw_text_is_valid = raw_text and isinstance(raw_text, str) and len(raw_text) > 100 and not str(raw_text).strip().lower().startswith('k.a.')
if summary_is_empty_or_default and raw_text_is_valid:
if limit is not None and processed_count >= limit:
self.logger.info(f"Verarbeitungslimit ({limit}) erreicht.")
break
company_name = self._get_cell_value_safe(row, "CRM Name")
tasks_for_processing_batch.append({"row_num": i, "raw_text": raw_text, "company_name": company_name})
processed_count += 1
else:
skipped_count += 1
# --- 4. Batch-Verarbeitung auslösen ---
if len(tasks_for_processing_batch) >= openai_batch_size or (i == effective_end_row and tasks_for_processing_batch):
self.logger.info(f"--- Starte Website-Summarization Batch für {len(tasks_for_processing_batch)} Tasks (max. {max_openai_workers} Worker) ---")
summarization_results = {}
batch_error_count = 0
with ThreadPoolExecutor(max_workers=max_openai_workers) as executor:
future_to_task = {executor.submit(self._summarize_task_batch, task): task for task in tasks_for_processing_batch}
for future in as_completed(future_to_task):
task = future_to_task[future]
try:
result_dict = future.result()
if isinstance(result_dict, dict) and 'row_num' in result_dict:
summarization_results[result_dict['row_num']] = result_dict
if result_dict.get('error'):
batch_error_count += 1
self.logger.warning(f"Worker meldete Fehler bei Zusammenfassung für Zeile {result_dict['row_num']}: {result_dict.get('status_message')}")
else:
self.logger.error(f"Inkonsistentes Ergebnis für Zeile {task['row_num']}: Erwartete dict mit 'row_num', bekam {type(result_dict)}. Überspringe.")
summarization_results[task['row_num']] = {'summary': "FEHLER (Inkonsistenter Rückgabetyp)", 'error': True}
batch_error_count += 1
except Exception as exc:
self.logger.error(f"Unerwarteter Fehler bei Ergebnisabfrage für Zeile {task['row_num']}: {exc}", exc_info=True)
summarization_results[task['row_num']] = {'summary': "FEHLER (Task Exception)", 'error': True}
batch_error_count += 1
self.logger.info(f" -> Zusammenfassung für Batch beendet. {len(summarization_results)} Ergebnisse erhalten ({batch_error_count} davon mit Fehlern).")
# --- 5. Updates für das Google Sheet vorbereiten ---
if summarization_results:
current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
current_version = getattr(Config, 'VERSION', 'unknown')
for row_num, res_dict in summarization_results.items():
# Zusammenfassung, Timestamp (wir überschreiben den alten Scrape-TS) und Version werden zum Update hinzugefügt
all_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[res_dict.get('summary', 'k.A.')]]})
all_sheet_updates.append({'range': f'{timestamp_col_letter}{row_num}', 'values': [[current_timestamp]]})
all_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]})
tasks_for_processing_batch = [] # Batch leeren
# --- 6. Sheet-Update auslösen, wenn Update-Batch voll ist ---
# Pro Zeile gibt es 3 Updates
if len(all_sheet_updates) >= (update_batch_row_limit * 3):
self.logger.info(f"Sende gesammelte Sheet-Updates für Zusammenfassungen ({len(all_sheet_updates) // 3} Zeilen)...")
self.sheet_handler.batch_update_cells(all_sheet_updates)
all_sheet_updates = []
time.sleep(1)
# --- 7. Finale Updates senden ---
if all_sheet_updates:
self.logger.info(f"Sende finale gesammelte Sheet-Updates für Zusammenfassungen ({len(all_sheet_updates) // 3} Zeilen)...")
self.sheet_handler.batch_update_cells(all_sheet_updates)
self.logger.info(f"Website-Zusammenfassung (Batch) abgeschlossen. {processed_count} Zeilen zur Verarbeitung ausgewählt, {skipped_count} Zeilen übersprungen.")
def evaluate_branch_task(self, task_data, openai_semaphore):
"""
Führt die Branchenevaluation fuer eine einzelne Zeile aus.
"""
logger = logging.getLogger(__name__ + ".evaluate_branch_task")
row_num = task_data['row_num']
result = {
"branch": "k.A. (Fehler Task)",
"consistency": "error",
"justification": "Fehler in Worker-Task"}
error = None
try:
with openai_semaphore:
result = evaluate_branche_chatgpt(
task_data['crm_branche'], task_data['beschreibung'],
task_data['wiki_branche'], task_data['wiki_kategorien'],
task_data['website_summary']
)
except Exception as e:
error = f"Fehler bei Branchenevaluation Zeile {row_num}: {e}"
logger.error(error)
logger.debug(traceback.format_exc())
result = {"branch": "FEHLER",
"consistency": "error_task",
"justification": error[:500]}
return {"row_num": row_num, "result": result, "error": error}
def process_branch_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None):
"""
Batch-Prozess NUR fuer Brancheneinschaetzung.
"""
global ALLOWED_TARGET_BRANCHES
self.logger.info(
f"Starte Brancheneinschaetzung (Parallel Batch). Bereich: {start_sheet_row if start_sheet_row else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit else 'Unbegrenzt'}")
if not ALLOWED_TARGET_BRANCHES:
load_target_schema()
if not ALLOWED_TARGET_BRANCHES:
self.logger.critical(
"FEHLER: Ziel-Branchenschema konnte nicht geladen werden. Breche Batch ab.")
return
self.logger.info(
f"Starte Brancheneinschaetzung (Parallel Batch). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...")
# --- Daten laden und Startzeile ermitteln ---
if start_sheet_row is None:
self.logger.info(
"Automatische Ermittlung der Startzeile basierend auf leeren Timestamp letzte Pruefung (BC)...")
start_data_index_no_header = self.sheet_handler.get_start_row_index(
check_column_key="Timestamp letzte Pruefung", min_sheet_row=7)
if start_data_index_no_header == -1:
self.logger.error(
"FEHLER bei automatischer Ermittlung der Startzeile. Breche Batch ab.")
return
start_sheet_row = start_data_index_no_header + \
self.sheet_handler._header_rows + 1
self.logger.info(
f"Automatisch ermittelte Startzeile (erste leere BC Zelle): {start_sheet_row}")
else:
if not self.sheet_handler.load_data():
self.logger.error(
"FEHLER beim Laden der Daten fuer process_branch_batch.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = self.sheet_handler._header_rows
total_sheet_rows = len(all_data)
if end_sheet_row is None:
end_sheet_row = total_sheet_rows
self.logger.info(
f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}")
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
self.logger.info(
"Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.")
return
# --- Indizes und Buchstaben ---
required_keys = [
"Timestamp letzte Pruefung",
"CRM Branche",
"CRM Beschreibung",
"Wiki Branche",
"Wiki Kategorien",
"Website Zusammenfassung",
"Version",
"Chat Vorschlag Branche",
"Chat Branche Konfidenz",
"Chat Konsistenz Branche",
"Chat Begruendung Abweichung Branche",
"CRM Name"]
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
if None in col_indices.values():
missing = [k for k, v in col_indices.items() if v is None]
self.logger.critical(
f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_branch_batch: {missing}. Breche ab.")
return
MAX_BRANCH_WORKERS = getattr(Config, 'MAX_BRANCH_WORKERS', 10)
OPENAI_CONCURRENCY_LIMIT = getattr(
Config, 'OPENAI_CONCURRENCY_LIMIT', 3)
processing_batch_size = getattr(
Config, 'PROCESSING_BRANCH_BATCH_SIZE', 20)
tasks_for_current_batch = []
processed_tasks_count = 0 # Zählt Tasks, die tatsächlich verarbeitet wurden
skipped_count = 0
if not ALLOWED_TARGET_BRANCHES:
load_target_schema()
if not ALLOWED_TARGET_BRANCHES:
self.logger.critical(
"FEHLER: Ziel-Branchenschema konnte nicht geladen werden. Breche Batch ab.")
return
# Funktion zum Verarbeiten eines einzelnen Batches (um Code-Duplikation
# zu reduzieren)
def _execute_and_write_batch(batch_tasks_to_run):
nonlocal processed_tasks_count # Zugriff auf die äußere Variable
if not batch_tasks_to_run:
return
batch_start_log = batch_tasks_to_run[0]['row_num']
batch_end_log = batch_tasks_to_run[-1]['row_num']
self.logger.debug(
f"\n--- Verarbeite Branch-Evaluation Batch ({len(batch_tasks_to_run)} Tasks, Zeilen {batch_start_log}-{batch_end_log}) ---")
current_batch_results = []
current_batch_errors = 0
openai_sem = threading.Semaphore(OPENAI_CONCURRENCY_LIMIT)
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_BRANCH_WORKERS) as executor:
future_map = {
executor.submit(
self.evaluate_branch_task,
task,
openai_sem): task for task in batch_tasks_to_run}
for future in concurrent.futures.as_completed(future_to_task):
task = future_to_task[future]
try:
# HIER KOMMT JETZT GARANTIERT EIN DICTIONARY AN
result_dict = future.result()
# Prüfe explizit, ob es wirklich ein Dictionary ist (doppelte Sicherheit)
if not isinstance(result_dict, dict):
self.logger.error(f"Fehlerhaftes Ergebnis für Zeile {task['row_num']}: Worker gab keinen Dictionary zurück. Bekam {type(result_dict)}. Überspringe.")
batch_error_count += 1
continue
# Lese die Werte aus dem Dictionary, anstatt das ganze Objekt zu speichern
row_num_from_result = result_dict['row_num']
raw_text_res = result_dict['raw_text']
scraping_results[row_num_from_result] = raw_text_res
if result_dict.get('error'):
batch_error_count += 1
except Exception as exc:
row_num = task['row_num']
err_msg = f"Unerwarteter Fehler bei Ergebnisabfrage für Zeile {row_num} ({task['url'][:100]}): {exc}"
self.logger.error(err_msg)
scraping_results[row_num] = "k.A. (Unerwarteter Fehler Task)"
batch_error_count += 1
self.logger.error(
f"Exception im Future für Zeile {task_info['row_num']}: {exc_future}")
current_batch_results.append(
{
"row_num": task_info['row_num'],
"result": {
"branch": "FEHLER FUTURE",
"consistency": "error_task",
"justification": str(exc_future)[
:100]},
"error": str(exc_future)})
current_batch_errors += 1
self.logger.debug(
f" Batch ({batch_start_log}-{batch_end_log}) beendet. {len(current_batch_results)} Ergebnisse, {current_batch_errors} Fehler.")
if current_batch_results:
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
ver = getattr(Config, 'VERSION', 'unknown')
updates_this_batch = []
current_batch_results.sort(key=lambda x: x['row_num'])
for item in current_batch_results:
rn, res = item['row_num'], item['result']
self.logger.debug(f" Zeile {rn} (Ergebnis): {res}")
updates_this_batch.append(
{
'range': f'{_get_col_letter(get_col_idx("Chat Vorschlag Branche") + 1)}{rn}',
'values': [
[
res.get(
"branch",
"ERR BR")]]})
updates_this_batch.append(
{
'range': f'{_get_col_letter(get_col_idx("Chat Branche Konfidenz") + 1)}{rn}',
'values': [
[
res.get(
"confidence",
"N/A CO")]]})
updates_this_batch.append(
{
'range': f'{_get_col_letter(get_col_idx("Chat Konsistenz Branche") + 1)}{rn}',
'values': [
[
res.get(
"consistency",
"err CO")]]})
updates_this_batch.append(
{
'range': f'{_get_col_letter(get_col_idx("Chat Begruendung Abweichung Branche") + 1)}{rn}',
'values': [
[
res.get(
"justification",
"No JU")]]})
updates_this_batch.append(
{
'range': f'{_get_col_letter(get_col_idx("Timestamp letzte Pruefung") + 1)}{rn}',
'values': [
[ts]]})
updates_this_batch.append(
{
'range': f'{_get_col_letter(get_col_idx("Version") + 1)}{rn}',
'values': [
[ver]]})
if updates_this_batch:
self.logger.debug(
f" Sende Sheet-Update für {len(current_batch_results)} Zeilen dieses Batches...")
s_upd = self.sheet_handler.batch_update_cells(
updates_this_batch)
if s_upd:
self.logger.info(
f" Sheet-Update für Batch Zeilen {batch_start_log}-{batch_end_log} erfolgreich.")
# Zähle verarbeitete Tasks
processed_tasks_count += len(batch_tasks_to_run)
pause_dur = getattr(Config, 'RETRY_DELAY', 5) * 0.8
self.logger.debug(
f"--- Batch ({batch_start_log}-{batch_end_log}) abgeschlossen. Warte {pause_dur:.2f}s ---")
time.sleep(pause_dur)
# Ende der Hilfsfunktion _execute_and_write_batch
# Hauptschleife über die Zeilen
for i in range(start_sheet_row, end_sheet_row + 1):
# Prüfe Limit für *tatsächlich verarbeitete* Tasks
if limit is not None and processed_tasks_count >= limit:
self.logger.info(
f"Verarbeitungslimit ({limit}) erreicht. Stoppe weitere Zeilenprüfung.")
break
row_index_in_list = i - 1
if row_index_in_list >= total_sheet_rows:
break
row = all_data[row_index_in_list]
if not any(cell and isinstance(cell, str) and cell.strip()
for cell in row):
skipped_count += 1
continue
company_name_log = self._get_cell_value_safe(
row, "CRM Name").strip()
ao_value = self._get_cell_value_safe(
row, "Timestamp letzte Pruefung").strip()
if ao_value: # Wenn Timestamp gesetzt ist, überspringen
skipped_count += 1
continue
# --- DEBUG BLOCK für info_sources_count (wie gehabt) ---
crm_branche_val = self._get_cell_value_safe(
row, "CRM Branche").strip()
crm_beschreibung_val = self._get_cell_value_safe(
row, "CRM Beschreibung").strip()
wiki_branche_val = self._get_cell_value_safe(
row, "Wiki Branche").strip()
wiki_kategorien_val = self._get_cell_value_safe(
row, "Wiki Kategorien").strip()
website_summary_val = self._get_cell_value_safe(
row, "Website Zusammenfassung").strip()
# ... (kompletter detaillierter Debug-Block für info_sources_count hier einfügen) ...
self.logger.debug(
f"Zeile {i} ({company_name_log[:30]}...) - Rohwerte für Info-Quellen:")
sources_to_check = {
"CRM Branche": crm_branche_val,
"CRM Beschreibung": crm_beschreibung_val,
"Wiki Branche": wiki_branche_val,
"Wiki Kategorien": wiki_kategorien_val,
"Website Zusammenfassung": website_summary_val}
info_sources_count = 0
counted_sources = []
for source_name, val in sources_to_check.items():
cond1 = bool(val)
cond2 = isinstance(val, str)
cond3 = False
cond4 = False
cond5 = False
if cond1 and cond2:
stripped_val = val.strip()
cond3 = bool(stripped_val)
if cond3:
lower_stripped_val = stripped_val.lower()
cond4 = lower_stripped_val != "k.a."
cond5 = not stripped_val.upper().startswith("FEHLER")
is_valid_source = cond1 and cond2 and cond3 and cond4 and cond5
if is_valid_source:
info_sources_count += 1
counted_sources.append(source_name)
self.logger.debug(
f" Prüfe Quelle '{source_name}': Wert='{str(val)[:30]}...', c1?{cond1}, c2?{cond2}, c3?{cond3}, c4?{cond4}, c5?{cond5} -> Gültig? {is_valid_source}")
self.logger.debug(
f"Zeile {i} ({company_name_log[:30]}...) - Gezählte valide Quellen: {info_sources_count} - {counted_sources}")
if info_sources_count < 2:
self.logger.info(
f"Zeile {i} ({company_name_log[:30]}...) (Branch Check): Uebersprungen (Timestamp BC leer, aber nur {info_sources_count} Informationsquellen verfuegbar: {counted_sources}). Mindestens 2 benoetigt.")
skipped_count += 1
continue
# Task zur Liste hinzufügen, wenn alle Kriterien erfüllt sind UND
# Limit noch nicht erreicht
if limit is None or (
processed_tasks_count +
len(tasks_for_current_batch)) < limit:
tasks_for_current_batch.append({
"row_num": i, "crm_branche": crm_branche_val, "beschreibung": crm_beschreibung_val,
"wiki_branche": wiki_branche_val, "wiki_kategorien": wiki_kategorien_val,
"website_summary": website_summary_val
})
elif limit is not None and (processed_tasks_count + len(tasks_for_current_batch)) >= limit:
# Wenn das Hinzufügen dieses Tasks das Limit erreichen oder überschreiten würde,
# füge ihn noch hinzu (wird im nächsten Batch-Check gekürzt)
# und beende dann die Schleife
tasks_for_current_batch.append({
"row_num": i, "crm_branche": crm_branche_val, "beschreibung": crm_beschreibung_val,
"wiki_branche": wiki_branche_val, "wiki_kategorien": wiki_kategorien_val,
"website_summary": website_summary_val
})
self.logger.info(
f"Zeile {i} wurde als letzter Task vor Erreichen des Limits ({limit}) gesammelt.")
# Die execute_and_write_batch Logik wird getriggert, wenn die
# Schleife endet oder der Batch voll ist.
# Batch ausführen, wenn voll ODER es die letzte Zeile ist
if len(
tasks_for_current_batch) >= processing_batch_size or i == end_sheet_row:
_execute_and_write_batch(tasks_for_current_batch)
tasks_for_current_batch = [] # Batch-Liste für den nächsten Durchlauf leeren
# Sicherstellen, dass ein eventuell nicht voller letzter Batch auch noch verarbeitet wird,
# falls die Schleife durch das Limit beendet wurde und noch Tasks übrig
# sind.
if tasks_for_current_batch:
self.logger.debug(
f"Verarbeite verbleibenden Rest-Batch von {len(tasks_for_current_batch)} Tasks...")
_execute_and_write_batch(tasks_for_current_batch)
self.logger.info(
f"Brancheneinschaetzung (Parallel Batch) abgeschlossen. {processed_tasks_count} Zeilen verarbeitet, {skipped_count} Zeilen uebersprungen.")
def process_find_wiki_serp(
self,
start_sheet_row=None,
end_sheet_row=None,
limit=None,
min_employees=500,
min_umsatz=200):
"""
Sucht fehlende Wikipedia-URLs ueber SerpAPI.
"""
self.logger.info(
f"Starte Modus 'find_wiki_serp'. Filter: (Umsatz > {min_umsatz} MIO € ODER MA > {min_employees}). Bereich: {start_sheet_row if start_sheet_row else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit else 'Unbegrenzt'}")
"""
Sucht fehlende Wikipedia-URLs (Spalte M = k.A.) ueber SerpAPI fuer Unternehmen mit
(Umsatz CRM > min_umsatz MIO € ODER Mitarbeiter CRM > min_employees)
UND wenn der SerpAPI Wiki Search Timestamp (AY) leer ist.
Traegt gefundene URLs in Spalte M ein. Setzt ReEval-Flag (A)
und loescht abhaengige Wiki-Spalten (N-V, AN, AO, AP, AX).
Setzt Timestamp in Spalte AY, wann die Suche durchgefuehrt wurde (unabhaengig vom Ergebnis).
Args:
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AY).
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht uebersprungener) Zeilen. Defaults to None (Unbegrenzt).
min_employees (int, optional): Mindestanzahl Mitarbeiter (Spalte K) als Teilfilter. Defaults to 500.
min_umsatz (int, optional): Mindestumsatz in MIO € (Spalte J) als Teilfilter. Defaults to 200.
"""
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
# Logge die Konfiguration des Batch-Laufs
self.logger.info(
f"Starte Modus 'find_wiki_serp' (AY, M, A). Filter: (Umsatz CRM > {min_umsatz} MIO € ODER Mitarbeiter CRM > {min_employees}). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT
# --- Daten laden und Startzeile ermitteln ---
# Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt
if start_sheet_row is None:
self.logger.info(
"Automatische Ermittlung der Startzeile basierend auf leeren AY...") # <<< GEÄNDERT
# Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AY (Block 1 Column Map).
# Standardmaessig ab Zeile 7
start_data_index_no_header = self.sheet_handler.get_start_row_index(
check_column_key="SerpAPI Wiki Search Timestamp", min_sheet_row=7)
# Wenn get_start_row_index -1 zurueckgibt (Fehler)
if start_data_index_no_header == -1:
self.logger.error(
"FEHLER bei automatischer Ermittlung der Startzeile. Breche Batch ab.") # <<< GEÄNDERT
return # Beende die Methode
# Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten
# Daten-Index
start_sheet_row = start_data_index_no_header + \
self.sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut
self.logger.info(
f"Automatisch ermittelte Startzeile (erste leere AY Zelle): {start_sheet_row}") # <<< GEÄNDERT
else:
# Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein.
# Der load_data Aufruf ist mit retry_on_failure dekoriert (Block
# 2).
if not self.sheet_handler.load_data():
self.logger.error(
"FEHLER beim Laden der Daten fuer process_find_wiki_serp.") # <<< GEÄNDERT
return # Beende die Methode, wenn das Laden fehlschlaegt
# Holen Sie die gesamte Datenliste (inklusive Header) aus dem
# SheetHandler.
all_data = self.sheet_handler.get_all_data_with_headers()
# Annahme: header_rows ist als Attribut im SheetHandler verfuegbar
# (Block 14).
header_rows = self.sheet_handler._header_rows
total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet
# Berechne Endzeile, wenn nicht manuell gesetzt
if end_sheet_row is None:
end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
# Logge den verarbeitungsbereich
self.logger.info(
f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT
# Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht
# ueber Gesamtzeilen)
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
self.logger.info(
"Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
return # Beende die Methode, wenn der Bereich leer ist
# --- Indizes und Buchstaben ---
# Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP
# (Block 1) vorhanden sind
required_keys = [
# AY, M, J, K (Pruefkriterien / Timestamp)
"SerpAPI Wiki Search Timestamp", "Wiki URL", "CRM Umsatz", "CRM Anzahl Mitarbeiter",
# A, B, D (Daten fuer Suche / Updates)
"ReEval Flag", "CRM Name", "CRM Website",
# N-R (Spalten zum Leeren)
"Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien",
# S-U (Spalten zum Leeren)
"Chat Wiki Konsistenzpruefung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel",
# V, AN, AO (Spalten zum Leeren)
"Begruendung bei Abweichung", "Wikipedia Timestamp", "Timestamp letzte Pruefung",
"Version", "Wiki Verif. Timestamp" # AP, AX (Spalten zum Leeren)
]
# Erstellen Sie ein Dictionary mit Schluesseln und Indizes
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
# Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden
# wurden
if None in col_indices.values():
missing = [k for k, v in col_indices.items() if v is None]
self.logger.critical(
f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_find_wiki_serp: {missing}. Breche ab.") # <<< GEÄNDERT
return # Beende die Methode bei kritischem Fehler
# Ermitteln Sie die Spaltenbuchstaben fuer Updates und Leerung (nutzt
# interne Helfer _get_col_letter Block 14)
ts_ay_letter = _get_col_letter(
col_indices["SerpAPI Wiki Search Timestamp"] +
1) # Timestamp zu setzen (AY)
m_letter = _get_col_letter(
col_indices["Wiki URL"] + 1) # Wiki URL Spalte (M)
a_letter = _get_col_letter(
col_indices["ReEval Flag"] + 1) # ReEval Flag (A)
# Spalten N-V leeren.
# N ist Wiki Absatz, V ist Begruendung bei Abweichung.
n_idx = col_indices["Wiki Absatz"]
v_idx = col_indices["Begruendung bei Abweichung"]
# Erstellen Sie den Bereichsnamen (z.B. "N:V")
n_letter = _get_col_letter(n_idx + 1)
v_letter = _get_col_letter(v_idx + 1)
nv_range_letter = f'{n_letter}:{v_letter}' # z.B. N:V
# Erstellen Sie eine Liste von leeren Strings fuer diesen Bereich
# Anzahl der Spalten = V_Index - N_Index + 1
empty_nv_values = [''] * (v_idx - n_idx + 1)
# Timestamps AN, AO, AP, AX leeren.
an_letter = _get_col_letter(
col_indices["Wikipedia Timestamp"] + 1) # AN (Wiki Extraction TS)
ao_letter = _get_col_letter(
col_indices["Timestamp letzte Pruefung"] +
1) # AO (Chat Evaluation TS)
ap_letter = _get_col_letter(
col_indices["Version"] + 1) # AP (Version)
ax_letter = _get_col_letter(
col_indices["Wiki Verif. Timestamp"] + 1) # AX (Wiki Verif. TS)
# --- Verarbeitung ---
# Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1)
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)
# Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts)
all_sheet_updates = []
# Zaehlt Zeilen, fuer die SerpAPI versucht wurde (im Rahmen des Limits
# zaehlen).
processed_count = 0
# Zaehlt Zeilen, die uebersprungen wurden (verschiedene Gruende).
skipped_count = 0
# Zaehlt Zeilen, wo eine URL gefunden und eingetragen wurde.
found_urls_count = 0
# Aktueller Zeitstempel fuer den AY Timestamp
now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Iteriere durch die Datenzeilen im definierten Bereich (1-basierte
# Sheet-Zeilennummer)
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
# Pruefen Sie, ob das Ende des Sheets erreicht wurde
if row_index_in_list >= total_sheet_rows:
break # Ende des Sheets erreicht
row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile
# Stellen Sie sicher, dass die Zeile nicht leer ist
if not any(cell and isinstance(cell, str) and cell.strip()
for cell in row):
# self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).")
# # Zu viel Laerm im Debug
skipped_count += 1 # Zaehlen als uebersprungen
continue # Springe zur naechsten Zeile
# --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
# Kriterium: SerpAPI Wiki Search Timestamp (AY) ist leer.
# UND Wiki URL (M) ist leer oder "k.A.".
# UND (Umsatz CRM (J) > min_umsatz MIO € ODER Mitarbeiter CRM (K) >
# min_employees).
# Holen Sie die Werte aus den entsprechenden Spalten (nutzt interne
# Helfer _get_cell_value_safe)
ay_value = self._get_cell_value_safe(
row, "SerpAPI Wiki Search Timestamp").strip() # Block 1 Column Map
m_value = self._get_cell_value_safe(
row, "Wiki URL").strip() # Block 1 Column Map
umsatz_val_str = self._get_cell_value_safe(
row, "CRM Umsatz") # Block 1 Column Map
ma_val_str = self._get_cell_value_safe(
row, "CRM Anzahl Mitarbeiter") # Block 1 Column Map
# Pruefen Sie, ob AY leer ist.
is_ay_empty = not ay_value
# Pruefen Sie, ob M leer oder "k.A." ist.
is_m_empty_or_ka = not m_value or (isinstance(
m_value, str) and m_value.lower() == "k.a.")
# Nutze die globale Hilfsfunktion (Block 5), um die Werte fuer den Groessen-Filter zu bekommen.
# get_numeric_filter_value gibt 0 fuer ungueltige/leere Werte
# zurueck.
umsatz_val_mio = get_numeric_filter_value(
umsatz_val_str, is_umsatz=True)
ma_val_num = get_numeric_filter_value(ma_val_str, is_umsatz=False)
# Pruefen Sie, ob das Groessen-Kriterium erfuellt ist.
size_criteria_met = (
umsatz_val_mio > min_umsatz) or (
ma_val_num > min_employees)
# Verarbeitung ist noetig, wenn AY leer ist UND M leer/k.A. ist UND
# das Groessen-Kriterium erfuellt ist.
processing_needed_for_row = is_ay_empty and is_m_empty_or_ka and size_criteria_met
# Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
log_check = (
i < start_sheet_row +
5) or (
i %
100 == 0) or (processing_needed_for_row)
if log_check:
company_name = self._get_cell_value_safe(
row, "CRM Name").strip() # Block 1 Column Map
self.logger.debug(
f"Zeile {i} ({company_name[:50]}... SerpAPI Wiki Search Check): AY leer? {is_ay_empty}, M leer/k.A.? {is_m_empty_or_ka}, Groesse ({umsatz_val_mio:.1f} Mio, {ma_val_num} MA) Kriterium ({min_umsatz} Mio, {min_employees} MA)? {size_criteria_met}. Benötigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT
# Wenn die Verarbeitung fuer diese Zeile nicht noetig ist
if not processing_needed_for_row:
skipped_count += 1 # Zaehlen als uebersprungene Zeile
continue # Springe zur naechsten Zeile
# --- Wenn Verarbeitung noetig: Fuehre SerpAPI Suche aus ---
# Zaehle die Zeile, fuer die SerpAPI versucht wird (im Rahmen des
# Limits zaehlen)
processed_count += 1
# Pruefe das Limit fuer verarbeitete Zeilen
if limit is not None and isinstance(
limit, int) and limit > 0 and processed_count > limit:
# Wenn das Limit erreicht ist und es ein positives Limit gibt
self.logger.info(
f"Verarbeitungslimit ({limit}) fuer process_find_wiki_serp erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
break # Brich die Schleife ab
# Hole Firmenname und Website fuer die Suche (nutzt interne Helfer
# _get_cell_value_safe)
company_name = self._get_cell_value_safe(
row, "CRM Name").strip() # Block 1 Column Map
# Block 1 Column Map (Website kann fuer SerpAPI Kontext hilfreich
# sein)
website_url = self._get_cell_value_safe(row, "CRM Website").strip()
# Wenn kein Firmenname vorhanden ist, kann die Suche nicht
# durchgefuehrt werden
if not company_name:
self.logger.warning(
f"Zeile {i}: Uebersprungen (kein Firmenname fuer Suche vorhanden in Spalte B).") # <<< GEÄNDERT
skipped_count += 1 # Zaehlen als uebersprungene Zeile, da Suche nicht moeglich
# Setze AY Timestamp auch hier, um nicht immer wieder zu versuchen
# Erstelle leeres Update-Dict, damit extend funktioniert
updates = []
updates.append({'range': f'{ts_ay_letter}{i}', 'values': [
[now_timestamp_str]]}) # Block 1 Column Map
# Fuege dieses einzelne Update zur Liste hinzu
all_sheet_updates.extend(updates)
updates = [] # Leere die lokale Liste
continue # Springe zur naechsten Zeile
self.logger.info(
f"Zeile {i}: Suche Wiki-URL fuer '{company_name[:100]}...' (Umsatz (Mio): {umsatz_val_mio:.1f}, MA: {ma_val_num}) ueber SerpAPI...") # <<< GEÄNDERT
# Führe die SerpAPI Suche durch (nutzt globale Funktion Block 10 mit Retry).
# serp_wikipedia_lookup ist mit retry_on_failure dekoriert (Block 2).
# Wenn serp_wikipedia_lookup nach Retries fehlschlaegt, wirft er
# eine Exception.
wiki_url_found = None # Initialisiere mit None
try:
wiki_url_found = serp_wikipedia_lookup(
company_name, website=website_url) # Nutzt globalen Helfer (Block 10)
# Wenn serp_wikipedia_lookup erfolgreich ist, gibt es die URL
# oder None zurueck.
except Exception as e_serp_wiki:
# Wenn serp_wikipedia_lookup eine Exception wirft (nach
# Retries)
self.logger.error(
f"FEHLER bei serp_wikipedia_lookup fuer Zeile {i} ('{company_name[:100]}...'): {e_serp_wiki}") # <<< GEÄNDERT
# wiki_url_found bleibt None. Fahren Sie fort.
pass # Fahren Sie fort, um Timestamp zu setzen und Updates vorzubereiten
# --- Updates vorbereiten ---
# Timestamp AY IMMER setzen, nachdem der Versuch gemacht wurde,
# unabhaengig vom Ergebnis der Suche.
updates_for_row = [] # Lokale Liste fuer Updates dieser Zeile
updates_for_row.append({'range': f'{ts_ay_letter}{i}', 'values': [
[now_timestamp_str]]}) # Block 1 Column Map
# Wenn eine URL gefunden wurde, bereite weitere Updates vor.
# Eine gefundene URL ist ein String, der nicht None ist und nicht
# "k.A." oder Fehlerstring ist.
if wiki_url_found and isinstance(
wiki_url_found,
str) and wiki_url_found.lower() not in [
"k.a.",
"kein artikel gefunden"] and not wiki_url_found.startswith("FEHLER"): # Korrektur Pruefung
self.logger.info(
f" -> URL gefunden: {wiki_url_found[:100]}... Bereite Update vor (Setze M, A; Loesche N-V, AN, AO, AP, AX).") # <<< GEÄNDERT
found_urls_count += 1 # Zaehle den Fund
# Setze M (Wiki URL) mit der gefundenen URL
updates_for_row.append({'range': f'{m_letter}{i}', 'values': [
[wiki_url_found]]}) # Block 1 Column Map
# Setze ReEval Flag (A) auf 'x' (signalisiert, dass eine
# Re-Evaluation noetig ist)
updates_for_row.append({'range': f'{a_letter}{i}', 'values': [
['x']]}) # Block 1 Column Map
# Leere Spalten N-V.
# Fuege das Update zum Leeren des Bereichs V-Y hinzu, falls der
# Bereichsname ermittelt werden konnte.
if nv_range_letter: # Pruefe, ob der Bereichsname ermittelt werden konnte.
updates_for_row.append({'range': f'{n_letter}{i}:{v_letter}{i}', 'values': [
empty_nv_values]}) # Block 1 Column Map, lokale Variable
else:
self.logger.warning(
f"Konnte Spaltenbereich N-V ({n_letter}:{v_letter}) fuer Leerung nicht ermitteln fuer Zeile {i}. Leerung uebersprungen.") # <<< GEÄNDERT
# Leere Timestamps AN, AO, AP, AX.
# Dies setzt die Zeile zurueck, damit andere Schritte sie
# spaeter bearbeiten.
# Block 1 Column Map
updates_for_row.append(
{'range': f'{an_letter}{i}', 'values': [['']]})
# Block 1 Column Map
updates_for_row.append(
{'range': f'{ao_letter}{i}', 'values': [['']]})
# Block 1 Column Map
updates_for_row.append(
{'range': f'{ap_letter}{i}', 'values': [['']]})
# Block 1 Column Map
updates_for_row.append(
{'range': f'{ax_letter}{i}', 'values': [['']]})
else:
# Wenn keine Wiki-URL ueber SerpAPI gefunden wurde
self.logger.debug(
f" -> Keine Wiki-URL fuer '{company_name[:100]}...' ueber SerpAPI gefunden.") # <<< GEÄNDERT
# Nur AY Timestamp wird gesetzt, was bereits oben passiert ist.
# Keine weiteren Updates fuer M, A, N-V etc.
# Sammle die Updates fuer diese Zeile in der globalen Liste.
all_sheet_updates.extend(updates_for_row)
# Sende gesammelte Sheet Updates, wenn das Update-Batch-Limit erreicht ist.
# update_batch_row_limit wird aus Config geholt (Block 1).
# Die Anzahl der Updates pro Zeile variiert (1 bei nicht gefunden, ca. 10+ bei gefunden).
# Pruefen Sie einfach die Laenge der gesammelten Liste.
if len(all_sheet_updates) >= update_batch_row_limit * \
5: # Grobe Schaetzung: im Schnitt 5 Updates pro Zeile
self.logger.debug(
f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
# Wenn es fehlschlaegt, wird es intern geloggt.
success = self.sheet_handler.batch_update_cells(
all_sheet_updates)
if success:
self.logger.info(
f" Sheet-Update fuer {len(all_sheet_updates)} Zellen erfolgreich.") # <<< GEÄNDERT
# Der Fehlerfall wird von batch_update_cells geloggt
# Leere die gesammelten Updates nach dem Senden.
all_sheet_updates = []
# Kleine Pause nach jeder SerpAPI-Suche (nutzt Config Block 1).
# Der retry_on_failure Decorator (Block 2) kuemmert sich um Retries mit Backoff.
# Dies ist eine globale Rate-Limit-Vorsorge zwischen einzelnen
# Anfragen.
serp_delay = getattr(Config, 'SERPAPI_DELAY', 1.5)
# self.logger.debug(f"Warte {serp_delay:.2f}s nach SerpAPI
# Suche...") # Zu viel Laerm im Debug
time.sleep(serp_delay)
# --- Finale Sheet Updates senden ---
# Sende alle verbleibenden gesammelten Updates in einem letzten
# Batch-Update.
if all_sheet_updates:
self.logger.info(
f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block
# 14) mit Retry.
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
if success:
# <<< GEÄNDERT
self.logger.info(f"FINALES Sheet-Update erfolgreich.")
# Der Fehlerfall wird von batch_update_cells geloggt
# Logge den Abschluss des Modus
self.logger.info(
f"Modus 'find_wiki_serp' abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {found_urls_count} URLs gefunden & eingetragen, {skipped_count} Zeilen uebersprungen.") # <<< GEÄNDERT
def process_contact_search(
self,
start_sheet_row=None,
end_sheet_row=None,
limit=None):
"""
Sucht LinkedIn Kontakte ueber SerpAPI.
"""
self.logger.info(
f"Starte Contact Research (Batch). Bereich: {start_sheet_row if start_sheet_row else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit else 'Unbegrenzt'}")
"""
Sucht LinkedIn Kontakte ueber SerpAPI fuer Zeilen, bei denen der
Contact Search Timestamp (AM) leer ist. Traegt Trefferzahlen in
AI-AL und den Timestamp in AM ein. Schreibt Details optional in ein 'Contacts' Blatt.
Args:
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AM).
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht uebersprungener) Zeilen. Defaults to None (Unbegrenzt).
"""
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
# Logge die Konfiguration des Batch-Laufs
self.logger.info(
f"Starte Contact Research (Batch AM, AI-AL). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT
# --- Daten laden und Startzeile ermitteln ---
# Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt
if start_sheet_row is None:
self.logger.info(
"Automatische Ermittlung der Startzeile basierend auf leeren AM...") # <<< GEÄNDERT
# Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AM (Block 1 Column Map).
# Standardmaessig ab Zeile 7
start_data_index_no_header = self.sheet_handler.get_start_row_index(
check_column_key="Contact Search Timestamp", min_sheet_row=7)
# Wenn get_start_row_index -1 zurueckgibt (Fehler)
if start_data_index_no_header == -1:
self.logger.error(
"FEHLER bei automatischer Ermittlung der Startzeile. Breche Batch ab.") # <<< GEÄNDERT
return # Beende die Methode
# Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten
# Daten-Index
start_sheet_row = start_data_index_no_header + \
self.sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut
self.logger.info(
f"Automatisch ermittelte Startzeile (erste leere AM Zelle): {start_sheet_row}") # <<< GEÄNDERT
else:
# Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein.
# Der load_data Aufruf ist mit retry_on_failure dekoriert (Block
# 2).
if not self.sheet_handler.load_data():
self.logger.error(
"FEHLER beim Laden der Daten fuer process_contact_search.") # <<< GEÄNDERT
return # Beende die Methode, wenn das Laden fehlschlaegt
# Holen Sie die gesamte Datenliste (inklusive Header) aus dem
# SheetHandler.
all_data = self.sheet_handler.get_all_data_with_headers()
# Annahme: header_rows ist als Attribut im SheetHandler verfuegbar
# (Block 14).
header_rows = self.sheet_handler._header_rows
total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet
# Berechne Endzeile, wenn nicht manuell gesetzt
if end_sheet_row is None:
end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
# Logge den verarbeitungsbereich
self.logger.info(
f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT
# Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht
# ueber Gesamtzeilen)
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
self.logger.info(
"Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
return # Beende die Methode, wenn der Bereich leer ist
# --- Indizes und Buchstaben ---
# Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP
# (Block 1) vorhanden sind
required_keys = [
"Contact Search Timestamp", # AM - Pruefkriterium / Timestamp
# B, C, D (Daten fuer Suche)
"CRM Name", "CRM Kurzform", "CRM Website",
# AI, AJ (Zielspalten fuer Trefferzahlen)
"Linked Serviceleiter gefunden", "Linked It-Leiter gefunden",
# AK, AL (Zielspalten fuer Trefferzahlen)
"Linked Management gefunden", "Linked Disponent gefunden"
]
# Erstellen Sie ein Dictionary mit Schluesseln und Indizes
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
# Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden
# wurden
if None in col_indices.values():
missing = [k for k, v in col_indices.items() if v is None]
self.logger.critical(
f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_contact_search: {missing}. Breche ab.") # <<< GEÄNDERT
return # Beende die Methode bei kritischem Fehler
# Ermitteln Sie die Spaltenbuchstaben fuer Updates (AI-AL, AM) (nutzt
# interne Helfer _get_col_letter Block 14)
ts_am_letter = _get_col_letter(
col_indices["Contact Search Timestamp"] + 1) # AM
ai_letter = _get_col_letter(
col_indices["Linked Serviceleiter gefunden"] + 1) # AI
aj_letter = _get_col_letter(
col_indices["Linked It-Leiter gefunden"] + 1) # AJ
ak_letter = _get_col_letter(
col_indices["Linked Management gefunden"] + 1) # AK
al_letter = _get_col_letter(
col_indices["Linked Disponent gefunden"] + 1) # AL
# Positionen, nach denen gesucht wird (kann in Config verschoben werden Block 1)
# Die Zuordnung zur Zaehlspalte (AI-AL) muss hier im Code erfolgen.
positions_to_search = {
"Serviceleiter": ["Serviceleiter", "Leiter Kundendienst", "Einsatzleiter"],
"IT-Leiter": ["IT-Leiter", "Leiter IT"],
# Management erweitert
"Management": ["Geschäftsführer", "Vorstand", "Inhaber", "CEO", "CTO", "COO", "Kaufmännischer Leiter", "Technischer Leiter"],
"Disponent": ["Disponent", "Einsatzplaner"] # Disponent erweitert
}
# Stellen Sie sicher, dass die Schluessel im Dict den COLUMN_MAP Keys (AI-AL) entsprechen,
# damit die Zaehlung korrekt zugeordnet werden kann.
# --- Kontakte-Blatt oeffnen oder erstellen ---
contacts_sheet = None # Initialisiere mit None
# Der Zugriff auf das Spreadsheet-Objekt erfolgt ueber den SheetHandler
# (Block 14).
if self.sheet_handler and self.sheet_handler.sheet and self.sheet_handler.sheet.spreadsheet:
try:
# Versuche, das Sheet "Contacts" zu oeffnen
contacts_sheet = self.sheet_handler.sheet.spreadsheet.worksheet(
"Contacts")
self.logger.info("Blatt 'Contacts' gefunden.") # <<< GEÄNDERT
except gspread.exceptions.WorksheetNotFound:
# Wenn nicht gefunden, erstelle es.
# <<< GEÄNDERT
self.logger.info(
"Blatt 'Contacts' nicht gefunden, erstelle neu...")
try:
# Definieren Sie den Header fuer das neue Blatt
contacts_header = [
"Firmenname",
"CRM Kurzform",
"Website",
"Geschlecht",
"Vorname",
"Nachname",
"Position",
"Suchbegriffskategorie",
"E-Mail-Adresse",
"LinkedIn-Link",
"Timestamp"]
# Schaetzen Sie die Anzahl der Zeilen und Spalten fuer das
# neue Blatt (kann angepasst werden)
num_cols_contacts_sheet = len(contacts_header)
# Erstellen Sie das neue Blatt
contacts_sheet = self.sheet_handler.sheet.spreadsheet.add_worksheet(
title="Contacts", rows="5000", cols=num_cols_contacts_sheet)
# Schreiben Sie den Header in die erste Zeile des neuen Blattes
# Nutzt _get_col_letter interne Methode des SheetHandlers
# (Block 14)
contacts_sheet.update(
values=[contacts_header],
range_name=f"A1:{_get_col_letter(num_cols_contacts_sheet)}1")
# <<< GEÄNDERT
self.logger.info(
"Neues Blatt 'Contacts' erstellt und Header eingetragen.")
except Exception as e_create_sheet:
# Fange Fehler bei der Erstellung des Blattes ab und logge
# sie.
self.logger.critical(
f"FEHLER: Konnte Blatt 'Contacts' nicht erstellen: {e_create_sheet}. Kontakt-Details koennen NICHT gespeichert werden.") # <<< GEÄNDERT
# Logge den Traceback.
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
# Setze contacts_sheet auf None, um spaetere
# Schreibversuche zu verhindern
contacts_sheet = None
else:
# Wenn SheetHandler oder Sheet-Objekt nicht verfuegbar war.
self.logger.warning(
"SheetHandler oder Sheet-Objekt nicht verfuegbar. Kann Blatt 'Contacts' nicht oeffnen/erstellen. Kontakt-Details werden NICHT gespeichert.") # <<< GEÄNDERT
contacts_sheet = None # Sicherstellen, dass contacts_sheet None ist
# --- Verarbeitung ---
# Gesammelte Updates fuer Batch-Schreiben ins Hauptblatt (Liste von
# Dicts)
all_sheet_updates = []
# Gesammelte Zeilen fuer append_rows ins Contacts-Blatt (Liste von
# Listen)
all_contact_rows_to_append = []
# append_rows kann grosse Batches handhaben, wir koennen hier mehr sammeln als beim Batch-Update.
# Oder wir schreiben pro Firma in das Contacts-Blatt (weniger sammelbar).
# Fuer diesen Modus sammeln wir alle Kontaktzeilen und schreiben am
# Ende gesammelt mit append_rows.
# Zaehlt Zeilen im Hauptblatt, die fuer die Verarbeitung in Frage
# kommen und in den Batch aufgenommen werden (im Rahmen des Limits).
processed_count = 0
# Zaehlt Zeilen im Hauptblatt, die uebersprungen wurden (wegen AM oder
# fehlender Daten).
skipped_count = 0
# Aktueller Zeitstempel fuer die AM Timestamp und Kontaktzeilen
now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Iteriere durch die Datenzeilen im definierten Bereich (1-basierte
# Sheet-Zeilennummer)
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
# Pruefen Sie, ob das Ende des Sheets erreicht wurde
if row_index_in_list >= total_sheet_rows:
break # Ende des Sheets erreicht
row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile
# Stellen Sie sicher, dass die Zeile nicht leer ist
if not any(cell and isinstance(cell, str) and cell.strip()
for cell in row):
# self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).")
# # Zu viel Laerm im Debug
skipped_count += 1 # Zaehlen als uebersprungen
continue # Springe zur naechsten Zeile
# --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
# Kriterium: Contact Search Timestamp (AM) ist leer.
# ZUSAETZLICH: Pruefen, ob CRM Name, Kurzform und Website vorhanden
# und gueltig sind.
# Holen Sie den Wert aus Spalte AM (Contact Search Timestamp)
# (nutzt interne Helfer _get_cell_value_safe)
am_value = self._get_cell_value_safe(
row, "Contact Search Timestamp").strip() # Block 1 Column Map
# Pruefung basiert darauf, ob AM leer ist.
processing_needed_based_on_status = not am_value
# Holen Sie die benoetigten Daten fuer die Suche (nutzt interne
# Helfer _get_cell_value_safe)
company_name = self._get_cell_value_safe(
row, "CRM Name").strip() # Block 1 Column Map
crm_kurzform = self._get_cell_value_safe(
row, "CRM Kurzform").strip() # Block 1 Column Map
website = self._get_cell_value_safe(
row, "CRM Website").strip() # Block 1 Column Map
# Pruefen Sie, ob die Mindestdaten fuer die Suche vorhanden und gueltig sind.
# Name und Kurzform duerfen nicht leer sein. Website muss vorhanden
# und gueltig aussehen.
has_min_data_for_search = company_name and crm_kurzform and website and isinstance(
website, str) and website.lower() not in [
"k.a.", "kein artikel gefunden", "fehler bei suche", "http:"] # Fuege "http:" hinzu
# Verarbeitung ist noetig, wenn AM leer ist UND die Mindestdaten
# fuer die Suche vorhanden sind.
processing_needed_for_row = processing_needed_based_on_status and has_min_data_for_search
# Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
log_check = (
i < start_sheet_row +
5) or (
i %
100 == 0) or (processing_needed_for_row)
if log_check:
# Gekuerzt loggen
company_name_log = company_name[:50] + \
'...' if len(company_name) > 50 else company_name
self.logger.debug(
f"Zeile {i} ({company_name_log} Contact Check): AM leer? {processing_needed_based_on_status}, Mindestdaten gueltig? {has_min_data_for_search}. Benötigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT
# Wenn die Verarbeitung fuer diese Zeile nicht noetig ist
if not processing_needed_for_row:
skipped_count += 1 # Zaehlen als uebersprungene Zeile
continue # Springe zur naechsten Zeile
# --- Wenn Verarbeitung noetig: Fuehre LinkedIn Suche(n) aus ---
# Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im
# Rahmen des Limits zaehlen)
processed_count += 1
# Pruefe das Limit fuer verarbeitete Zeilen
if limit is not None and isinstance(
limit, int) and limit > 0 and processed_count > limit:
# Wenn das Limit erreicht ist und es ein positives Limit gibt
self.logger.info(
f"Verarbeitungslimit ({limit}) fuer process_contact_search erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
break # Brich die Schleife ab
self.logger.info(
f"Zeile {i}: Suche LinkedIn Kontakte fuer '{crm_kurzform[:50]}...' ({website[:50]}...)...") # <<< GEÄNDERT
# Liste zum Sammeln aller gefundenen Kontakte fuer DIESE Zeile
# (Liste von Dicts)
all_found_contacts_for_row = []
# Dictionary zum Zaehlen der Treffer pro Kategorie fuer diese Zeile
# (AI-AL)
contact_counts_for_row = {
key: 0 for key in positions_to_search.keys()}
# Führe die Suche fuer jede Positionskategorie durch.
# positions_to_search Dictionary ist oben definiert.
for category, queries in positions_to_search.items():
# Führe die Suche fuer jede spezifische Abfrage innerhalb der Kategorie durch.
# search_linkedin_contacts (Block 10) nutzt den retry_on_failure Decorator (Block 2).
# Wenn search_linkedin_contacts fehlschlaegt, wirft es eine
# Exception oder gibt eine leere Liste zurueck.
# Dictionary zum Sammeln eindeutiger Kontakte {linkedin_url:
# contact_data} fuer diese Kategorie
found_contacts_in_category = {}
for position_query in queries:
self.logger.debug(
f" -> Suche nach Position: '{position_query}' bei '{crm_kurzform[:50]}'...") # <<< GEÄNDERT
try:
# Rufe die globale Funktion search_linkedin_contacts auf (Block 10).
# Limitieren Sie die Anzahl der SerpAPI Ergebnisse pro
# Query, um Kosten zu managen.
contacts_from_query = search_linkedin_contacts(
company_name=company_name,
# Voller Name fuer Kontext (optional genutzt)
website=website, # Website fuer Email-Generierung spaeter
position_query=position_query, # Die spezifische Position
crm_kurzform=crm_kurzform, # Die Kurzform der Firma
num_results=getattr(
Config,
'SERPAPI_LINKEDIN_RESULTS_PER_QUERY',
5) # Konfigurierbar in Config (Block 1)
)
# Fuege die gefundenen Kontakte (mit Suchkategorie) zur
# Liste fuer diese Kategorie hinzu, dedupliziert ueber
# URL.
for contact in contacts_from_query:
linkedin_url = contact.get("LinkedInURL")
if linkedin_url and isinstance(
linkedin_url, str) and linkedin_url.strip(): # Stelle sicher, dass URL gueltig ist
if linkedin_url not in found_contacts_in_category:
# Wenn die URL noch nicht in dieser
# Kategorie gefunden wurde, fuege den
# Kontakt hinzu.
# Speichere die Kategorie, die den Treffer
# brachte
contact["Suchbegriffskategorie"] = category
found_contacts_in_category[linkedin_url] = contact
# else: Wenn die URL bereits gefunden wurde, mache nichts (erste Kategorie wird beibehalten).
# self.logger.debug(f" -> Gefunden:
# {contact.get('Vorname')}
# {contact.get('Nachname')}
# ({contact.get('Position')})") # Zu viel Laerm
# im Debug
except Exception as e_linkedin_search:
# Wenn search_linkedin_contacts eine Exception wirft (nach Retries)
# Der Fehler wird bereits vom retry_on_failure
# Decorator oder search_linkedin_contacts geloggt.
self.logger.error(
f"FEHLER bei search_linkedin_contacts fuer Zeile {i} (Query: '{position_query}', Firma: '{crm_kurzform[:50]}...'): {e_linkedin_search}") # <<< GEÄNDERT
pass # Faert fort mit der naechsten Query oder Kategorie
# Pause nach jeder SerpAPI Suche (pro position_query)
# Nutzt Config.SERPAPI_DELAY (Block 1).
serp_delay = getattr(Config, 'SERPAPI_DELAY', 1.5)
# self.logger.debug(f"Warte {serp_delay:.2f}s nach LinkedIn
# Suche fuer '{position_query}'...") # Zu viel Laerm im
# Debug
time.sleep(serp_delay)
# Zaehle die eindeutigen Treffer in dieser Kategorie nach allen
# Queries innerhalb der Kategorie.
contact_counts_for_row[category] = len(
found_contacts_in_category)
# Fuege die eindeutigen Kontakte DIESER Kategorie zur
# Gesamtliste fuer DIESE Zeile hinzu.
all_found_contacts_for_row.extend(
found_contacts_in_category.values())
# --- Verarbeite gefundene Kontakte und bereite Updates vor ---
# Liste von Listen fuer append_rows ins 'Contacts' Blatt
rows_to_append_to_contacts_sheet = []
# Updates fuer das Hauptblatt (AI-AL, AM) fuer DIESE Zeile
main_sheet_updates_for_row = []
# Timestamp fuer DIESE Zeile/Kontakte
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Fuegen Sie die Updates fuer die Trefferzahlen im Hauptblatt hinzu (nutzt interne Helfer _get_col_letter Block 14)
# Stellen Sie sicher, dass die Spaltenbuchstaben korrekt sind (AI,
# AJ, AK, AL) (aus oben ermittelt)
main_sheet_updates_for_row.append({'range': f'{ai_letter}{i}', 'values': [
[str(contact_counts_for_row.get("Serviceleiter", 0))]]})
main_sheet_updates_for_row.append({'range': f'{aj_letter}{i}', 'values': [
[str(contact_counts_for_row.get("IT-Leiter", 0))]]})
main_sheet_updates_for_row.append({'range': f'{ak_letter}{i}', 'values': [
[str(contact_counts_for_row.get("Management", 0))]]})
main_sheet_updates_for_row.append({'range': f'{al_letter}{i}', 'values': [
[str(contact_counts_for_row.get("Disponent", 0))]]})
# Setze den Contact Search Timestamp (AM) fuer DIESE Zeile
main_sheet_updates_for_row.append(
{'range': f'{ts_am_letter}{i}', 'values': [[timestamp]]})
# Sammeln Sie diese Updates fuer das Hauptblatt in der globalen
# Liste all_sheet_updates.
all_sheet_updates.extend(main_sheet_updates_for_row)
self.logger.info(
f"Zeile {i}: Kontaktzahlen gesammelt: {contact_counts_for_row} Timestamp AM vorgemerkt fuer Update.") # <<< GEÄNDERT
# Bereiten Sie die Zeilen fuer das 'Contacts' Blatt vor (falls es existiert).
# all_found_contacts_for_row enthaelt alle gefundenen Kontakte fuer
# DIESE Zeile (mit evtl. Duplikaten bei URL).
# Pruefen Sie, ob das Contacts-Sheet geoeffnet/erstellt werden
# konnte (siehe Initialisierung oben)
if contacts_sheet:
# Führen Sie eine finale Deduplizierung ueber die LinkedIn-URL durch.
# Dictionary-Comprehension behält nur das letzte Vorkommen bei
# Duplikaten.
unique_contacts_for_row_dict = {c['LinkedInURL']: c for c in all_found_contacts_for_row if c.get(
'LinkedInURL')} # Filtere Kontakte ohne URL
unique_contacts_for_row = list(
unique_contacts_for_row_dict.values()) # Liste der eindeutigen Kontakte
# Iteriere ueber die eindeutigen Kontakte fuer diese Zeile
for contact in unique_contacts_for_row:
# Nutzt den extrahierten Vornamen
firstname = contact.get("Vorname", "")
# Nutzt den extrahierten Nachnamen
lastname = contact.get("Nachname", "")
# Generiere Geschlecht und E-Mail-Adresse (nutzt globale Funktionen Block 5).
# get_gender und get_email_address behandeln
# leere/ungueltige Eingaben.
gender_value = get_gender(firstname)
# Nutzt die Website der Firma (initial geladen)
email = get_email_address(firstname, lastname, website)
# Erstellen Sie die Liste der Werte fuer eine Zeile im
# 'Contacts' Blatt.
contact_row = [
contact.get("Firmenname", ""), # Voller Firmenname
contact.get("CRM Kurzform", ""), # Firmenkurzform
contact.get("Website", ""), # Website der Firma
gender_value, # Generiertes Geschlecht
firstname, # Extrahierter Vorname
lastname, # Extrahierter Nachname
# Extrahierte oder Fallback Position
contact.get("Position", ""),
# Kategorie, die den Treffer brachte
contact.get("Suchbegriffskategorie", ""),
email, # Generierte E-Mail-Adresse
# URL des LinkedIn Profils
contact.get("LinkedInURL", ""),
timestamp # Zeitstempel des Suchlaufs
]
# Fuegen Sie diese Zeile zur Liste der Zeilen hinzu, die
# spaeter ins Contacts-Sheet geschrieben werden.
rows_to_append_to_contacts_sheet.append(contact_row)
# Wenn Zeilen zum Anfuegen gefunden wurden
if rows_to_append_to_contacts_sheet:
# Fuegen Sie diese Zeilen zur globalen Liste aller Kontakte
# hinzu, die spaeter angefuegt werden.
all_contact_rows_to_append.extend(
rows_to_append_to_contacts_sheet)
self.logger.debug(
f" -> {len(rows_to_append_to_contacts_sheet)} eindeutige Kontakte fuer Zeile {i} zum Anfuegen an 'Contacts' vorgemerkt.") # <<< GEÄNDERT
else:
self.logger.debug(
f" -> Keine neuen Kontakte fuer Zeile {i} gefunden.") # <<< GEÄNDERT
# Sende gesammelte Sheet Updates (Hauptblatt) wenn das Update-Batch-Limit erreicht ist.
# update_batch_row_limit wird aus Config geholt (Block 1).
# Updates pro Zeile im Hauptblatt sind 5 (AI-AL + AM). Anzahl der
# Zeilen = len(all_sheet_updates) / 5.
rows_in_main_sheet_update_batch = len(all_sheet_updates) // 5
if rows_in_main_sheet_update_batch >= update_batch_row_limit:
self.logger.debug(
f" Sende gesammelte Hauptblatt-Updates ({rows_in_main_sheet_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
# Wenn es fehlschlaegt, wird es intern geloggt.
success = self.sheet_handler.batch_update_cells(
all_sheet_updates)
if success:
self.logger.info(
f" Hauptblatt-Update fuer {rows_in_main_sheet_update_batch} Zeilen erfolgreich.") # <<< GEÄNDERT
# Der Fehlerfall wird von batch_update_cells geloggt
# Leere die gesammelten Updates nach dem Senden.
all_sheet_updates = []
# Eine laengere Pause nach der Verarbeitung jeder Firma im Contact Search Modus.
# Dieser Modus ist API-intensiv und sollte langsamer laufen.
# Nutzt Config.RETRY_DELAY (Block 1).
# Laengere Pause, z.B. 80% der Retry-Wartezeit
pause_duration = getattr(Config, 'RETRY_DELAY', 10) * 0.8
self.logger.debug(
f"Warte {pause_duration:.2f}s nach Verarbeitung von Zeile {i}...") # <<< GEÄNDERT
time.sleep(pause_duration)
# --- Finale Sheet Updates (Hauptblatt) senden ---
# Sende alle verbleibenden gesammelten Updates in einem letzten
# Batch-Update.
if all_sheet_updates:
rows_in_final_main_sheet_update_batch = len(all_sheet_updates) // 5
self.logger.info(
f"Sende FINALE gesammelte Hauptblatt-Updates ({rows_in_final_main_sheet_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block
# 14) mit Retry.
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
if success:
# <<< GEÄNDERT
self.logger.info(f"FINALES Hauptblatt-Update erfolgreich.")
# Der Fehlerfall wird von batch_update_cells geloggt
# --- Finale Kontakte-Zeilen (Contacts Sheet) anfuegen ---
# Fuege alle gesammelten Kontaktzeilen auf einmal ans Ende des
# 'Contacts' Blattes an.
if contacts_sheet and all_contact_rows_to_append:
self.logger.info(
f"Fuege {len(all_contact_rows_to_append)} gesammelte Kontaktzeilen an Blatt 'Contacts' an...") # <<< GEÄNDERT
try:
# append_rows ist effizienter als batch_update fuer viele neue Zeilen am Ende.
# Die gspread.Worksheet.append_rows Methode kann Exceptions werfen (z.B. APIError),
# die hier gefangen werden koennen, wenn gewuenscht.
# Wenn sie eine Exception wirft, wird diese nicht von retry_on_failure auf
# process_contact_search behandelt, da append_rows nicht mit @retry_on_failure
# dekoriert ist. Sie muessten append_rows selbst in einen try/except Block packen oder
# es mit @retry_on_failure dekorieren (falls gspread es unterstuetzt).
# Fuer jetzt, fangen wir die Exception hier.
contacts_sheet.append_rows(
all_contact_rows_to_append,
value_input_option='USER_ENTERED') # Standard Option
self.logger.info(
f"Anfuegen von {len(all_contact_rows_to_append)} Kontaktzeilen erfolgreich.") # <<< GEÄNDERT
except Exception as e_append:
# Fange Fehler beim Anfuegen der Zeilen ab und logge sie.
self.logger.error(
f"FEHLER beim Anfuegen von Kontaktzeilen an Blatt 'Contacts': {type(e_append).__name__} - {e_append}") # <<< GEÄNDERT
# Logge den Traceback.
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
pass # Faert fort, der Rest des Skripts sollte nicht blockiert werden
# Logge den Abschluss des Modus
self.logger.info(
f"Modus 'contact_search' abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {skipped_count} Zeilen uebersprungen.") # <<< GEÄNDERT
def process_url_check(
self,
start_sheet_row=None,
end_sheet_row=None,
limit=None):
"""
Sucht nach Zeilen mit URL_CHECK_MARKER und versucht, eine neue URL zu finden.
"""
self.logger.info(
f"Starte Modus 'check_urls'. Bereich: {start_sheet_row if start_sheet_row else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit else 'Unbegrenzt'}")
"""
Sucht nach Zeilen, die in Spalte AR mit URL_CHECK_MARKER oder bekannten "k.A. (Fehler...)"
Mustern markiert sind UND bei denen der AY-Timestamp (SerpAPI Wiki Search Timestamp) leer ist
(außer bei URL_CHECK_MARKER, der immer eine Suche auslöst).
Versucht, eine neue URL ueber SerpAPI zu finden.
Wenn erfolgreich und URL ist NEU: Aktualisiert D, loescht AR, setzt ReEval-Flag (A) und loescht Timestamps.
Wenn URL identisch oder keine neue URL gefunden: AR wird entsprechend aktualisiert.
Setzt immer den AY-Timestamp (als Timestamp der URL-Prüfung).
Args:
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (ab Zeile 7).
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER Zeilen. Defaults to None (Unbegrenzt).
"""
self.logger.info(
f"Starte Modus 'check_urls'. Sucht nach '{URL_CHECK_MARKER}' oder 'k.A. (Fehler...)' in AR. Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...")
# --- Konfiguration holen ---
update_batch_row_limit = getattr(
Config,
'UPDATE_BATCH_ROW_LIMIT',
50) # <<< NEUE ZEILE HINZUGEFÜGT
if not self.sheet_handler.load_data():
self.logger.error("Fehler beim Laden der Daten fuer URL Check.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = self.sheet_handler._header_rows
total_sheet_rows = len(all_data)
if start_sheet_row is None:
start_sheet_row = header_rows + 1
if end_sheet_row is None:
end_sheet_row = total_sheet_rows
self.logger.info(
f"Suchbereich fuer URL Checks: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}.")
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
self.logger.info(
"Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.")
return
required_keys = [
"Website Rohtext",
"CRM Name",
"CRM Website",
"ReEval Flag",
"Website Scrape Timestamp",
"Timestamp letzte Pruefung",
"Wikipedia Timestamp",
"Wiki Verif. Timestamp",
"SerpAPI Wiki Search Timestamp",
"Version"]
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
if None in col_indices.values():
missing = [k for k, v in col_indices.items() if v is None]
self.logger.critical(
f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_url_check: {missing}. Breche ab.")
return
ar_letter = _get_col_letter(
col_indices["Website Rohtext"] + 1)
d_letter = _get_col_letter(
col_indices["CRM Website"] + 1)
a_letter = _get_col_letter(
col_indices["ReEval Flag"] + 1)
at_letter = _get_col_letter(
col_indices["Website Scrape Timestamp"] + 1)
ao_letter = _get_col_letter(
col_indices["Timestamp letzte Pruefung"] + 1)
an_letter = _get_col_letter(
col_indices["Wikipedia Timestamp"] + 1)
ax_letter = _get_col_letter(
col_indices["Wiki Verif. Timestamp"] + 1)
ay_letter = _get_col_letter(
col_indices["SerpAPI Wiki Search Timestamp"] +
1) # Timestamp dieser Funktion
ap_letter = _get_col_letter(
col_indices["Version"] + 1)
ka_error_patterns = [
"k.A.",
"k.A. (Extraktion leer)",
"k.A. (Nur Cookie-Banner erkannt)",
"k.A. (Kein Body gefunden)",
"k.A. (Fehler Parsing:",
"k.A. (Unerwarteter Fehler Task)",
"k.A. (Fehler Scraping:",
"k.A. (Timeout",
"k.A. (SSL Fehler",
"k.A. (Connection Error",
"k.A. (HTTP Error",
URL_CHECK_MARKER]
all_sheet_updates = []
processed_count = 0
skipped_count = 0
found_new_url_count = 0
now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1
if row_index_in_list >= total_sheet_rows:
break
row = all_data[row_index_in_list]
if not any(cell and isinstance(cell, str) and cell.strip()
for cell in row):
skipped_count += 1
continue
ar_value = self._get_cell_value_safe(
row, "Website Rohtext").strip()
# Verwende den spezifischen Timestamp für diese Funktion
ay_timestamp_value = self._get_cell_value_safe(
row, "SerpAPI Wiki Search Timestamp").strip()
processing_needed_for_row = False
is_marker_case = ar_value == URL_CHECK_MARKER
is_ka_error_case = any(
pattern in ar_value for pattern in ka_error_patterns if pattern != URL_CHECK_MARKER)
if is_marker_case: # URL_CHECK_MARKER löst immer eine Suche aus
processing_needed_for_row = True
# Alte k.A.-Fehler nur, wenn AY-Timestamp noch nicht gesetzt wurde
elif is_ka_error_case and not ay_timestamp_value:
processing_needed_for_row = True
if not processing_needed_for_row:
skipped_count += 1
continue
processed_count += 1
if limit is not None and isinstance(
limit, int) and limit > 0 and processed_count > limit:
self.logger.info(
f"Verarbeitungslimit ({limit}) fuer process_url_check erreicht.")
break
company_name = self._get_cell_value_safe(row, "CRM Name").strip()
old_crm_website_url = self._get_cell_value_safe(
row, "CRM Website").strip()
normalized_old_crm_url = simple_normalize_url(old_crm_website_url)
if not company_name:
self.logger.warning(
f"Zeile {i}: Uebersprungen (kein Firmenname fuer Suche vorhanden).")
skipped_count += 1
updates_for_row_skip = [{'range': f'{ay_letter}{i}', 'values': [
[now_timestamp_str]]}] # Timestamp trotzdem setzen
all_sheet_updates.extend(updates_for_row_skip)
continue
self.logger.info(
f"Zeile {i}: AR='{ar_value[:50]}...'. Suche neue URL für '{company_name[:50]}...' (Aktuell D: '{old_crm_website_url[:50]}...')...")
updates_for_row = []
new_url_found_str = "k.A."
try:
new_url_found_str = serp_website_lookup(company_name)
normalized_new_url = simple_normalize_url(new_url_found_str)
if new_url_found_str != "k.A." and normalized_new_url != "k.A.":
if normalized_new_url != normalized_old_crm_url:
self.logger.info(
f" -> Neue, andere URL gefunden: {new_url_found_str}. Alte war: '{old_crm_website_url}'. Bereite Update vor.")
found_new_url_count += 1
updates_for_row.append(
{'range': f'{d_letter}{i}', 'values': [[new_url_found_str]]})
updates_for_row.append(
{'range': f'{ar_letter}{i}', 'values': [['']]})
updates_for_row.append(
{'range': f'{a_letter}{i}', 'values': [['x']]})
updates_for_row.append(
{'range': f'{at_letter}{i}', 'values': [['']]})
updates_for_row.append(
{'range': f'{ao_letter}{i}', 'values': [['']]})
updates_for_row.append(
{'range': f'{an_letter}{i}', 'values': [['']]})
updates_for_row.append(
{'range': f'{ax_letter}{i}', 'values': [['']]})
# Wird unten explizit neu gesetzt
updates_for_row.append(
{'range': f'{ay_letter}{i}', 'values': [['']]})
updates_for_row.append(
{'range': f'{ap_letter}{i}', 'values': [['']]})
else:
self.logger.info(
f" -> SerpAPI fand URL '{new_url_found_str}', aber diese ist identisch mit der bereits vorhandenen URL in Spalte D. Keine Änderung in D.")
updates_for_row.append({'range': f'{ar_letter}{i}', 'values': [
["k.A. (URL via SerpAPI identisch mit alter URL)"]]})
else:
self.logger.warning(
f" -> Keine neue gueltige URL via SerpAPI für '{company_name[:50]}...' gefunden. Setze AR auf 'k.A. (Keine URL bei Neusuche)'.")
updates_for_row.append({'range': f'{ar_letter}{i}', 'values': [
["k.A. (Keine URL bei Neusuche)"]]})
except Exception as e_serp_lookup:
self.logger.error(
f"FEHLER bei SERP Website Lookup für Zeile {i} ('{company_name[:50]}...'): {e_serp_lookup}")
updates_for_row.append({'range': f'{ar_letter}{i}', 'values': [
[f"k.A. (Fehler URL Suche)"]]})
pass
updates_for_row.append({'range': f'{ay_letter}{i}', 'values': [
[now_timestamp_str]]}) # AY Timestamp immer setzen
all_sheet_updates.extend(updates_for_row)
if len(all_sheet_updates) >= update_batch_row_limit * \
3: # Angepasst, da Anzahl Updates variiert
self.logger.debug(
f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Operationen)...")
success = self.sheet_handler.batch_update_cells(
all_sheet_updates)
if success:
self.logger.info(
f" Sheet-Update für {len(all_sheet_updates)} Operationen erfolgreich.")
all_sheet_updates = []
serp_delay = getattr(Config, 'SERPAPI_DELAY', 1.5)
time.sleep(serp_delay)
if all_sheet_updates:
self.logger.info(
f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Operationen)...")
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
if success:
self.logger.info(f"FINALES Sheet-Update erfolgreich.")
self.logger.info(
f"Modus 'check_urls' abgeschlossen. {processed_count} Zeilen mit Marker/Fehler verarbeitet, {found_new_url_count} neue URLs gefunden, {skipped_count} Zeilen uebersprungen.")
def process_repair_sitz_data(
self,
start_sheet_row=None,
end_sheet_row=None,
limit=None):
"""
Wendet die verbesserte Sitz-Parsing-Logik auf bestehende Daten an.
"""
self.logger.info(
f"Starte Modus 'Sitz-Daten Reparatur'. Bereich: {start_sheet_row if start_sheet_row else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit else 'Unbegrenzt'}")
"""
Liest bestehende Sitz-Stadt/Land-Angaben, wendet die verbesserte Parsing-Logik
an und aktualisiert das Sheet, falls sich Änderungen ergeben.
"""
self.logger.info(
f"Starte Modus 'Sitz-Daten Reparatur'. Bereich: {start_sheet_row if start_sheet_row is not None else 'Komplett ab Datenstart'}, End: {end_sheet_row if end_sheet_row else 'Sheet-Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...")
if not self.sheet_handler.load_data():
self.logger.error(
"Konnte Sheet-Daten nicht laden für Sitz-Reparatur. Abbruch.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_offset = self.sheet_handler._header_rows
stadt_col_idx = COLUMN_MAP.get("Wiki Sitz Stadt")
land_col_idx = COLUMN_MAP.get("Wiki Sitz Land")
# Optional: Eine Spalte für den originalen Roh-Sitz-String, falls vorhanden
# roh_sitz_col_idx = COLUMN_MAP.get("IHRE_ROH_SITZ_SPALTE")
if stadt_col_idx is None or land_col_idx is None:
self.logger.error(
"Spaltenindizes für 'Wiki Sitz Stadt' oder 'Wiki Sitz Land' nicht in COLUMN_MAP. Abbruch.")
return
updates_fuer_sheet = []
processed_rows_count = 0
updated_rows_count = 0
effective_start_row = start_sheet_row if start_sheet_row is not None else header_offset + 1
effective_end_row = end_sheet_row if end_sheet_row is not None else len(
all_data)
self.logger.info(
f"Prüfe Zeilen {effective_start_row} bis {effective_end_row} für Sitz-Reparatur.")
for row_num_sheet in range(effective_start_row, effective_end_row + 1):
if limit is not None and processed_rows_count >= limit:
self.logger.info(
f"Limit von {limit} erreichten Zeilen für Sitz-Reparatur erreicht.")
break
row_list_idx = row_num_sheet - 1
if row_list_idx >= len(all_data):
break # Ende der Daten erreicht
row_data = all_data[row_list_idx]
aktuelle_stadt = self._get_cell_value_safe(
row_data, "Wiki Sitz Stadt")
aktuelle_land = self._get_cell_value_safe(
row_data, "Wiki Sitz Land")
# Erzeuge den Input-String für die Parsing-Funktion
# Besser: Wenn Sie den *ursprünglichen* String aus der Wikipedia Infobox
# in einer separaten Spalte gespeichert hätten, würden Sie diesen hier verwenden.
# Als Fallback kombinieren wir aktuelle Stadt und Land.
input_sitz_string = aktuelle_stadt
if aktuelle_land and aktuelle_land.lower() not in ["", "k.a."]:
if input_sitz_string and input_sitz_string.lower() not in [
"", "k.a."]:
# Kombiniere mit Komma
input_sitz_string += f", {aktuelle_land}"
else:
input_sitz_string = aktuelle_land # Wenn Stadt leer/kA, nimm nur Land
if not input_sitz_string or not input_sitz_string.strip(
) or input_sitz_string.lower() == 'k.a.':
# self.logger.debug(f"Zeile {row_num_sheet}: Keine validen aktuellen Sitzdaten ('{aktuelle_stadt}', '{aktuelle_land}') zum Reparieren.")
continue
processed_rows_count += 1
try:
# Verwende die neue Parsing-Methode des WikipediaScrapers
# Stellen Sie sicher, dass self.wiki_scraper eine Instanz von
# WikipediaScraper ist
parsed_sitz_info = self.wiki_scraper._parse_sitz_string_detailed(
input_sitz_string)
neue_stadt = parsed_sitz_info.get('sitz_stadt', 'k.A.')
neues_land = parsed_sitz_info.get('sitz_land', 'k.A.')
# Nur updaten, wenn sich etwas geändert hat
if (neue_stadt != aktuelle_stadt and not (neue_stadt == "k.A." and aktuelle_stadt == "")) or (
neues_land != aktuelle_land and not (neues_land == "k.A." and aktuelle_land == "")):
self.logger.info(
f"Zeile {row_num_sheet}: SITZ-UPDATE. Input: '{input_sitz_string[:60]}...' Alt: '{aktuelle_stadt} / {aktuelle_land}' -> Neu: '{neue_stadt} / {neues_land}'")
updates_fuer_sheet.append({
'range': f'{_get_col_letter(stadt_col_idx + 1)}{row_num_sheet}',
'values': [[neue_stadt]]
})
updates_fuer_sheet.append({
'range': f'{_get_col_letter(land_col_idx + 1)}{row_num_sheet}',
'values': [[neues_land]]
})
updated_rows_count += 1
# else:
# self.logger.debug(f"Zeile {row_num_sheet}: Keine Änderung bei Sitzdaten für Input '{input_sitz_string[:60]}...'. Alt: '{aktuelle_stadt} / {aktuelle_land}', Neu: '{neue_stadt} / {neues_land}'")
except Exception as e_parse:
self.logger.error(
f"Fehler beim Parsen des Sitzes für Zeile {row_num_sheet} mit Input '{input_sitz_string}': {e_parse}")
# Batch-Update Logik
if len(updates_fuer_sheet) >= getattr(
Config, 'UPDATE_BATCH_ROW_LIMIT', 50) * 2: # Mal 2, da zwei Spalten pro Zeile
self.logger.info(
f"Sende Batch-Update für {len(updates_fuer_sheet)//2} Sitzreparaturen...")
self.sheet_handler.batch_update_cells(updates_fuer_sheet)
updates_fuer_sheet = []
# time.sleep(1) # Optionale Pause
# Letzten Batch senden
if updates_fuer_sheet:
self.logger.info(
f"Sende finalen Batch-Update für {len(updates_fuer_sheet)//2} Sitzreparaturen...")
self.sheet_handler.batch_update_cells(updates_fuer_sheet)
self.logger.info(
f"Sitz-Daten Reparatur abgeschlossen. {processed_rows_count} Zeilen geprüft, {updated_rows_count} Zeilen aktualisiert.")
def _get_numeric_value_for_plausi(self, value_str, is_umsatz=False):
"""
Hilfsfunktion, um numerische Werte für Plausibilitätschecks zu extrahieren.
"""
# ... (Implementation remains similar to the original code) ...
# Diese Funktion ist relativ kurz und könnte hier stehen bleiben.
from helpers import extract_numeric_value
extracted_val_str = extract_numeric_value(value_str, is_umsatz)
if extracted_val_str.lower() in ['k.a.', '0']:
return np.nan
try:
num_val = float(extracted_val_str)
return num_val * 1000000.0 if is_umsatz else num_val
except (ValueError, TypeError):
return np.nan
def _check_financial_plausibility(self, row_data_dict):
"""
Führt die Plausibilitätschecks für eine Zeile durch.
"""
results = {
"plaus_umsatz_flag": "NICHT_PRUEFBAR",
"plaus_ma_flag": "NICHT_PRUEFBAR",
"plaus_ratio_flag": "NICHT_PRUEFBAR",
"abweichung_umsatz_flag": "N/A",
"abweichung_ma_flag": "N/A",
"plausi_begruendung_final": "Plausibilität OK"}
temp_begruendungen = []
parent_account_name_d_val = row_data_dict.get(
"Parent Account Name", "").strip()
is_konzern_tochter_laut_d = bool(
parent_account_name_d_val and parent_account_name_d_val.lower() != 'k.a.')
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# +++ NEU/ERWEITERT: Info aus Spalte O und P für Konzernlogik heranziehen +++
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# Diese Keys müssen im row_data_dict vorhanden sein, wenn diese
# Funktion aufgerufen wird!
parent_o_val = row_data_dict.get(
"System Vorschlag Parent Account",
"").strip().lower()
parent_p_val = row_data_dict.get(
"Parent Vorschlag Status", "").strip().lower()
is_konzern_tochter_laut_o_und_p = bool(
parent_o_val and parent_o_val != 'k.a.' and parent_p_val == 'x')
# (Das `wiki_stammt_von_parent_explizit` Flag ist optional, wenn Sie den anderen Ansatz verfolgen)
# wiki_stammt_von_parent_explizit = row_data_dict.get("Wiki Daten von Parent", False)
# or wiki_stammt_von_parent_explizit
is_part_of_a_group_for_plausi = is_konzern_tochter_laut_d or is_konzern_tochter_laut_o_und_p
log_msg_group_parts = []
if is_konzern_tochter_laut_d:
log_msg_group_parts.append(f"D='{parent_account_name_d_val}'")
if is_konzern_tochter_laut_o_und_p:
log_msg_group_parts.append(f"O/P='{parent_o_val}/{parent_p_val}'")
# if wiki_stammt_von_parent_explizit:
# log_msg_group_parts.append("WikiParentFlag=True")
if is_part_of_a_group_for_plausi:
self.logger.debug(
f" PlausiCheck: Unternehmen ist Teil einer Gruppe ({'; '.join(log_msg_group_parts)}). Abweichungs-Checks CRM/Wiki werden als INFO_KONZERN_LOGIK behandelt.")
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# +++ ENDE NEU/ERWEITERT ++++++++++++++++++++++++++++++++++++++++++++++
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# --- 1. Plausibilität Finaler Umsatz (BG) ---
final_umsatz_str = row_data_dict.get(
"Finaler Umsatz (Wiki>CRM)", "k.A.")
umsatz_num_absolut = self._get_numeric_value_for_plausi(
final_umsatz_str, is_umsatz=True)
exclusion_list_common = ['k.a.', '', 'n/a',
'-', '0', '0.0', '0,00', '0.000', '0.00']
if pd.isna(umsatz_num_absolut):
if final_umsatz_str.lower().strip(
) not in exclusion_list_common and not final_umsatz_str.startswith("FEHLER"):
results["plaus_umsatz_flag"] = "FEHLER_FORMAT"
temp_begruendungen.append(
f"Finaler Umsatz ('{final_umsatz_str}') konnte nicht als Zahl interpretiert werden.")
else:
results["plaus_umsatz_flag"] = "OK"
if umsatz_num_absolut == 0 and final_umsatz_str != "0":
results["plaus_umsatz_flag"] = "WARNUNG_NULL_WERT"
temp_begruendungen.append(
f"Finaler Umsatz ist numerisch 0 (aus '{final_umsatz_str}').")
elif umsatz_num_absolut < getattr(Config, 'PLAUSI_UMSATZ_MIN_WARNUNG', 50000):
results["plaus_umsatz_flag"] = "WARNUNG_NIEDRIG"
temp_begruendungen.append(
f"Finaler Umsatz ({umsatz_num_absolut:,.0f} €) < Min-Schwelle ({getattr(Config, 'PLAUSI_UMSATZ_MIN_WARNUNG', 50000):,.0f} €).")
elif umsatz_num_absolut > getattr(Config, 'PLAUSI_UMSATZ_MAX_WARNUNG', 200000000000):
results["plaus_umsatz_flag"] = "WARNUNG_HOCH"
temp_begruendungen.append(
f"Finaler Umsatz ({umsatz_num_absolut:,.0f} €) > Max-Schwelle ({getattr(Config, 'PLAUSI_UMSATZ_MAX_WARNUNG', 200000000000):,.0f} €).")
# --- 2. Plausibilität Finale Mitarbeiter (BH) ---
final_ma_str = row_data_dict.get(
"Finaler Mitarbeiter (Wiki>CRM)", "k.A.")
ma_num_absolut = self._get_numeric_value_for_plausi(
final_ma_str, is_umsatz=False)
if pd.isna(ma_num_absolut):
if final_ma_str.lower().strip(
) not in exclusion_list_common and not final_ma_str.startswith("FEHLER"):
results["plaus_ma_flag"] = "FEHLER_FORMAT"
temp_begruendungen.append(
f"Finale MA ('{final_ma_str}') konnte nicht als Zahl interpretiert werden.")
else:
results["plaus_ma_flag"] = "OK"
if ma_num_absolut == 0 and final_ma_str != "0":
results["plaus_ma_flag"] = "WARNUNG_NULL_WERT"
temp_begruendungen.append(
f"Finale MA ist numerisch 0 (aus '{final_ma_str}').")
elif ma_num_absolut < getattr(Config, 'PLAUSI_MA_MIN_WARNUNG_ABS', 1):
results["plaus_ma_flag"] = "WARNUNG_NIEDRIG"
temp_begruendungen.append(
f"Finale MA ({ma_num_absolut:.0f}) < Min-Schwelle ({getattr(Config, 'PLAUSI_MA_MIN_WARNUNG_ABS', 1):.0f}).")
elif not pd.isna(umsatz_num_absolut) and umsatz_num_absolut >= getattr(Config, 'PLAUSI_UMSATZ_MIN_SCHWELLE_FUER_MA_CHECK', 1000000) and \
ma_num_absolut < getattr(Config, 'PLAUSI_MA_MIN_WARNUNG_BEI_UMSATZ', 3):
results["plaus_ma_flag"] = "WARNUNG_ZU_WENIG_MA_BEI_UMSATZ"
temp_begruendungen.append(
f"Finale MA ({ma_num_absolut:.0f}) auffällig niedrig für Umsatz ({umsatz_num_absolut:,.0f} €).")
elif ma_num_absolut > getattr(Config, 'PLAUSI_MA_MAX_WARNUNG', 1000000):
results["plaus_ma_flag"] = "WARNUNG_HOCH"
temp_begruendungen.append(
f"Finale MA ({ma_num_absolut:.0f}) > Max-Schwelle ({getattr(Config, 'PLAUSI_MA_MAX_WARNUNG', 1000000):,.0f}).")
# --- 3. Plausibilität Umsatz/MA Ratio (BI) ---
if not pd.isna(umsatz_num_absolut) and not pd.isna(
ma_num_absolut) and umsatz_num_absolut > 0:
if ma_num_absolut > 0:
ratio = umsatz_num_absolut / ma_num_absolut
results["plaus_ratio_flag"] = "OK"
if ratio < getattr(
Config,
'PLAUSI_RATIO_UMSATZ_PRO_MA_MIN',
25000):
results["plaus_ratio_flag"] = "WARNUNG_RATIO_NIEDRIG"
temp_begruendungen.append(
f"Umsatz/MA Ratio ({ratio:,.0f} €/MA) < Min-Schwelle ({getattr(Config, 'PLAUSI_RATIO_UMSATZ_PRO_MA_MIN', 25000):,.0f} €/MA).")
elif ratio > getattr(Config, 'PLAUSI_RATIO_UMSATZ_PRO_MA_MAX', 1500000):
results["plaus_ratio_flag"] = "WARNUNG_RATIO_HOCH"
temp_begruendungen.append(
f"Umsatz/MA Ratio ({ratio:,.0f} €/MA) > Max-Schwelle ({getattr(Config, 'PLAUSI_RATIO_UMSATZ_PRO_MA_MAX', 1500000):,.0f} €/MA).")
elif ma_num_absolut == 0:
results["plaus_ratio_flag"] = "FEHLER_MA_NULL_BEI_UMSATZ"
temp_begruendungen.append(
"Umsatz vorhanden, aber MA ist 0. Ratio nicht berechenbar.")
elif pd.isna(umsatz_num_absolut) or umsatz_num_absolut <= 0:
results["plaus_ratio_flag"] = "NICHT_PRUEFBAR_UMSATZ_FEHLT"
# --- 4. Abgleich CRM vs. Wiki (BJ, BK) ---
crm_umsatz_str = row_data_dict.get("CRM Umsatz", "k.A.")
wiki_umsatz_str = row_data_dict.get("Wiki Umsatz", "k.A.")
crm_ma_str = row_data_dict.get("CRM Anzahl Mitarbeiter", "k.A.")
wiki_ma_str = row_data_dict.get("Wiki Mitarbeiter", "k.A.")
crm_u_abs = self._get_numeric_value_for_plausi(
crm_umsatz_str, is_umsatz=True)
wiki_u_abs = self._get_numeric_value_for_plausi(
wiki_umsatz_str, is_umsatz=True)
crm_m_abs_comp = self._get_numeric_value_for_plausi(
crm_ma_str, is_umsatz=False)
wiki_m_abs_comp = self._get_numeric_value_for_plausi(
wiki_ma_str, is_umsatz=False)
abweichung_prozent_config = getattr(
Config, 'PLAUSI_ABWEICHUNG_CRM_WIKI_PROZENT', 30) / 100.0
# Umsatz Abweichung (BJ)
if pd.notna(crm_u_abs) and pd.notna(
wiki_u_abs) and crm_u_abs > 0 and wiki_u_abs > 0:
if is_part_of_a_group_for_plausi: # ERWEITERTE BEDINGUNG HIER VERWENDEN
results["abweichung_umsatz_flag"] = "INFO_KONZERN_LOGIK"
else:
diff_umsatz = abs(crm_u_abs - wiki_u_abs)
bezugswert_umsatz = max(
crm_u_abs, wiki_u_abs) if max(
crm_u_abs, wiki_u_abs) > 0 else 1
if (diff_umsatz / bezugswert_umsatz) > abweichung_prozent_config:
results["abweichung_umsatz_flag"] = "WARNUNG_SIGNIFIKANT"
temp_begruendungen.append(
f"Umsatz CRM ({crm_u_abs:,.0f} €) vs. Wiki ({wiki_u_abs:,.0f} €) weicht >{abweichung_prozent_config*100:.0f}% ab.")
else:
results["abweichung_umsatz_flag"] = "OK"
elif pd.notna(crm_u_abs) and crm_u_abs > 0 and (pd.isna(wiki_u_abs) or wiki_u_abs <= 0):
results["abweichung_umsatz_flag"] = "WIKI_FEHLT_ODER_NULL"
elif (pd.isna(crm_u_abs) or crm_u_abs <= 0) and pd.notna(wiki_u_abs) and wiki_u_abs > 0:
results["abweichung_umsatz_flag"] = "CRM_FEHLT_ODER_NULL"
else:
results["abweichung_umsatz_flag"] = "BEIDE_FEHLEN_ODER_NULL"
# Mitarbeiter Abweichung (BK)
if pd.notna(crm_m_abs_comp) and pd.notna(
wiki_m_abs_comp) and crm_m_abs_comp > 0 and wiki_m_abs_comp > 0:
if is_part_of_a_group_for_plausi: # ERWEITERTE BEDINGUNG HIER VERWENDEN
results["abweichung_ma_flag"] = "INFO_KONZERN_LOGIK"
else:
diff_ma = abs(crm_m_abs_comp - wiki_m_abs_comp)
bezugswert_ma = max(
crm_m_abs_comp,
wiki_m_abs_comp) if max(
crm_m_abs_comp,
wiki_m_abs_comp) > 0 else 1
if (diff_ma / bezugswert_ma) > abweichung_prozent_config:
results["abweichung_ma_flag"] = "WARNUNG_SIGNIFIKANT"
temp_begruendungen.append(
f"MA CRM ({crm_m_abs_comp:.0f}) vs. Wiki ({wiki_m_abs_comp:.0f}) weicht >{abweichung_prozent_config*100:.0f}% ab.")
else:
results["abweichung_ma_flag"] = "OK"
elif pd.notna(crm_m_abs_comp) and crm_m_abs_comp > 0 and (pd.isna(wiki_m_abs_comp) or wiki_m_abs_comp <= 0):
results["abweichung_ma_flag"] = "WIKI_FEHLT_ODER_NULL"
elif (pd.isna(crm_m_abs_comp) or crm_m_abs_comp <= 0) and pd.notna(wiki_m_abs_comp) and wiki_m_abs_comp > 0:
results["abweichung_ma_flag"] = "CRM_FEHLT_ODER_NULL"
else:
results["abweichung_ma_flag"] = "BEIDE_FEHLEN_ODER_NULL"
if temp_begruendungen:
results["plausi_begruendung_final"] = "; ".join(temp_begruendungen)
return results
def run_plausibility_checks_batch(
self,
start_sheet_row=None,
end_sheet_row=None,
limit=None):
"""
Führt Konsolidierung und Plausi-Checks für einen Bereich von Zeilen aus.
"""
self.logger.info(
f"Starte Modus 'plausi_check_data'. Bereich: {start_sheet_row if start_sheet_row else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit else 'Unbegrenzt'}")
self.logger.info(
f"Starte Modus 'plausi_check_data' (Konsolidierung & Plausi-Checks). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}")
if not self.sheet_handler.load_data():
self.logger.error(
"Konnte Sheet-Daten nicht laden für Plausi-Checks. Abbruch.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_offset = self.sheet_handler._header_rows
total_sheet_rows = len(all_data)
effective_start_row = start_sheet_row if start_sheet_row is not None else header_offset + 1
effective_end_row = end_sheet_row if end_sheet_row is not None else total_sheet_rows
if effective_start_row > effective_end_row or effective_start_row > total_sheet_rows:
self.logger.info(
"Start liegt nach Ende oder außerhalb des Sheets. Keine Zeilen zu verarbeiten.")
return
self.logger.info(
f"Verarbeite Zeilen {effective_start_row} bis {effective_end_row} für Konsolidierung und Plausi-Checks.")
required_keys_for_plausi_mode = [ # Schlüssel wie gehabt
"CRM Umsatz", "Wiki Umsatz", "CRM Anzahl Mitarbeiter", "Wiki Mitarbeiter", "Parent Account Name",
"Finaler Umsatz (Wiki>CRM)", "Finaler Mitarbeiter (Wiki>CRM)",
"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", "CRM Name" # CRM Name für Logging hinzugefügt
]
if not all(
key in COLUMN_MAP for key in required_keys_for_plausi_mode):
missing_k = [
k for k in required_keys_for_plausi_mode if k not in COLUMN_MAP]
self.logger.error(
f"Nicht alle benötigten Spalten ({missing_k}) für Modus 'plausi_check_data' in COLUMN_MAP. Abbruch.")
return
all_sheet_updates = []
processed_rows_count = 0
now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
update_batch_limit_config = getattr(
Config, 'UPDATE_BATCH_ROW_LIMIT', 50)
for row_num_sheet in range(
effective_start_row,
effective_end_row + 1):
if limit is not None and processed_rows_count >= limit:
self.logger.info(
f"Verarbeitungslimit von {limit} Zeilen erreicht.")
break
row_list_idx = row_num_sheet - 1
if row_list_idx >= total_sheet_rows:
break
row_data = all_data[row_list_idx]
crm_name_check = self._get_cell_value_safe(
row_data, "CRM Name").strip()
if not crm_name_check:
continue
self.logger.debug(
f"Zeile {row_num_sheet} ({crm_name_check[:30]}...): Starte Konsolidierung und Plausi-Check.")
current_row_updates = []
# 1. Konsolidierung (BD, BE)
final_umsatz_str_konsolidiert = "k.A."
final_ma_str_konsolidiert = "k.A."
parent_account_name_d_val = self._get_cell_value_safe(
row_data, "Parent Account Name").strip()
parent_o_val_plausi = self._get_cell_value_safe(
row_data, "System Vorschlag Parent Account").strip() # Für Plausi-Check holen
parent_p_val_plausi = self._get_cell_value_safe(
row_data, "Parent Vorschlag Status").strip() # Für Plausi-Check holen
try:
crm_umsatz_val_str = self._get_cell_value_safe(
row_data, "CRM Umsatz")
# Wiki-Werte direkt aus der Zeile (row_data) lesen
wiki_umsatz_val_str_sheet = self._get_cell_value_safe(
row_data, "Wiki Umsatz")
crm_ma_val_str = self._get_cell_value_safe(
row_data, "CRM Anzahl Mitarbeiter")
wiki_ma_val_str_sheet = self._get_cell_value_safe(
row_data, "Wiki Mitarbeiter")
num_crm_umsatz = get_numeric_filter_value(
crm_umsatz_val_str, is_umsatz=True)
num_wiki_umsatz = get_numeric_filter_value(
wiki_umsatz_val_str_sheet, is_umsatz=True) # Verwende _sheet Wert
num_crm_ma = get_numeric_filter_value(
crm_ma_val_str, is_umsatz=False)
num_wiki_ma = get_numeric_filter_value(
wiki_ma_val_str_sheet, is_umsatz=False) # Verwende _sheet Wert
if parent_account_name_d_val and parent_account_name_d_val.lower() != 'k.a.':
self.logger.debug(
f" -> Parent D ('{parent_account_name_d_val}') ist gesetzt. Konsolidiere primär mit CRM-Daten der Tochter.")
final_num_umsatz = num_crm_umsatz if num_crm_umsatz > 0 else num_wiki_umsatz
final_num_ma = num_crm_ma if num_crm_ma > 0 else num_wiki_ma
else:
final_num_umsatz = num_wiki_umsatz if num_wiki_umsatz > 0 else num_crm_umsatz
final_num_ma = num_wiki_ma if num_wiki_ma > 0 else num_crm_ma
final_umsatz_str_konsolidiert = str(
int(round(final_num_umsatz))) if final_num_umsatz > 0 else 'k.A.'
final_ma_str_konsolidiert = str(
int(round(final_num_ma))) if final_num_ma > 0 else 'k.A.'
except Exception as e_conso_batch:
self.logger.error(
f"Fehler bei Konsolidierung in Plausi-Batch für Zeile {row_num_sheet}: {e_conso_batch}")
final_umsatz_str_konsolidiert = "FEHLER_KONSO_PLAUSI"
final_ma_str_konsolidiert = "FEHLER_KONSO_PLAUSI"
current_row_updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Finaler Umsatz (Wiki>CRM)") + 1)}{row_num_sheet}',
'values': [
[final_umsatz_str_konsolidiert]]})
current_row_updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Finaler Mitarbeiter (Wiki>CRM)") + 1)}{row_num_sheet}',
'values': [
[final_ma_str_konsolidiert]]})
# 2. Plausibilitäts-Checks (BG-BM)
if not final_umsatz_str_konsolidiert.startswith(
"FEHLER") and not final_ma_str_konsolidiert.startswith("FEHLER"):
try:
plausi_input_data = {
"Finaler Umsatz (Wiki>CRM)": final_umsatz_str_konsolidiert,
"Finaler Mitarbeiter (Wiki>CRM)": final_ma_str_konsolidiert,
"CRM Umsatz": self._get_cell_value_safe(row_data, "CRM Umsatz"),
"Wiki Umsatz": self._get_cell_value_safe(row_data, "Wiki Umsatz"),
"CRM Anzahl Mitarbeiter": self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter"),
"Wiki Mitarbeiter": self._get_cell_value_safe(row_data, "Wiki Mitarbeiter"),
"Parent Account Name": parent_account_name_d_val,
"System Vorschlag Parent Account": parent_o_val_plausi, # Variable von oben
"Parent Vorschlag Status": parent_p_val_plausi # Variable von oben
}
plausi_results = self._check_financial_plausibility(
plausi_input_data)
current_row_updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Plausibilität Umsatz") + 1)}{row_num_sheet}',
'values': [
[
plausi_results.get(
"plaus_umsatz_flag",
"ERR_FLAG")]]})
current_row_updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Plausibilität Mitarbeiter") + 1)}{row_num_sheet}',
'values': [
[
plausi_results.get(
"plaus_ma_flag",
"ERR_FLAG")]]})
current_row_updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Plausibilität Umsatz/MA Ratio") + 1)}{row_num_sheet}',
'values': [
[
plausi_results.get(
"plaus_ratio_flag",
"ERR_FLAG")]]})
current_row_updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Abweichung Umsatz CRM/Wiki") + 1)}{row_num_sheet}',
'values': [
[
plausi_results.get(
"abweichung_umsatz_flag",
"ERR_FLAG")]]})
current_row_updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Abweichung MA CRM/Wiki") + 1)}{row_num_sheet}',
'values': [
[
plausi_results.get(
"abweichung_ma_flag",
"ERR_FLAG")]]})
current_row_updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Plausibilität Begründung") + 1)}{row_num_sheet}',
'values': [
[
plausi_results.get(
"plausi_begruendung_final",
"Fehler Begr.")]]})
except Exception as e_plausi_run_batch:
self.logger.error(
f"Fehler im Plausi-Check Aufruf (Batch-Modus) für Zeile {row_num_sheet}: {e_plausi_run_batch}")
for key_flag in [
"Plausibilität Umsatz",
"Plausibilität Mitarbeiter",
"Plausibilität Umsatz/MA Ratio",
"Abweichung Umsatz CRM/Wiki",
"Abweichung MA CRM/Wiki"]:
current_row_updates.append(
{
'range': f'{_get_col_letter(COLUMN_MAP[key_flag] + 1)}{row_num_sheet}',
'values': [
['FEHLER_CALL']]})
current_row_updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Plausibilität Begründung") + 1)}{row_num_sheet}',
'values': [
[f"Systemfehler: {str(e_plausi_run_batch)[:100]}"]]})
else: # Fehler bei Konsolidierung
self.logger.warning(
f"Zeile {row_num_sheet}: Überspringe Plausi-Checks wegen Fehler bei Konsolidierung.")
for key_flag in [
"Plausibilität Umsatz",
"Plausibilität Mitarbeiter",
"Plausibilität Umsatz/MA Ratio",
"Abweichung Umsatz CRM/Wiki",
"Abweichung MA CRM/Wiki"]:
current_row_updates.append(
{
'range': f'{_get_col_letter(COLUMN_MAP[key_flag] + 1)}{row_num_sheet}',
'values': [
['INPUT_FEHLER_KONSO']]})
current_row_updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Plausibilität Begründung") + 1)}{row_num_sheet}',
'values': [
["Konsolidierung fehlgeschlagen"]]})
current_row_updates.append(
{
'range': f'{_get_col_letter(get_col_idx("Plausibilität Prüfdatum") + 1)}{row_num_sheet}',
'values': [
[now_timestamp_str]]})
all_sheet_updates.extend(current_row_updates)
processed_rows_count += 1
# Batch-Update auslösen, wenn Limit erreicht
if processed_rows_count % update_batch_limit_config == 0 and processed_rows_count > 0:
if all_sheet_updates:
self.logger.info(
f"Plausi-Batch: Sende {len(all_sheet_updates)} Operationen für {update_batch_limit_config} Zeilen...")
self.sheet_handler.batch_update_cells(
all_sheet_updates)
all_sheet_updates = [] # Liste leeren nach dem Senden
time.sleep(0.5) # Kurze Pause nach Batch-Update
# Ende der for-Schleife über die Zeilen
# Sende verbleibende Updates
if all_sheet_updates:
self.logger.info(
f"Plausi-Batch: Sende verbleibende {len(all_sheet_updates)} Operationen...")
self.sheet_handler.batch_update_cells(all_sheet_updates)
self.logger.info(
f"Modus 'plausi_check_data' abgeschlossen. {processed_rows_count} Zeilen verarbeitet.")
def _suggest_parent_account_openai_task(self, task_data, openai_semaphore):
"""
Fragt ChatGPT nach einem Parent Account für ein einzelnes Unternehmen.
"""
"""
Fragt ChatGPT nach einem Parent Account für ein einzelnes Unternehmen.
Läuft in einem separaten Thread für den Parent-Suggestion-Batch.
Args:
task_data (dict): Enthält die Daten für die Zeile
(row_num, crm_name, crm_website, crm_beschreibung,
wiki_url, wiki_absatz, wiki_kategorien, website_zusammenfassung).
openai_semaphore (threading.Semaphore): Semaphore zur Begrenzung gleichzeitiger OpenAI-Calls.
Returns:
dict: {'row_num': int, 'suggested_parent': str, 'justification': str, 'error': str or None}
"""
logger = logging.getLogger(__name__ + ".suggest_parent_task")
row_num = task_data['row_num']
suggested_parent = "k.A."
justification = "Keine Begründung erhalten."
error_msg = None
# Bereinige Input-Daten für den Prompt
crm_name = str(task_data.get('crm_name', 'N/A')).strip()
crm_website = str(task_data.get('crm_website', 'N/A')).strip()
crm_beschreibung = str(task_data.get(
'crm_beschreibung', 'N/A')).strip()[:800] # Gekürzt
wiki_url = str(task_data.get('wiki_url', 'N/A')).strip()
wiki_absatz = str(task_data.get('wiki_absatz', 'N/A')
).strip()[:800] # Gekürzt
wiki_kategorien = str(task_data.get(
'wiki_kategorien', 'N/A')).strip()[:500] # Gekürzt
website_zusammenfassung = str(task_data.get(
'website_zusammenfassung', 'N/A')).strip()[:800] # Gekürzt
prompt_parts = [
"Du bist ein Wirtschaftsanalyst und recherchierst Unternehmensstrukturen.",
"Basierend auf den folgenden Informationen, identifiziere bitte den Namen der direkten Muttergesellschaft oder des übergeordneten Konzerns für das genannte Unternehmen.",
"Wenn keine klare Muttergesellschaft ersichtlich ist oder das Unternehmen selbständig zu sein scheint, antworte mit 'k.A.' für den Parent Account.",
"Gib deine Antwort ausschließlich im folgenden Format aus (keine Einleitung, kein Schlusssatz):",
"Vorgeschlagener Parent Account: <Name des Parent Accounts oder k.A.>",
"Begründung: <Sehr kurze Begründung für deinen Vorschlag oder warum keiner gemacht werden kann. Erwähne die Informationsquelle, falls möglich (z.B. Wikipedia Infobox, Website).>",
"\n--- Unternehmensinformationen ---",
f"Unternehmen: {crm_name}",
]
if crm_website and crm_website.lower() != "n/a":
prompt_parts.append(f"Website: {crm_website}")
if crm_beschreibung and crm_beschreibung.lower() != "n/a":
prompt_parts.append(f"CRM Beschreibung: {crm_beschreibung}")
if wiki_url and "wikipedia.org" in wiki_url.lower():
prompt_parts.append(f"Wikipedia URL: {wiki_url}")
if wiki_absatz and wiki_absatz.lower() != "n/a":
prompt_parts.append(f"Wikipedia Absatz: {wiki_absatz}")
if wiki_kategorien and wiki_kategorien.lower() != "n/a":
prompt_parts.append(f"Wikipedia Kategorien: {wiki_kategorien}")
if website_zusammenfassung and website_zusammenfassung.lower(
) != "n/a" and not website_zusammenfassung.startswith("k.A. (Fehler"):
prompt_parts.append(
f"Website Zusammenfassung: {website_zusammenfassung}")
prompt_parts.append(
"\nBitte gib NUR die Antwort im oben genannten Format.")
prompt = "\n".join(prompt_parts)
# Token Count (optional, zur Info)
# try:
# pt_count = token_count(prompt)
# logger.debug(f"Zeile {row_num}: Prompt für Parent Suggestion ({pt_count} Tokens): {prompt[:200]}...")
# except Exception: pass
try:
with openai_semaphore:
# call_openai_chat ist mit @retry_on_failure dekoriert
raw_chat_response = call_openai_chat(
prompt, temperature=0.1, model=getattr(
Config, 'TOKEN_MODEL', 'gpt-3.5-turbo'))
if raw_chat_response:
parsed_parent = "k.A."
parsed_justification = "Keine Begründung extrahiert."
parent_match = re.search(
r"Vorgeschlagener Parent Account:\s*(.*)",
raw_chat_response,
re.IGNORECASE)
if parent_match:
parsed_parent = parent_match.group(1).strip()
if not parsed_parent or parsed_parent.lower(
) == "k.a.": # Sicherstellen, dass "k.A." korrekt übernommen wird
parsed_parent = "k.A."
justification_match = re.search(
r"Begründung:\s*(.*)", raw_chat_response, re.IGNORECASE)
if justification_match:
parsed_justification = justification_match.group(1).strip()
suggested_parent = parsed_parent
justification = parsed_justification
logger.debug(
f"Zeile {row_num}: ChatGPT Parent Vorschlag='{suggested_parent}', Begründung='{justification[:100]}...'")
else:
error_msg = "Leere Antwort von OpenAI erhalten."
logger.warning(f"Zeile {row_num}: {error_msg}")
justification = error_msg
except Exception as e:
error_msg = f"Fehler bei OpenAI Call für Parent Suggestion (Zeile {row_num}): {type(e).__name__} - {str(e)[:100]}"
logger.error(error_msg)
justification = error_msg
return {
"row_num": row_num,
"suggested_parent": suggested_parent,
"justification": justification,
"error": error_msg}
def process_parent_suggestion_batch(
self,
start_sheet_row=None,
end_sheet_row=None,
limit=None,
re_evaluate_question_mark=False):
"""
Batch-Prozess zur Generierung von Parent-Account-Vorschlägen.
"""
self.logger.info(
f"Starte Parent Account Suggestion Batch. Bereich: {start_sheet_row if start_sheet_row else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit else 'Unbegrenzt'}")
"""
Batch-Prozess zur Generierung von Parent-Account-Vorschlägen mittels ChatGPT.
Schreibt Ergebnisse in Spalten O, P, Q.
Bearbeitet nur Zeilen, bei denen Spalte Q (Parent Vorschlag Timestamp) leer ist,
es sei denn re_evaluate_question_mark ist True und Spalte P ist '?'.
Args:
start_sheet_row (int, optional): Die 1-basierte Startzeile.
end_sheet_row (int, optional): Die 1-basierte Endzeile.
limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen.
re_evaluate_question_mark (bool, optional): Wenn True, werden auch Zeilen mit '?'
in Spalte P (Parent Vorschlag Status) erneut bewertet,
AUCH WENN Spalte Q bereits einen Timestamp hat.
"""
self.logger.info(
f"Starte Parent Account Suggestion Batch. Bereich: {start_sheet_row if start_sheet_row else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit else 'Unbegrenzt'}, Re-Eval ?: {re_evaluate_question_mark}")
# --- Daten laden und Startzeile ermitteln ---
col_o_key = "System Vorschlag Parent Account"
col_p_key = "Parent Vorschlag Status"
col_q_key = "Parent Vorschlag Timestamp" # Timestamp-Spalte für die Auswahl
if start_sheet_row is None:
# Geändert: Start basierend auf leerem Q
self.logger.info(
f"Automatische Ermittlung der Startzeile basierend auf leerem '{col_q_key}'...")
start_data_index_no_header = self.sheet_handler.get_start_row_index(
check_column_key=col_q_key, min_sheet_row=7)
if start_data_index_no_header == -1:
self.logger.error(
"FEHLER bei autom. Startzeilenermittlung. Breche ab.")
return
start_sheet_row = start_data_index_no_header + \
self.sheet_handler._header_rows + 1
self.logger.info(
f"Automatisch ermittelte Startzeile: {start_sheet_row}")
else:
if not self.sheet_handler.load_data():
self.logger.error(
"FEHLER beim Laden der Daten für Parent Suggestion Batch.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = self.sheet_handler._header_rows
total_sheet_rows = len(all_data)
if end_sheet_row is None:
end_sheet_row = total_sheet_rows
self.logger.info(
f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}.")
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
self.logger.info(
"Start liegt nach Ende oder außerhalb des Sheets. Keine Verarbeitung.")
return
# --- Indizes ---
required_keys = [
col_o_key,
col_p_key,
col_q_key,
"CRM Name",
"CRM Website",
"CRM Beschreibung",
"Wiki URL",
"Wiki Absatz",
"Wiki Kategorien",
"Website Zusammenfassung",
"Version"]
if not all(key in COLUMN_MAP for key in required_keys):
missing = [k for k in required_keys if k not in COLUMN_MAP]
self.logger.critical(
f"FEHLER: Spaltenschlüssel für Parent Suggestion Batch fehlen: {missing}. Abbruch.")
return
col_o_letter = _get_col_letter(
COLUMN_MAP[col_o_key] + 1)
col_p_letter = _get_col_letter(
COLUMN_MAP[col_p_key] + 1)
col_q_letter = _get_col_letter(
COLUMN_MAP[col_q_key] + 1)
openai_sem = threading.Semaphore(
getattr(Config, 'OPENAI_CONCURRENCY_LIMIT', 3))
max_workers = getattr(Config, 'MAX_BRANCH_WORKERS', 10)
processing_batch_size = getattr(
Config, 'PROCESSING_BRANCH_BATCH_SIZE', 10)
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)
tasks_for_current_openai_batch = []
all_sheet_updates = []
processed_count = 0
skipped_count = 0
# Funktion zum Verarbeiten und Schreiben eines Batches (bleibt intern
# gleich)
def _execute_and_write_openai_batch(current_tasks):
# ... (Code der inneren Funktion bleibt identisch wie im vorherigen Vorschlag) ...
nonlocal processed_count
if not current_tasks:
return
batch_start_log_row = current_tasks[0]['row_num']
batch_end_log_row = current_tasks[-1]['row_num']
self.logger.debug(
f"\n--- Starte Parent Suggestion OpenAI Batch ({len(current_tasks)} Tasks, Zeilen {batch_start_log_row}-{batch_end_log_row}) ---")
batch_results_list = []
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_task_map = {
executor.submit(
self._suggest_parent_account_openai_task,
task,
openai_sem): task for task in current_tasks}
for future in concurrent.futures.as_completed(
future_to_task_map):
task_info_orig = future_to_task_map[future]
try:
result_data = future.result()
batch_results_list.append(result_data)
except Exception as e_future:
self.logger.error(
f"Exception im Future für Parent Suggestion Zeile {task_info_orig['row_num']}: {e_future}")
batch_results_list.append({
"row_num": task_info_orig['row_num'],
"suggested_parent": "FEHLER_TASK",
"justification": str(e_future)[:150],
"error": str(e_future)
})
self.logger.debug(
f" OpenAI Batch ({batch_start_log_row}-{batch_end_log_row}) abgeschlossen. {len(batch_results_list)} Ergebnisse erhalten.")
if batch_results_list:
now_ts_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
updates_for_this_batch = []
for res_item in batch_results_list:
rn = res_item['row_num']
parent_val = res_item.get(
'suggested_parent', 'k.A. (Fehler)')
updates_for_this_batch.append(
{'range': f'{col_o_letter}{rn}', 'values': [[parent_val]]})
status_val = "?" if parent_val and parent_val.lower(
) != "k.a." and not parent_val.startswith("FEHLER") else ""
updates_for_this_batch.append(
{'range': f'{col_p_letter}{rn}', 'values': [[status_val]]})
updates_for_this_batch.append(
{'range': f'{col_q_letter}{rn}', 'values': [[now_ts_str]]})
if res_item.get('justification'):
self.logger.debug(
f"Zeile {rn} - Parent Begründung: {res_item.get('justification')[:200]}...")
all_sheet_updates.extend(updates_for_this_batch)
# Zähle hier, wenn Tasks tatsächlich an OpenAI gingen
processed_count += len(current_tasks)
if len(all_sheet_updates) >= update_batch_row_limit * 3:
self.logger.info(
f"Sende Batch-Updates für Parent Suggestions ({len(all_sheet_updates)//3} Zeilen)...")
self.sheet_handler.batch_update_cells(all_sheet_updates)
all_sheet_updates.clear()
time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.5)
# Ende der Hilfsfunktion _execute_and_write_openai_batch
# Hauptschleife über die Zeilen
for i in range(start_sheet_row, end_sheet_row + 1):
# Limit-Prüfung erfolgt jetzt innerhalb der _execute_and_write_openai_batch
# oder besser hier vor dem Sammeln von Tasks, um nicht unnötig zu
# iterieren.
if limit is not None and processed_count >= limit:
self.logger.info(
f"Verarbeitungslimit ({limit}) für Parent Suggestions erreicht.")
break
row_idx_list = i - 1
if row_idx_list >= total_sheet_rows:
break
row = all_data[row_idx_list]
if not any(cell and str(cell).strip() for cell in row):
skipped_count += 1
continue
# Kriterien für Verarbeitung
val_q_timestamp = self._get_cell_value_safe(
row, col_q_key).strip() # Timestamp aus Spalte Q
val_p_status = self._get_cell_value_safe(
row, col_p_key).strip() # Status aus Spalte P
needs_processing = False
if not val_q_timestamp: # Spalte Q (Timestamp) ist leer
needs_processing = True
# Neubewertung für Status "?" auch wenn Timestamp Q gesetzt ist
elif re_evaluate_question_mark and val_p_status == "?":
needs_processing = True
self.logger.debug(
f"Zeile {i}: Wird trotz vorhandenem Timestamp in Q ('{val_q_timestamp}') verarbeitet, da P='?' und re_evaluate_question_mark=True.")
if not needs_processing:
skipped_count += 1
continue
# Daten für Task sammeln
task_data = {
"row_num": i,
"crm_name": self._get_cell_value_safe(row, "CRM Name"),
"crm_website": self._get_cell_value_safe(row, "CRM Website"),
"crm_beschreibung": self._get_cell_value_safe(row, "CRM Beschreibung"),
"wiki_url": self._get_cell_value_safe(row, "Wiki URL"),
"wiki_absatz": self._get_cell_value_safe(row, "Wiki Absatz"),
"wiki_kategorien": self._get_cell_value_safe(row, "Wiki Kategorien"),
"website_zusammenfassung": self._get_cell_value_safe(row, "Website Zusammenfassung")
}
tasks_for_current_openai_batch.append(task_data)
if len(tasks_for_current_openai_batch) >= processing_batch_size:
_execute_and_write_openai_batch(tasks_for_current_openai_batch)
tasks_for_current_openai_batch.clear()
if tasks_for_current_openai_batch:
_execute_and_write_openai_batch(tasks_for_current_openai_batch)
if all_sheet_updates:
self.logger.info(
f"Sende finale Batch-Updates für Parent Suggestions ({len(all_sheet_updates)//3} Zeilen)...")
self.sheet_handler.batch_update_cells(all_sheet_updates)
self.logger.info(
f"Parent Account Suggestion Batch abgeschlossen. {processed_count} Zeilen verarbeitet, {skipped_count} Zeilen übersprungen.")
def _predict_technician_bucket(self, row_data):
"""
Führt eine Vorhersage des Servicetechniker-Buckets für eine einzelne Zeile durch.
Die Feature-Erstellung ist exakt auf den Trainingsprozess abgestimmt.
"""
company_name = self._get_cell_value_safe(row_data, 'CRM Name').strip()
self.logger.debug(f"Versuche ML-Schaetzung fuer Zeile ({company_name[:50]}...)")
if not self.is_setup_complete or self.model is None or self.imputer is None or self._expected_features is None:
self.logger.error("ML-Artefakte (Modell/Imputer/Features) nicht initialisiert. Überspringe Vorhersage.")
return "FEHLER Schaetzung (Setup fehlt)"
try:
# === Feature Erstellung (exakt wie im Training) ===
# 1. Numerische Werte holen
umsatz_val = get_numeric_filter_value(self._get_cell_value_safe(row_data, "Finaler Umsatz (Wiki>CRM)"), is_umsatz=True)
ma_val = get_numeric_filter_value(self._get_cell_value_safe(row_data, "Finaler Mitarbeiter (Wiki>CRM)"), is_umsatz=False)
umsatz_val = np.nan if umsatz_val == 0 else umsatz_val
ma_val = np.nan if ma_val == 0 else ma_val
# 2. 'is_part_of_group' Feature
parent_d = self._get_cell_value_safe(row_data, "Parent Account Name").strip().lower()
parent_o = self._get_cell_value_safe(row_data, "System Vorschlag Parent Account").strip().lower()
parent_p = self._get_cell_value_safe(row_data, "Parent Vorschlag Status").strip().lower()
is_group = 1 if (parent_d and parent_d != 'k.a.') or (parent_o and parent_o != 'k.a.' and parent_p == 'x') else 0
# 3. Ratio & Log Features
log_umsatz = np.log1p(umsatz_val) if pd.notna(umsatz_val) else np.nan
log_ma = np.log1p(ma_val) if pd.notna(ma_val) else np.nan
umsatz_pro_ma = (umsatz_val / ma_val) if pd.notna(umsatz_val) and pd.notna(ma_val) and ma_val > 0 else np.nan
# 4. Branchen-Gruppen-Feature (entscheidende Korrektur)
branche_ki_val = self._get_cell_value_safe(row_data, "Chat Vorschlag Branche")
branch_group_map = {branch_name: details.get('gruppe', 'Sonstige') for branch_name, details in Config.BRANCH_GROUP_MAPPING.items()}
branchen_gruppe = branch_group_map.get(branche_ki_val, 'Sonstige')
# 5. DataFrame mit allen möglichen Features erstellen
data_for_prediction = {
'Log_Finaler_Umsatz_ML': log_umsatz,
'Log_Finaler_Mitarbeiter_ML': log_ma,
'Umsatz_pro_MA_ML': umsatz_pro_ma,
'is_part_of_group': is_group,
}
# Füge die One-Hot-Encoded Branchen-Gruppen hinzu
for expected_feature in self._expected_features:
if expected_feature.startswith('Gruppe_'):
gruppe_name_from_column = expected_feature.replace('Gruppe_', '')
data_for_prediction[expected_feature] = 1 if gruppe_name_from_column == branchen_gruppe else 0
df_processed = pd.DataFrame([data_for_prediction], columns=self._expected_features)
# 6. Vorhersage durchführen
# self.model ist die komplette Pipeline, die die Imputation intern durchführt
prediction_proba = self.model.predict_proba(df_processed)
predicted_bucket_label = self.model.classes_[np.argmax(prediction_proba[0])]
self.logger.debug(f" -> ML Vorhersage Ergebnis: '{predicted_bucket_label}'")
return predicted_bucket_label
except Exception as e_predict:
self.logger.exception(f"FEHLER bei der ML-Vorhersage für Zeile ({company_name[:50]}...): {e_predict}")
return f"FEHLER Schaetzung: {str(e_predict)[:100]}..."
def _load_ml_model(self, model_path, imputer_path):
"""
Laedt das trainierte ML-Modell, den Imputer und die Feature-Liste.
"""
self.model, self.imputer, self._expected_features = None, None, None
try:
if not os.path.exists(
model_path) or not os.path.exists(imputer_path):
self.logger.error("Modell- oder Imputer-Datei nicht gefunden.")
return
with open(model_path, 'rb') as f:
self.model = pickle.load(f)
with open(imputer_path, 'rb') as f:
self.imputer = pickle.load(f)
if os.path.exists(PATTERNS_FILE_JSON):
with open(PATTERNS_FILE_JSON, 'r', encoding='utf-8') as f:
self._expected_features = json.load(
f).get("feature_columns")
if self._expected_features:
self.logger.info("ML-Modell, Imputer und Features geladen.")
else:
self.logger.error("Konnte erwartete Features nicht laden.")
except Exception as e:
self.logger.exception(f"FEHLER beim Laden von ML-Artefakten: {e}")
def prepare_data_for_modeling(self):
"""
Laedt und bereitet Daten für das ML-Training vor.
"""
"""
Laedt Daten aus dem Google Sheet ueber den sheet_handler,
bereitet sie fuer das Decision Tree Modell vor:
- Waehlt relevante Spalten aus und benennt sie um.
- Konsolidiert Umsatz/Mitarbeiter (Wiki > CRM Prioritaet).
- Filtert nach gueltiger Technikerzahl (> 0).
- Erstellt die Zielvariable (Techniker-Bucket).
- Bereitet Features auf (One-Hot Encoding fuer Branche).
- Behaelt NaNs in numerischen Features fuer spaetere Imputation.
Returns:
pandas.DataFrame: Vorbereiteter DataFrame fuer Training/Test-Split,
oder None bei Fehlern oder wenn keine gueltigen Trainingsdaten gefunden wurden.
"""
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
# <<< GEÄNDERT
self.logger.info("Starte Datenvorbereitung fuer Modellierung (Training)...")
# Prüfen, ob der Sheet Handler initialisiert ist und Daten hat.
# Wenn nicht, versuchen, die Daten zu laden.
if not self.sheet_handler or not self.sheet_handler.get_all_data_with_headers():
if not self.sheet_handler.load_data():
self.logger.critical("Konnte Daten auch nach erneutem Versuch nicht laden. Abbruch der Datenvorbereitung.")
return None
self.logger.critical(
"Konnte Daten auch nach erneutem Versuch nicht laden. Abbruch der Datenvorbereitung.") # <<< GEÄNDERT
return None # Gebe None zurueck, wenn Laden fehlschlaegt
# Holen Sie die gesamte Datenliste (inklusive Header) aus dem
# SheetHandler.
all_data = self.sheet_handler.get_all_data_with_headers()
# Annahme: header_rows ist als Attribut im SheetHandler verfuegbar
# (Block 14).
header_rows = self.sheet_handler._header_rows
# Pruefe auf ausreichende Zeilenzahl (Header + mindestens eine
# Datenzeile)
min_required_rows = header_rows + 1
# Wenn nicht genuegend Zeilen da sind
if not all_data or len(all_data) < min_required_rows:
self.logger.error(
f"Fehler: Nicht genuegend Datenzeilen ({len(all_data)}) im Sheet gefunden fuer Modellierung (mindestens {min_required_rows} benoetigt).") # <<< GEÄNDERT
return None # Gebe None zurueck, wenn nicht genuegend Daten da sind
# --- Header pruefen und DataFrame erstellen ---
try:
# Die erste Zeile sollte die Spaltennamen enthalten.
headers = all_data[0]
# Stellen Sie sicher, dass die Header-Zeile auch die erwartete Mindestlaenge hat,
# um die Spaltenindizes aus COLUMN_MAP (Block 1) zu finden.
try:
# Finde den hoechsten Index in COLUMN_MAP
max_col_idx_in_map = max(d['index'] for d in COLUMN_MAP.values() if 'index' in d)
# Pruefen Sie, ob die Anzahl der geladenen Spalten im Header
# ausreicht
if len(headers) <= max_col_idx_in_map:
# Logge einen kritischen Fehler, wenn das Mapping auf
# Spalten zeigt, die nicht im Sheet existieren
self.logger.critical(
f"FEHLER: Header-Zeile ({len(headers)} Spalten) ist kuerzer als der hoechste Index in COLUMN_MAP ({max_col_idx_in_map}). COLUMN_MAP passt nicht zum Sheet.") # <<< GEÄNDERT
return None # Beende die Methode
except ValueError: # Tritt auf, wenn COLUMN_MAP leer ist
self.logger.critical(
"FEHLER: COLUMN_MAP scheint leer zu sein oder enthaelt keine Werte. Kann Max Index nicht ermitteln.") # <<< GEÄNDERT
return None # Beende die Methode
except Exception as e:
# Fange andere unerwartete Fehler ab
self.logger.critical(
f"FEHLER beim Pruefen der Spaltenlaenge der Header-Zeile: {e}") # <<< GEÄNDERT
return None # Beende die Methode
except IndexError:
# Wenn das Sheet leer ist oder keine erste Zeile hat
self.logger.critical(
"FEHLER: Sheet scheint leer zu sein oder hat keine erste Zeile, keine Header gefunden.") # <<< GEÄNDERT
return None # Beende die Methode
except Exception as e:
# Fange andere unerwartete Fehler beim Zugriff auf Header ab
self.logger.critical(
f"FEHLER beim Zugriff auf Header: {e}") # <<< GEÄNDERT
# Logge den Traceback
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
return None # Beende die Methode
# Datenzeilen sind alle Zeilen nach den Header-Zeilen
# Annahme: Die ersten X Zeilen sind Header
data_rows = all_data[header_rows:]
# Erstelle DataFrame aus den Datenzeilen und den Headern
df = pd.DataFrame(data_rows, columns=headers)
self.logger.info(
f"Initialen DataFrame fuer Modellierung erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.") # <<< GEÄNDERT
# DACH-Filter (basierend auf CRM Land - Spalte G)
# Holt den tatsächlichen Spaltennamen
crm_land_col_header = headers[get_col_idx("CRM Land")]
# Erlaubte Werte für DACH-Länder (Groß- und Kleinschreibung wird durch
# .str.upper() behandelt)
dach_countries = [
"DE",
"CH",
"AT",
"DEUTSCHLAND",
"ÖSTERREICH",
"SCHWEIZ",
"OESTERREICH"] # OESTERREICH hinzugefügt
# Sicherstellen, dass die Spalte existiert, bevor gefiltert wird
if crm_land_col_header in df.columns:
df = df[df[crm_land_col_header].astype(str).str.upper().isin(
dach_countries)].copy() # .copy() um Warnung zu vermeiden
self.logger.info(
f"Nach DACH-Filter (basierend auf '{crm_land_col_header}'): {len(df)} Zeilen verbleiben.")
if df.empty:
self.logger.error(
"Keine DACH-Unternehmen im Datensatz nach Filterung.")
return None
else:
self.logger.error(
f"Spalte '{crm_land_col_header}' für DACH-Filter nicht im DataFrame gefunden.")
return None
# Plausibilitätsfilter (basierend auf Spalten BG und BH)
plausi_umsatz_col_header = headers[get_col_idx("Plausibilität Umsatz")]
plausi_ma_col_header = headers[get_col_idx("Plausibilität Mitarbeiter")]
# Sicherstellen, dass die Spalten existieren
if plausi_umsatz_col_header in df.columns and plausi_ma_col_header in df.columns:
# Filtere Zeilen, bei denen Plausi-Umsatz oder Plausi-MA einen Fehler anzeigt
# Hier gehen wir davon aus, dass Fehler mit "FEHLER_" beginnen.
df = df[~df[plausi_umsatz_col_header].astype(
str).str.upper().str.startswith('FEHLER')].copy()
df = df[~df[plausi_ma_col_header].astype(
str).str.upper().str.startswith('FEHLER')].copy()
self.logger.info(
f"Nach Entfernung von FEHLER-Plausi-Fällen (BG, BH): {len(df)} Zeilen verbleiben.")
if df.empty:
self.logger.error("Keine Zeilen nach Plausi-Filterung übrig.")
return None
# Hier könnten Sie noch spezifischere Filter für bestimmte
# WARNUNG-Typen einbauen, falls gewünscht
else:
self.logger.error(
f"Plausibilitätsspalten '{plausi_umsatz_col_header}' oder '{plausi_ma_col_header}' nicht im DataFrame gefunden.")
return None
# --- Spaltenauswahl und Umbenennung ---
# Definiere die notwendigen Spalten anhand ihrer COLUMN_MAP Schluessel (Block 1)
# und weisen ihnen interne, einfachere Namen zu, die im DataFrame
# verwendet werden.
col_keys_mapping = {
"name": "CRM Name", # Zur Identifikation, wird spaeter entfernt
"branche_ki": "Chat Vorschlag Branche", # Fuer One-Hot Encoding
"umsatz_crm": "CRM Umsatz", # Fuer Konsolidierung
"umsatz_wiki": "Wiki Umsatz", # Fuer Konsolidierung
"ma_crm": "CRM Anzahl Mitarbeiter", # Fuer Konsolidierung
"ma_wiki": "Wiki Mitarbeiter", # Fuer Konsolidierung
# DIE ZIELVARIABLE (Bekannte Technikerzahl)
"techniker": "CRM Anzahl Techniker",
# Spalte D <- Dies ist Zeile 9012
"parent_d_raw": "Parent Account Name",
"parent_o_raw": "System Vorschlag Parent Account", # Spalte O
"parent_p_raw": "Parent Vorschlag Status" # Spalte P
}
# Ueberpruefe, ob alle benoetigten Spalten-Schluessel in der COLUMN_MAP
# (Block 1) vorhanden sind
missing_keys_in_map = [
key for key in col_keys_mapping.values() if key not in COLUMN_MAP]
if missing_keys_in_map:
self.logger.critical(
f"FEHLER: Folgende benoetigte Spalten-Schluessel fehlen in COLUMN_MAP fuer prepare_data_for_modeling: {missing_keys_in_map}.") # <<< GEÄNDERT
return None # Beende die Methode
# Erstelle das Mapping von tatsaechlichen Header-Namen zu internen Schluesseln.
# Verwende die Header-Namen aus dem geladenen Sheet und die COLUMN_MAP,
# um die richtigen Header zu finden.
header_to_internal_key = {} # Dict zum Umbenennen der Spalten
# Liste der Header-Namen, die aus dem DF ausgewaehlt werden
cols_to_select_by_header = []
try:
# Iteriere ueber das Mapping von internen zu COLUMN_MAP Schluesseln
for internal_key, column_map_key in col_keys_mapping.items():
# Hole den tatsaechlichen Header-Namen aus dem Sheet
header_name_from_sheet = headers[COLUMN_MAP[column_map_key]['index']]
# Fuege das Mapping hinzu
header_to_internal_key[header_name_from_sheet] = internal_key
# Fuege den Header-Namen zur Liste der auszuwaehlenden Spalten
# hinzu
cols_to_select_by_header.append(header_name_from_sheet)
# Waehle nur die benoetigten Spalten im DataFrame aus
# Kopie erstellen, um SettingWithCopyWarning zu vermeiden
df_subset = df[cols_to_select_by_header].copy()
# Benenne die Spalten um zu den internen Namen
df_subset.rename(columns=header_to_internal_key, inplace=True)
except KeyError as e:
# Dieser Fehler sollte eigentlich durch die obige Pruefung abgefangen werden,
# tritt aber auf, wenn ein erwarteter Header-Name nicht im
# geladenen DF ist (selten, wenn COLUMN_MAP korrekt ist).
self.logger.critical(
f"FEHLER beim Auswaehlen/Umbenennen der Spalten (KeyError: '{e}'). Der Header wurde nicht im DataFrame gefunden.") # <<< GEÄNDERT
self.logger.debug(
f"Erwartete Header: {cols_to_select_by_header}. Verfuegbare Header im DF: {list(df.columns)}") # <<< GEÄNDERT
return None # Beende die Methode
except IndexError as e:
# Tritt auf, wenn COLUMN_MAP einen Index > Anzahl Spalten im DF hat
self.logger.critical(
f"FEHLER beim Auswaehlen/Umbenennen der Spalten (IndexError: '{e}'). COLUMN_MAP zeigt auf Spalten, die nicht im geladenen Sheet existieren.") # <<< GEÄNDERT
self.logger.debug(
f"COLUMN_MAP: {COLUMN_MAP}. Sheet hat {len(headers)} Spalten.") # <<< GEÄNDERT
return None # Beende die Methode
except Exception as e:
# Fange andere unerwartete Fehler ab
self.logger.critical(
f"Unerwarteter FEHLER beim Auswaehlen/Umbenennen der Spalten: {e}") # <<< GEÄNDERT
# Logge den Traceback
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
return None # Beende die Methode
self.logger.info(
f"Benötigte Spalten fuer Modellierung ausgewaehlt und umbenannt: {list(df_subset.columns)}") # <<< GEÄNDERT
self.logger.info("Erstelle Feature 'is_part_of_group'...")
# Zugreifen auf die Spalten im DataFrame df_subset
# Die Spaltennamen hier müssen den internen Namen entsprechen,
# die in col_keys_mapping definiert wurden (z.B. 'parent_d_raw').
parent_d_series = df_subset['parent_d_raw'].astype(
str).str.strip().str.lower()
parent_o_series = df_subset['parent_o_raw'].astype(
str).str.strip().str.lower()
parent_p_series = df_subset['parent_p_raw'].astype(
str).str.strip().str.lower()
cond1 = parent_d_series.notna() & (
parent_d_series != 'k.a.') & (
parent_d_series != '')
cond2_o = parent_o_series.notna() & (
parent_o_series != 'k.a.') & (
parent_o_series != '')
cond2_p = parent_p_series == 'x'
cond2 = cond2_o & cond2_p
# .loc verwenden, um die neue Spalte sicher zuzuweisen und SettingWithCopyWarning zu vermeiden
df_subset.loc[:, 'is_part_of_group'] = np.where(cond1 | cond2, 1, 0)
self.logger.info(
f"Feature 'is_part_of_group' erstellt. {df_subset['is_part_of_group'].sum()} Unternehmen als Teil einer Gruppe markiert.")
self.logger.debug(
f"Verteilung von 'is_part_of_group':\n{df_subset['is_part_of_group'].value_counts(normalize=True, dropna=False)}")
# --- Features konsolidieren (Umsatz, Mitarbeiter) ---
self.logger.debug(
"Konsolidiere Umsatz und Mitarbeiter für ML-Features...")
cols_to_process_ml = {
'Umsatz': ('umsatz_wiki', 'umsatz_crm', 'Finaler_Umsatz_ML'),
'Mitarbeiter': ('ma_wiki', 'ma_crm', 'Finaler_Mitarbeiter_ML')
}
for base_name, (wiki_col_ml, crm_col_ml,
final_col_ml) in cols_to_process_ml.items():
is_umsatz_flag = (base_name == 'Umsatz')
wiki_series = df_subset[wiki_col_ml].apply(
lambda x: get_numeric_filter_value(
x, is_umsatz=is_umsatz_flag))
crm_series = df_subset[crm_col_ml].apply(
lambda x: get_numeric_filter_value(
x, is_umsatz=is_umsatz_flag))
# Wähle Wiki-Wert, wenn vorhanden und > 0, sonst CRM-Wert
df_subset.loc[:, final_col_ml] = np.where(
(wiki_series.notna()) & (wiki_series > 0),
wiki_series,
crm_series
)
# Ersetze 0 explizit durch NaN, damit es von log1p und Imputer
# korrekt behandelt wird
df_subset.loc[:, final_col_ml] = df_subset[final_col_ml].replace(
0, np.nan)
self.logger.info(
f" -> {df_subset[final_col_ml].notna().sum()} gueltige '{final_col_ml}' Werte erstellt (von {len(df_subset)} Zeilen).")
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# +++ NEUER BLOCK: Feature Engineering (Ratio & Log-Transformationen) +
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
self.logger.info(
"Erstelle zusätzliche Features (Ratio, Log-Transformationen)...")
if 'Finaler_Umsatz_ML' in df_subset.columns and 'Finaler_Mitarbeiter_ML' in df_subset.columns:
# Umsatz pro Mitarbeiter
# Hier sind Nullen schon durch NaN ersetzt
ma_for_ratio = df_subset['Finaler_Mitarbeiter_ML']
df_subset.loc[:,
'Umsatz_pro_MA_ML'] = df_subset['Finaler_Umsatz_ML'] / ma_for_ratio
df_subset['Umsatz_pro_MA_ML'].replace(
[np.inf, -np.inf], np.nan, inplace=True)
self.logger.debug(f" -> Feature 'Umsatz_pro_MA_ML' erstellt.")
# Log-Transformationen (np.log1p(x) berechnet log(1+x), sicher für
# NaNs)
df_subset.loc[:, 'Log_Finaler_Umsatz_ML'] = np.log1p(
df_subset['Finaler_Umsatz_ML'])
df_subset.loc[:, 'Log_Finaler_Mitarbeiter_ML'] = np.log1p(
df_subset['Finaler_Mitarbeiter_ML'])
self.logger.debug(f" -> Log-transformierte Features erstellt.")
else:
self.logger.warning(
"Konsolidierte Umsatz/Mitarbeiter-Spalten nicht gefunden, Feature Engineering übersprungen.")
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# +++ ENDE NEUER BLOCK ++++++++++++++++++++++++++++++++++++++++++++++++
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# --- Zielvariable vorbereiten (Technikerzahl) ---
self.logger.info("Verarbeite Zielvariable 'techniker'...")
techniker_col_internal = "techniker"
df_subset.loc[:, 'Anzahl_Servicetechniker_Numeric'] = df_subset[techniker_col_internal].apply(
lambda x: get_numeric_filter_value(x, is_umsatz=False))
initial_rows_before_tech_filter = len(df_subset)
df_filtered = df_subset[
df_subset['Anzahl_Servicetechniker_Numeric'].notna() &
(df_subset['Anzahl_Servicetechniker_Numeric'] > 0)
].copy()
removed_rows_tech_filter = initial_rows_before_tech_filter - \
len(df_filtered)
if removed_rows_tech_filter > 0:
self.logger.info(
f"{removed_rows_tech_filter} Zeilen entfernt aufgrund fehlender/ungueltiger Technikerzahl.")
self.logger.info(
f"Verbleibende Zeilen fuer Modellierungstraining: {len(df_filtered)}")
if df_filtered.empty:
self.logger.error(
"FEHLER: Keine Zeilen mit gueltiger Technikerzahl (>0) uebrig fuer Modellierungstraining!")
return None
# --- Techniker-Buckets erstellen (mit reduzierter Klassenanzahl) ---
self.logger.info(
"Erstelle reduzierte Techniker-Buckets (3 Klassen)...")
bins_new = [-1, 49, 249, float('inf')]
labels_new = [
'Techniker_Klein (0-49)',
'Techniker_Mittel (50-249)',
'Techniker_Gross (250+)']
df_filtered.loc[:, 'Techniker_Bucket'] = pd.cut(df_filtered['Anzahl_Servicetechniker_Numeric'],
bins=bins_new,
labels=labels_new,
right=True,
include_lowest=True)
self.logger.info(
f"Verteilung der neuen Techniker-Buckets:\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True, dropna=False).sort_index().round(3)}")
# --- Kategoriale Features vorbereiten (Branchen-Gruppen) ---
# Wir verwenden konsistent die KI-generierte Spalte 'branche_ki' als Basis für die Gruppierung.
branche_col_internal = "branche_ki"
self.logger.info(f"Verarbeite kategoriales Feature '{branche_col_internal}' und mappe es zu 'Branchen_Gruppe'...")
if branche_col_internal not in df_filtered.columns:
self.logger.critical(f"FEHLER: Die für das Mapping benötigte Spalte '{branche_col_internal}' wurde nicht im DataFrame gefunden. Breche ab.")
return None
# Wende das saubere Mapping aus der Config an, um nur den 'gruppe'-String zu extrahieren.
branch_group_map = {branch_name: details.get('gruppe', 'Sonstige') for branch_name, details in Config.BRANCH_GROUP_MAPPING.items()}
df_filtered.loc[:, 'Branchen_Gruppe'] = df_filtered[branche_col_internal].map(branch_group_map).fillna('Sonstige')
self.logger.info("Mapping zu 'Branchen_Gruppe' durchgeführt.")
self.logger.debug(
f"Verteilung der Branchen-Gruppen:\n{df_filtered['Branchen_Gruppe'].value_counts(normalize=True).sort_index().round(3)}")
# --- One-Hot Encoding ---
df_encoded = pd.get_dummies(df_filtered, columns=['Branchen_Gruppe'], prefix='Gruppe', dummy_na=False)
self.logger.info("One-Hot Encoding fuer 'Branchen_Gruppe' durchgefuehrt.")
# --- Finale Auswahl der Features fuer das Modell ---
feature_columns_ml = [col for col in df_encoded.columns if col.startswith('Gruppe_')]
feature_columns_ml.extend(['Log_Finaler_Umsatz_ML', 'Log_Finaler_Mitarbeiter_ML', 'Umsatz_pro_MA_ML', 'is_part_of_group'])
self.logger.info(f"Finale Feature-Auswahl für das Training: {feature_columns_ml}")
target_column_ml = 'Techniker_Bucket'
identification_cols_ml = ['name', 'Anzahl_Servicetechniker_Numeric']
final_cols_for_df_ml = identification_cols_ml + feature_columns_ml + [target_column_ml]
missing_final_cols_ml = [col for col in final_cols_for_df_ml if col not in df_encoded.columns]
if missing_final_cols_ml:
self.logger.critical(f"FEHLER: Finale Spalten fuer Modellierung fehlen im DataFrame: {missing_final_cols_ml}")
return None
df_model_ready = df_encoded[final_cols_for_df_ml].copy()
numeric_features_to_convert = ['Log_Finaler_Umsatz_ML', 'Log_Finaler_Mitarbeiter_ML', 'Umsatz_pro_MA_ML', 'Anzahl_Servicetechniker_Numeric']
for col in numeric_features_to_convert:
if col in df_model_ready.columns:
df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce')
df_model_ready = df_model_ready.reset_index(drop=True)
self.logger.info("Datenvorbereitung fuer Modellierung (Training) abgeschlossen.")
self.logger.info(f"Finaler DataFrame fuer Modellierung hat {len(df_model_ready)} Zeilen und {len(df_model_ready.columns)} Spalten.")
self.logger.info(f"Anzahl Feature-Spalten: {len(feature_columns_ml)}")
numeric_features_for_imputation_ml = ['Log_Finaler_Umsatz_ML', 'Log_Finaler_Mitarbeiter_ML', 'Umsatz_pro_MA_ML']
existing_numeric_features = [col for col in numeric_features_for_imputation_ml if col in df_model_ready.columns]
if existing_numeric_features:
nan_counts = df_model_ready[existing_numeric_features].isna().sum()
self.logger.info(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}")
rows_with_nan = df_model_ready[existing_numeric_features].isna().any(axis=1).sum()
self.logger.info(f"Anzahl Zeilen mit mindestens einem fehlenden numerischen Feature (vor Imputation): {rows_with_nan}")
return df_model_ready
def train_technician_model(
self,
model_out=MODEL_FILE,
imputer_out=IMPUTER_FILE,
patterns_out=PATTERNS_FILE_JSON):
"""
Trainiert, evaluiert und speichert das ML-Modell für die Techniker-Schätzung.
"""
self.logger.info("Starte Training des Servicetechniker-Modells...")
self.logger.info(
"Starte Training des Servicetechniker Decision Tree Modells...")
# 1. Daten vorbereiten
df_model_ready = self.prepare_data_for_modeling()
if df_model_ready is None or df_model_ready.empty:
self.logger.error(
"Datenvorbereitung fuer Modelltraining fehlgeschlagen oder keine Daten. Training abgebrochen.")
return
# Feature Spalten und Zielspalte definieren
identification_cols = ['name', 'Anzahl_Servicetechniker_Numeric']
target_column = 'Techniker_Bucket'
feature_columns_ml = [
col for col in df_model_ready.columns if col not in identification_cols and col != target_column]
if not feature_columns_ml:
self.logger.critical(
"FEHLER: Keine Feature-Spalten nach Datenvorbereitung gefunden. Training nicht moeglich.")
return
X = df_model_ready[feature_columns_ml]
y = df_model_ready[target_column]
self.logger.info(
f"Daten fuer Training vorbereitet. X Shape: {X.shape}, y Shape: {y.shape}")
# 2. Split in Training und Test Set
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y)
self.logger.info(
f"Daten gesplittet. Train Set: {len(X_train)} Zeilen, Test Set: {len(X_test)} Zeilen.")
# 3. Pipeline-Definition (Imputation wird Teil der Pipeline)
# Schritt 1: Imputer für fehlende Werte
# Schritt 2: SMOTE für Klassen-Balancierung
# Schritt 3: RandomForestClassifier als Modell
pipeline = ImbPipeline([
('imputer', SimpleImputer(strategy='median')),
('smote', SMOTE(random_state=42)),
('classifier', RandomForestClassifier(random_state=42, n_jobs=-1))
])
# 4. Hyperparameter-Grid für GridSearchCV definieren
# Die Parameternamen müssen mit dem Namen des Schritts in der Pipeline beginnen.
param_grid = {
'classifier__n_estimators': [200, 300],
'classifier__max_depth': [10, 20, None],
'classifier__min_samples_split': [2, 5],
'classifier__min_samples_leaf': [1, 2]
}
# 5. GridSearchCV initialisieren
grid_search = GridSearchCV(
estimator=pipeline,
param_grid=param_grid,
cv=3,
scoring='accuracy',
verbose=2,
n_jobs=-1)
self.logger.info("Starte Hyperparameter-Tuning mit GridSearchCV...")
start_fit_time = time.time()
# 6. Grid auf den Original-Trainingsdaten fitten (ohne vorherige Imputation)
# Die Pipeline kümmert sich intern um die korrekte Imputation und SMOTE für jeden Fold.
grid_search.fit(X_train, y_train)
end_fit_time = time.time()
self.logger.info(
f"GridSearchCV-Suche abgeschlossen. Dauer: {end_fit_time - start_fit_time:.2f} Sekunden.")
# Beste Parameter und bestes Modell ausgeben und speichern
self.logger.info(
f"Beste gefundene Parameter: {grid_search.best_params_}")
self.logger.info(
f"Beste Cross-Validation Accuracy: {grid_search.best_score_:.4f}")
# Das beste Modell ist das, das mit den besten Parametern auf den
# *gesamten* Trainingsdaten trainiert wurde.
best_classifier = grid_search.best_estimator_
# Modell speichern
self.model = best_classifier # Zuweisung des besten gefundenen Modells
try:
model_dir = os.path.dirname(model_out)
if model_dir and not os.path.exists(model_dir):
os.makedirs(model_dir, exist_ok=True)
with open(model_out, 'wb') as f:
pickle.dump(best_classifier, f) # Das beste Modell speichern
self.logger.info(
f"Bestes RandomForest Modell erfolgreich gespeichert in '{model_out}'.")
except Exception as e:
self.logger.error(
f"FEHLER beim Speichern des besten Modells in '{model_out}': {e}")
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# +++ ENDE ANPASSUNG ++++++++++++++++++++++++++++++++++++++++++++++++++
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# Feature-Liste speichern (bleibt unverändert)
self._expected_features = feature_columns_ml
try:
# Wir verwenden die .classes_ vom besten gefundenen Modell
patterns_data = {
"feature_columns": self._expected_features,
"target_classes": list(
best_classifier.classes_)} # << KORRIGIERT
patterns_dir = os.path.dirname(patterns_out)
if patterns_dir and not os.path.exists(patterns_dir):
os.makedirs(patterns_dir, exist_ok=True)
with open(patterns_out, 'w', encoding='utf-8') as f:
json.dump(patterns_data, f, indent=4, ensure_ascii=False)
self.logger.info(
f"Erwartete Feature-Spalten und Klassen erfolgreich gespeichert in '{patterns_out}'.")
except Exception as e:
self.logger.error(
f"FEHLER beim Speichern der Feature-Spalten in '{patterns_out}': {e}")
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# +++ ENDE FEATURE-LISTEN SPEICHERUNG +++++++++++++++++++++++++++++++++
# +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# 5. Evaluation (Optional, aber empfohlen, um die Modellleistung zu
# bewerten)
self.logger.info(
"Starte Evaluation des besten Modells auf dem ungesehenen Testset...")
y_pred = best_classifier.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
self.logger.info(f"Finale Modell Genauigkeit auf dem Testset: {accuracy:.4f}")
# Erzeuge den Klassifikationsbericht als Dictionary für eine saubere Ausgabe
report_dict = classification_report(
y_test,
y_pred,
zero_division=0,
labels=self.model.classes_,
target_names=[str(c) for c in self.model.classes_],
output_dict=True
)
# Konvertiere das Dictionary in einen formatierten DataFrame für das Logging
report_df = pd.DataFrame(report_dict).transpose()
report_df['support'] = report_df['support'].astype(int) # Für saubere Darstellung
# Erstelle einen gut lesbaren String aus dem DataFrame
report_string = report_df.to_string()
self.logger.info(f"Klassifikationsbericht auf dem Testset:\n{report_string}")
cm = confusion_matrix(y_test, y_pred, labels=self.model.classes_)
cm_df = pd.DataFrame(cm, index=self.model.classes_, columns=self.model.classes_)
self.logger.info(f"Konfusionsmatrix auf dem Testset (Zeilen=Wahr, Spalten=Vorhersage):\n{cm_df.to_string()}")
# Block für Feature Importance
try:
# Greife auf den Schritt 'classifier' in der Pipeline zu, um das
# finale Modell zu bekommen
final_rf_model = best_classifier.named_steps['classifier']
self.logger.info("Feature Importance des besten Modells (Top 15):")
importances = final_rf_model.feature_importances_ # << KORRIGIERT
feature_importance_df = pd.DataFrame({
'Feature': feature_columns_ml,
'Importance': importances
}).sort_values(by='Importance', ascending=False)
self.logger.info(
f"\n{feature_importance_df.head(15).to_string(index=False)}")
except Exception as e_feat_imp:
self.logger.warning(
f"FEHLER beim Berechnen/Anzeigen der Feature Importance: {e_feat_imp}")
self.logger.info("Modelltraining und -evaluation abgeschlossen.")
def process_predict_technicians(self, start_sheet_row=None, end_sheet_row=None, limit=None):
"""
Batch-Prozess zur Vorhersage von Techniker-Buckets für Zeilen,
bei denen diese Information fehlt.
"""
self.logger.info(f"Starte Modus 'predict_technicians' (Batch). Bereich: {start_sheet_row or 'Start'}-{end_sheet_row or 'Ende'}, Limit: {limit or 'Unbegrenzt'}")
# Prüfen, ob das Modell geladen ist (passiert im setup())
if not self.is_setup_complete or self.model is None or self.imputer is None or self._expected_features is None:
self.logger.error("ML-Artefakte nicht initialisiert. Bitte zuerst trainieren. Breche Vorhersage ab.")
return
if not self.sheet_handler.load_data():
self.logger.error("Fehler beim Laden der Daten für die Vorhersage.")
return
all_data = self.sheet_handler.get_all_data_with_headers()
header_rows = self.sheet_handler._header_rows
effective_start = start_sheet_row if start_sheet_row is not None else header_rows + 1
effective_end = end_sheet_row if end_sheet_row is not None else len(all_data)
tasks = []
for i in range(effective_start - 1, effective_end):
if limit is not None and len(tasks) >= limit:
break
row_data = all_data[i]
bucket_value = self._get_cell_value_safe(row_data, "Geschaetzter Techniker Bucket").strip()
# Kriterium: Bucket ist leer oder enthält einen Fehler
if not bucket_value or "fehler" in bucket_value.lower():
tasks.append({'row_num': i + 1, 'data': row_data})
if not tasks:
self.logger.info("Keine Zeilen für die Techniker-Vorhersage gefunden.")
return
self.logger.info(f"{len(tasks)} Zeilen für die Vorhersage identifiziert. Starte Verarbeitung...")
all_updates = []
processed_count = 0
update_batch_size = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)
for idx, task in enumerate(tasks):
row_num = task['row_num']
row_data = task['data']
try:
# Aufruf der internen Methode zur Vorhersage für die einzelne Zeile
predicted_bucket = self._predict_technician_bucket(row_data)
col_idx = get_col_idx("Geschaetzter Techniker Bucket")
if col_idx is not None:
col_letter = _get_col_letter(col_idx + 1)
all_updates.append({'range': f'{col_letter}{row_num}', 'values': [[predicted_bucket]]})
processed_count += 1
except Exception as e:
self.logger.error(f"Fehler bei der Vorhersage für Zeile {row_num}: {e}", exc_info=True)
# Batch-Update-Logik
if (idx + 1) % update_batch_size == 0 or (idx + 1) == len(tasks):
if all_updates:
self.logger.info(f"Sende Batch-Update für {len(all_updates)} Vorhersagen...")
self.sheet_handler.batch_update_cells(all_updates)
all_updates = []
self.logger.info(f"Techniker-Vorhersage abgeschlossen. {processed_count} Zeilen bearbeitet.")
def process_website_details(
self,
start_sheet_row=None,
end_sheet_row=None,
limit=None):
"""
EXPERIMENTELL: Extrahiert Website-Details für Zeilen mit 'x' in Spalte A.
"""
"""
EXPERIMENTELL: Extrahiert Website-Details fuer Zeilen, die in Spalte A mit 'x' markiert sind.
Schreibt die Details in eine definierte Spalte (Website Details oder AR als Fallback).
Loescht NICHT das 'x'-Flag.
Args:
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (ab Zeile 7).
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht uebersprungener) Zeilen. Defaults to None (Unbegrenzt).
"""
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
# Logge den Start des Modus auf Warning, da es experimentell ist.
self.logger.warning(
f"Starte Modus (EXPERIMENTELL): Website Detail Extraction fuer Zeilen mit 'x' in Spalte A. Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT
self.logger.warning(
"Hinweis: Dieser Modus nutzt die globale Funktion 'scrape_website_details' (Block 13), deren Implementierung je nach Zielwebsites angepasst werden muss.") # <<< GEÄNDERT
# --- Daten laden ---
# Laden Sie Daten neu. Kein automatischer Startindex-Check noetig hier,
# da wir explizit nach dem 'x'-Flag suchen.
# Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2).
if not self.sheet_handler.load_data():
self.logger.error(
"Fehler beim Laden der Daten fuer Website Details Extraction.") # <<< GEÄNDERT
return # Beende die Methode, wenn das Laden fehlschlaegt
# Holen Sie die gesamte Datenliste (inklusive Header) aus dem
# SheetHandler.
all_data = self.sheet_handler.get_all_data_with_headers()
# Annahme: header_rows ist als Attribut im SheetHandler verfuegbar
# (Block 14).
header_rows = self.sheet_handler._header_rows
total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet
# Standard Startzeile, wenn nicht manuell gesetzt
if start_sheet_row is None:
# Standardmaessig ab erster Datenzeile (Zeile nach Headern)
start_sheet_row = header_rows + 1
# Berechne Endzeile, wenn nicht manuell gesetzt
if end_sheet_row is None:
end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
# Logge den Suchbereich fuer das 'x'-Flag
self.logger.info(
f"Suchbereich fuer 'x'-Flag: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT
# Pruefe, ob der Bereich gueltig ist
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
self.logger.info(
"Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
return # Beende die Methode, wenn der Bereich leer ist
# --- Indizes und Buchstaben ---
# Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP
# (Block 1) vorhanden sind
required_keys = ["ReEval Flag", "CRM Website", "CRM Name"] # A, D, B
# Erstellen Sie ein Dictionary mit Schluesseln und Indizes
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
# Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden
# wurden
if None in col_indices.values():
missing = [k for k, v in col_indices.items() if v is None]
self.logger.critical(
f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_website_details: {missing}. Breche ab.") # <<< GEÄNDERT
return # Beende die Methode bei kritischem Fehler
# Ermitteln Sie die Indizes
reeval_col_idx = col_indices["ReEval Flag"] # A
website_col_idx = col_indices["CRM Website"] # D
# Bestimme die ZIELSPALTE fuer die Details (Website Details ODER AR als
# Fallback)
# Versuche zuerst die dedizierte Spalte (Block 1 Column Map)
details_col_idx = COLUMN_MAP.get("Website Details")
details_col_key_for_logging = "Website Details" # Name fuer Logging
# Wenn die dedizierte Spalte nicht gefunden wurde
if details_col_idx is None:
# Fallback auf 'Website Rohtext' (AR)
details_col_idx = COLUMN_MAP.get(
"Website Rohtext") # Block 1 Column Map
details_col_key_for_logging = "Website Rohtext"
# Pruefen Sie, ob der Fallback-Schluessel gefunden wurde
if details_col_idx is None:
self.logger.critical(
"FEHLER: Weder 'Website Details' noch 'Website Rohtext' Spaltenindex in COLUMN_MAP gefunden.") # <<< GEÄNDERT
return # Beende die Methode bei kritischem Fehler
self.logger.warning(
f"Keine Spalte 'Website Details' in COLUMN_MAP, nutze '{details_col_key_for_logging}' ({_get_col_letter(details_col_idx+1)}) als Fallback.") # <<< GEÄNDERT
else:
# Logge die Verwendung der dedizierten Spalte
self.logger.info(
f"Nutze Spalte '{details_col_key_for_logging}' ({_get_col_letter(details_col_idx+1)}) fuer Website Details.") # <<< GEÄNDERT
# Ermitteln Sie den Spaltenbuchstaben der Zielspalte (nutzt interne
# Helfer _get_col_letter Block 14)
details_col_letter = _get_col_letter(
details_col_idx + 1)
# --- Verarbeitung ---
# Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1).
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)
# Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts)
all_sheet_updates = []
# Zaehlt Zeilen, die fuer die Verarbeitung in Frage kommen und in den
# Batch aufgenommen werden (im Rahmen des Limits).
processed_count = 0
# Zaehlt Zeilen, die uebersprungen wurden (nicht markiert oder fehlende
# URL).
skipped_count = 0
# Iteriere durch die Datenzeilen im definierten Bereich (1-basierte
# Sheet-Zeilennummer)
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
# Pruefen Sie, ob das Ende des Sheets erreicht wurde
if row_index_in_list >= total_sheet_rows:
break # Ende des Sheets erreicht
row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile
# Stellen Sie sicher, dass die Zeile nicht leer ist
if not any(cell and isinstance(cell, str) and cell.strip()
for cell in row):
# self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).")
# # Zu viel Laerm im Debug
skipped_count += 1 # Zaehlen als uebersprungene Zeile
continue # Springe zur naechsten Zeile
# --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
# Kriterium: Zeile ist mit 'x' in Spalte A (ReEval Flag) markiert.
# UND Website URL (D) ist vorhanden und gueltig aussehend.
# Holen Sie den Wert aus Spalte A (ReEval Flag) (nutzt interne
# Helfer _get_cell_value_safe)
cell_a_value = self._get_cell_value_safe(
row, "ReEval Flag").strip().lower() # Block 1 Column Map
# Pruefen Sie, ob die Zelle mit 'x' markiert ist.
is_marked_for_processing = cell_a_value == "x"
# Wenn die Zeile nicht mit 'x' markiert ist, ueberspringen
if not is_marked_for_processing:
skipped_count += 1 # Zaehlen als uebersprungene Zeile
continue # Springe zur naechsten Zeile
# Holen Sie den Wert aus Spalte D (CRM Website) (nutzt interne
# Helfer _get_cell_value_safe)
website_url = self._get_cell_value_safe(
row, "CRM Website").strip() # Block 1 Column Map
# Pruefen Sie, ob die Website URL (D) vorhanden und gueltig
# aussehend ist.
website_url_is_valid_looking = website_url and isinstance(
website_url,
str) and website_url.lower() not in [
"k.a.",
"kein artikel gefunden",
"fehler bei suche",
"http:"] # Fuege "http:" hinzu basierend auf Log
# Verarbeitung ist noetig, wenn die Zeile mit 'x' markiert ist UND
# die Website URL gueltig ist.
processing_needed_for_row = is_marked_for_processing and website_url_is_valid_looking
# Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
log_check = (
i < start_sheet_row +
5) or (
i %
100 == 0) or (processing_needed_for_row)
if log_check:
company_name = self._get_cell_value_safe(
row, "CRM Name").strip() # Block 1 Column Map
self.logger.debug(
f"Zeile {i} ({company_name[:50]}... Website Details Check): A='x'? {is_marked_for_processing}, D gueltig? {website_url_is_valid_looking}. Benoetigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT
# Wenn die Verarbeitung fuer diese Zeile nicht noetig ist (trotz
# 'x' fehlte die URL)
if not processing_needed_for_row:
skipped_count += 1 # Zaehlen als uebersprungene Zeile
# Optionale Behandlung: Wenn mit 'x' markiert, aber URL fehlt, was tun?
# Derzeit wird sie uebersprungen. Ggf. Fehler in Spalte
# notieren?
continue # Springe zur naechsten Zeile
# --- Wenn Verarbeitung noetig: Fuehre Details-Extraktion aus ---
# Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im
# Rahmen des Limits zaehlen)
processed_count += 1
# Pruefe das Limit fuer verarbeitete Zeilen
if limit is not None and isinstance(
limit, int) and limit > 0 and processed_count > limit:
# Wenn das Limit erreicht ist und es ein positives Limit gibt
self.logger.info(
f"Verarbeitungslimit ({limit}) fuer process_website_details erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
break # Brich die Schleife ab
# <<< GEÄNDERT (war selflogger)
self.logger.info(
f"Zeile {i}: Extrahiere Website Details von {website_url[:100]}...")
# Default Fehler, falls die Funktion nicht existiert (Sollte nicht
# passieren, wenn Block 13 korrekt ist)
details = "FEHLER: Funktion 'scrape_website_details' nicht verfuegbar"
try:
# Rufe die globale Funktion scrape_website_details auf (Block 13).
# scrape_website_details ist mit retry_on_failure dekoriert (Block 2).
# Wenn scrape_website_details fehlschlaegt, wirft sie eine
# Exception oder gibt einen Fehlerwert zurueck.
# <<< Ruft globale Funktion (Block 13)
details = scrape_website_details(website_url)
# Wenn die Funktion einen Fehler geloggt hat und einen Fehlerstring im Ergebnis zurueckgibt,
# wird dies in der 'details' Variable gespeichert.
if isinstance(details, str) and (details.startswith(
"k.A. (Fehler") or details.startswith("FEHLER:")):
# Fehler wurde bereits in scrape_website_details geloggt.
pass # Details enthaelt bereits den Fehlerstring.
elif not isinstance(details, str) or not details.strip():
# Wenn die Funktion keinen String oder einen leeren String
# zurueckgibt.
# Standard-Fehlerwert
details = "k.A. (Extraktion leer oder ungueltig)"
except NameError:
# Dieser Fehler sollte nicht auftreten, wenn
# scrape_website_details in Block 13 ist.
self.logger.critical(
"FEHLER: Funktion 'scrape_website_details' ist nicht definiert! Kann Details nicht extrahieren.") # <<< GEÄNDERT
# Logge den Traceback.
self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT
details = "FEHLER: Funktion nicht definiert" # Setze spezifischen Fehlerwert
except Exception as e_detail:
# Fange andere unerwartete Fehler ab, die nicht von
# scrape_website_details behandelt wurden.
self.logger.exception(
f"Unerwarteter Fehler bei scrape_website_details fuer {website_url[:100]}...: {type(e_detail).__name__} - {e_detail}") # <<< GEÄNDERT
# Signalisiert Fehler (gekuerzt)
details = f"k.A. (Unerwarteter Fehler: {str(e_detail)[:100]}...)"
# Fuege Update fuer die Details-Spalte hinzu (nutzt interne Helfer _get_col_letter Block 14)
# Stellen Sie sicher, dass der Wert ein String ist.
updates_for_row = [] # Lokale Liste fuer Updates dieser Zeile
updates_for_row.append({'range': f'{details_col_letter}{i}', 'values': [
[str(details)]]}) # Block 1 Column Map
self.logger.debug(
f"Zeile {i}: Details extrahiert und zum Update fuer Spalte {details_col_key_for_logging} ({details_col_letter}{i}) hinzugefuegt.") # <<< GEÄNDERT
# Sammle die Updates fuer diese Zeile in der globalen Liste
# all_sheet_updates.
all_sheet_updates.extend(updates_for_row)
# Sende gesammelte Sheet Updates wenn das Update-Batch-Limit erreicht ist.
# update_batch_row_limit wird aus Config geholt (Block 1).
# Updates pro Zeile ist 1 in diesem Modus. Anzahl der Zeilen =
# len(all_sheet_updates).
if len(all_sheet_updates) >= update_batch_row_limit:
self.logger.debug(
f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
# Wenn es fehlschlaegt, wird es intern geloggt.
success = self.sheet_handler.batch_update_cells(
all_sheet_updates)
if success:
self.logger.info(
f" Sheet-Update fuer {len(all_sheet_updates)} Zellen erfolgreich.") # <<< GEÄNDERT
# Der Fehlerfall wird von batch_update_cells geloggt
# Leere die gesammelten Updates nach dem Senden.
all_sheet_updates = []
# Kleine Pause nach jeder Extraktion (nutzt Config Block 1).
# Dieser Modus macht API calls (ueber scrape_website_details und
# dessen Helfer), also Pause einbauen.
pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.2
# self.logger.debug(f"Warte {pause_duration:.2f}s nach
# Extraktion...") # Zu viel Laerm im Debug
time.sleep(pause_duration)
# --- Finale Sheet Updates senden ---
# Sende alle verbleibenden gesammelten Updates in einem letzten
# Batch-Update.
if all_sheet_updates:
self.logger.info(
f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block
# 14) mit Retry.
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
if success:
# <<< GEÄNDERT
self.logger.info(f"FINALES Sheet-Update erfolgreich.")
# Der Fehlerfall wird von batch_update_cells geloggt
# Logge den Abschluss des Modus
self.logger.info(
f"Modus 'website_details' abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {skipped_count} Zeilen uebersprungen.") # <<< GEÄNDERT
def process_wiki_updates_from_chatgpt(
self,
start_sheet_row=None,
end_sheet_row=None,
limit=None):
"""
Verarbeitet Wiki-Updates basierend auf ChatGPT-Vorschlägen.
"""
"""
Identifiziert Zeilen, in denen Status S gesetzt ist, aber NICHT auf einem Endzustand
(OK, X (UPDATED/COPIED/INVALID)), prueft ob U eine *valide* und *andere* Wiki-URL ist.
- Wenn ja: Kopiert U->M, markiert S='X (URL Copied)', U='URL uebernommen', loescht
abhaengige Wiki-Spalten (N-V, AN, AO, AP, AX), setzt ReEval-Flag A='x'.
- Wenn nein (U keine URL, U==M, oder U ungueltig): LOESCHT den Inhalt von U und
markiert S als 'X (Invalid Suggestion)'.
Verarbeitet maximal limit Zeilen.
Args:
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (ab Zeile 7).
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
limit (int, optional): Maximale Anzahl ZU PRUEFENDER Zeilen. Defaults to None (Unbegrenzt).
"""
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
# Logge die Konfiguration des Modus
self.logger.info(
f"Starte Modus 'wiki_updates_from_chatgpt' (S, U, M, N-V, AN, AO, AX, AP, A). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT
# --- Daten laden ---
# Laden Sie Daten neu. Kein automatischer Startindex-Check noetig hier,
# da wir nach Status S suchen.
# Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2).
if not self.sheet_handler.load_data():
self.logger.error(
"Fehler beim Laden der Daten fuer Wiki Updates.") # <<< GEÄNDERT
return # Beende die Methode, wenn das Laden fehlschlaegt
# Holen Sie die gesamte Datenliste (inklusive Header) aus dem
# SheetHandler.
all_data = self.sheet_handler.get_all_data_with_headers()
# Annahme: header_rows ist als Attribut im SheetHandler verfuegbar
# (Block 14).
header_rows = self.sheet_handler._header_rows
total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet
# Standard Startzeile, wenn nicht manuell gesetzt
if start_sheet_row is None:
# Standardmaessig ab erster Datenzeile (Zeile nach Headern)
start_sheet_row = header_rows + 1
# Berechne Endzeile, wenn nicht manuell gesetzt
if end_sheet_row is None:
end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
# Logge den Suchbereich fuer Status S
self.logger.info(
f"Suchbereich fuer Status S: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT
# Pruefe, ob der Bereich gueltig ist
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
self.logger.info(
"Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
return # Beende die Methode, wenn der Bereich leer ist
# --- Indizes und Buchstaben ---
# Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP
# (Block 1) vorhanden sind
required_keys = [
# S, U, M (Pruefkriterien / Daten)
"Chat Wiki Konsistenzpruefung", "Chat Vorschlag Wiki Artikel", "Wiki URL",
# AN, AX, AO, AP (Spalten zum Loeschen)
"Wikipedia Timestamp", "Wiki Verif. Timestamp", "Timestamp letzte Pruefung", "Version",
"ReEval Flag", # A (ReEval Flag setzen)
# N-R (Spalten zum Loeschen)
"Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien",
# T, V (Spalten zum Loeschen)
"Chat Begründung Wiki Inkonsistenz", "Begruendung bei Abweichung",
# AY (SerpAPI Wiki Search Timestamp) wird ebenfalls geleert, da
# abhaengig von M.
"SerpAPI Wiki Search Timestamp" # AY (Spalte zum Leeren)
]
# Erstellen Sie ein Dictionary mit Schluesseln und Indizes
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
# Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden
# wurden
if None in col_indices.values():
missing = [k for k, v in col_indices.items() if v is None]
self.logger.critical(
f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_wiki_updates_from_chatgpt: {missing}. Breche ab.") # <<< GEÄNDERT
return # Beende die Methode bei kritischem Fehler
# Ermitteln Sie die Spaltenbuchstaben fuer Updates/Leerung (nutzt
# interne Helfer _get_col_letter Block 14)
s_letter = _get_col_letter(
col_indices["Chat Wiki Konsistenzpruefung"] + 1) # Status S
u_letter = _get_col_letter(
col_indices["Chat Vorschlag Wiki Artikel"] + 1) # Vorschlag U
m_letter = _get_col_letter(
col_indices["Wiki URL"] + 1) # Wiki URL M
a_letter = _get_col_letter(
col_indices["ReEval Flag"] + 1) # ReEval Flag A
# Spalten N-V leeren.
# N ist Wiki Absatz, V ist Begruendung bei Abweichung.
n_idx = col_indices["Wiki Absatz"]
v_idx = col_indices["Begruendung bei Abweichung"]
# Erstellen Sie den Bereichsnamen (z.B. "N:V")
n_letter = _get_col_letter(n_idx + 1)
v_letter = _get_col_letter(v_idx + 1)
nv_range_letter = f'{n_letter}:{v_letter}' # z.B. N:V
# Erstellen Sie eine Liste von leeren Strings fuer diesen Bereich
# Anzahl der Spalten = V_Index - N_Index + 1
empty_nv_values = [''] * (v_idx - n_idx + 1)
# Timestamps AN, AO, AX, AP, AY leeren.
an_letter = _get_col_letter(
col_indices["Wikipedia Timestamp"] + 1) # AN (Wiki Extraction TS)
ao_letter = _get_col_letter(
col_indices["Timestamp letzte Pruefung"] +
1) # AO (Chat Evaluation TS)
ap_letter = _get_col_letter(
col_indices["Version"] + 1) # AP (Version)
ax_letter = _get_col_letter(
col_indices["Wiki Verif. Timestamp"] + 1) # AX (Wiki Verif. TS)
ay_letter = _get_col_letter(
col_indices["SerpAPI Wiki Search Timestamp"] +
1) # AY (SerpAPI Wiki TS)
# --- Verarbeitung ---
# Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1).
update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50)
# Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts)
all_sheet_updates = []
# Zaehlt Zeilen, die geprueft werden (im Rahmen des Limits zaehlen).
processed_rows_count = 0
# Zaehlt Zeilen, die uebersprungen werden (Status S im Endzustand
# etc.).
skipped_count = 0
updated_url_count = 0 # Zaehlt Zeilen, wo U -> M kopiert wurde.
# Zaehlt Zeilen, wo Vorschlag U geloescht wurde.
cleared_suggestion_count = 0
# Iteriere durch die Datenzeilen im definierten Bereich (1-basierte
# Sheet-Zeilennummer)
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
# Pruefen Sie, ob das Ende des Sheets erreicht wurde
if row_index_in_list >= total_sheet_rows:
break # Ende des Sheets erreicht
row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile
# Stellen Sie sicher, dass die Zeile nicht leer ist
if not any(cell and isinstance(cell, str) and cell.strip()
for cell in row):
# self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).")
# # Zu viel Laerm im Debug
skipped_count += 1 # Zaehlen als uebersprungene Zeile
continue # Springe zur naechsten Zeile
# --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
# Kriterium: Status S ist gesetzt (nicht leer) UND NICHT einer der Endzustaende.
# Endzustaende: "OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID
# SUGGESTION)"
# Holen Sie den Wert aus Spalte S (Chat Wiki Konsistenzpruefung)
# (nutzt interne Helfer _get_cell_value_safe)
s_value = self._get_cell_value_safe(
row, "Chat Wiki Konsistenzpruefung").strip() # Block 1 Column Map
s_value_upper = s_value.upper()
# Definieren Sie die Endzustaende (Grossbuchstaben)
s_end_states = [
"OK",
"X (UPDATED)",
"X (URL COPIED)",
"X (INVALID SUGGESTION)"]
# Verarbeitung ist noetig, wenn S nicht leer ist UND S NICHT im
# Endzustand ist.
processing_needed_for_row = s_value and s_value_upper not in s_end_states
# Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
log_check = (
i < start_sheet_row +
5) or (
i %
100 == 0) or (processing_needed_for_row)
if log_check:
self.logger.debug(
f"Zeile {i} (Wiki Update Check): Status S='{s_value}'. Benoetigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT
# Wenn die Verarbeitung fuer diese Zeile nicht noetig ist
if not processing_needed_for_row:
skipped_count += 1 # Zaehlen als uebersprungene Zeile
continue # Springe zur naechsten Zeile
# --- Wenn Verarbeitung noetig: Pruefe Vorschlag U und handle ---
# Zaehle die Zeile, die geprueft wird (im Rahmen des Limits
# zaehlen).
processed_rows_count += 1
# Pruefe das Limit fuer verarbeitete Zeilen
if limit is not None and isinstance(
limit, int) and limit > 0 and processed_rows_count > limit:
# Wenn das Limit erreicht ist und es ein positives Limit gibt
self.logger.info(
f"Verarbeitungslimit ({limit}) fuer process_wiki_updates_from_chatgpt erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
break # Brich die Schleife ab
# Holen Sie die Werte aus Spalte U (Chat Vorschlag Wiki Artikel)
# und M (Wiki URL) (nutzt interne Helfer _get_cell_value_safe)
vorschlag_u = self._get_cell_value_safe(
row, "Chat Vorschlag Wiki Artikel").strip() # Block 1 Column Map
url_m = self._get_cell_value_safe(
row, "Wiki URL").strip() # Block 1 Column Map
self.logger.info(
f"Zeile {i}: Pruefe Wiki-Vorschlag U='{vorschlag_u[:100]}...' (aktuell M='{url_m[:100]}...')...") # <<< GEÄNDERT
# Flag, ob U eine gueltige, neue URL ist, die uebernommen werden
# soll.
is_update_candidate = False
new_url = "" # Die URL, die ggf. in M kopiert wird.
# Kriterium 1: Ist Vorschlag U ueberhaupt ein String und sieht nach
# Wikipedia aus?
condition1_u_is_wiki_url = vorschlag_u and isinstance(
vorschlag_u,
str) and "wikipedia.org/wiki/" in vorschlag_u.lower() and vorschlag_u.lower().startswith(
("http://",
"https://")) # Check auf Schema hinzugefuegt
# Wenn der Vorschlag U wie eine Wikipedia-URL aussieht
if condition1_u_is_wiki_url:
new_url = vorschlag_u # Nehme den Vorschlag als potenzielle neue URL
# Kriterium 2: Unterscheidet sich der Vorschlag U von der aktuellen URL in M?
# Pruefe, ob die neue URL nicht identisch mit der aktuellen
# M-URL ist.
condition2_u_differs_m = new_url != url_m
# Wenn sich der Vorschlag U von der aktuellen M-URL
# unterscheidet
if condition2_u_differs_m:
self.logger.debug(
f" -> Vorschlag U ({new_url[:100]}...) unterscheidet sich von M ({url_m[:100]}). Pruefe Validitaet...")
try:
# Nutze die globale Funktion 'is_valid_wikipedia_article_url' (definiert in Block 12)
# Diese Funktion ist bereits mit @retry_on_failure
# dekoriert.
condition3_u_is_valid = is_valid_wikipedia_article_url(new_url, lang=getattr(
Config, 'LANG', 'de')) # lang Argument hinzugefügt für Konsistenz
if condition3_u_is_valid:
is_update_candidate = True
self.logger.debug(
f" -> URL '{new_url[:100]}...' ist ein VALIDER Artikel laut API Check.") # <<< GEÄNDERT
else:
# Wenn die vorgeschlagene URL nicht valide ist
self.logger.debug(
f" -> URL '{new_url[:100]}...' ist KEIN valider Artikel laut API Check.") # <<< GEÄNDERT
except Exception as e_validity_check:
# Wenn die Validierungsfunktion eine Exception wirft (nach Retries)
# Der Fehler wird bereits vom retry_on_failure
# Decorator geloggt.
self.logger.error(
f"FEHLER bei Validitaetspruefung von Vorschlag U '{new_url[:100]}...': {e_validity_check}") # <<< GEÄNDERT
# Bei Fehler bleibt is_update_candidate False.
pass # Faert fort
else:
# Wenn der Vorschlag U identisch mit der aktuellen M-URL
# ist
self.logger.debug(
f" -> Vorschlag U ist identisch mit URL M. Wird nicht uebernommen.") # <<< GEÄNDERT
else:
# Wenn der Vorschlag U nicht wie eine Wikipedia-URL aussieht
self.logger.debug(
f" -> Vorschlag U ('{vorschlag_u[:100]}...') ist keine Wikipedia URL. Wird nicht uebernommen.") # <<< GEÄNDERT
# --- Verarbeitung des Kandidaten ODER Loeschen des ungueltigen Vorschlags ---
updates_for_row = [] # Lokale Liste fuer Updates DIESER Zeile
if is_update_candidate:
# Fall 1: Gueltiges Update durchfuehren (Vorschlag U wird in M
# kopiert)
self.logger.info(
f"Zeile {i}: Update-Kandidat VALIDIERUNG ERFOLGREICH. Kopiere U->M, setze ReEval-Flag 'x', loesche abhaengige Spalten.") # <<< GEÄNDERT
updated_url_count += 1 # Zaehle die uebernommene URL
# Updates sammeln (M, S, U, N-V, AN, AO, AP, AX, AY, A) (nutzt
# interne Helfer _get_col_letter Block 14)
# Setze die neue URL in Spalte M (Block 1 Column Map)
updates_for_row.append(
{'range': f'{m_letter}{i}', 'values': [[new_url]]})
# Setze Status S auf "X (URL Copied)" (Block 1 Column Map)
updates_for_row.append(
{'range': f'{s_letter}{i}', 'values': [["X (URL Copied)"]]})
# Schreibe Info in Spalte U (Block 1 Column Map)
updates_for_row.append(
{'range': f'{u_letter}{i}', 'values': [["URL uebernommen"]]})
# Setze ReEval Flag (A) auf 'x' (Block 1 Column Map)
updates_for_row.append(
{'range': f'{a_letter}{i}', 'values': [["x"]]})
# Leere Spalten N-V.
# Fuege das Update zum Leeren des Bereichs V-Y hinzu, falls der
# Bereichsname ermittelt werden konnte.
if nv_range_letter: # Pruefe, ob der Bereichsname ermittelt werden konnte.
updates_for_row.append({'range': f'{n_letter}{i}:{v_letter}{i}', 'values': [
empty_nv_values]}) # Block 1 Column Map, lokale Variable
else:
self.logger.warning(
f"Konnte Spaltenbereich N-V ({n_letter}:{v_letter}) fuer Leerung nicht ermitteln fuer Zeile {i}. Leerung uebersprungen.") # <<< GEÄNDERT
# Leere Timestamps AN, AO, AP, AX, AY.
# Dies setzt die Zeile zurueck, damit andere Schritte sie
# spaeter bearbeiten.
# AN (Wiki Extraction TS) Block 1 Column Map
updates_for_row.append(
{'range': f'{an_letter}{i}', 'values': [['']]})
# AO (Chat Evaluation TS) Block 1 Column Map
updates_for_row.append(
{'range': f'{ao_letter}{i}', 'values': [['']]})
# AP (Version) Block 1 Column Map
updates_for_row.append(
{'range': f'{ap_letter}{i}', 'values': [['']]})
# AX (Wiki Verif. TS) Block 1 Column Map
updates_for_row.append(
{'range': f'{ax_letter}{i}', 'values': [['']]})
# AY (SerpAPI Wiki TS) Block 1 Column Map
updates_for_row.append(
{'range': f'{ay_letter}{i}', 'values': [['']]})
else:
# Fall 2: Ungueltigen Vorschlag loeschen/markieren
# Wenn der Vorschlag U nicht uebernommen wird (weil ungueltig
# oder identisch mit M).
self.logger.info(
f"Zeile {i}: Vorschlag U ('{vorschlag_u[:100]}...') ist ungueltig/identisch. Loesche U und setze Status S auf 'X (Invalid Suggestion)'.") # <<< GEÄNDERT
cleared_suggestion_count += 1 # Zaehle den bereinigten Vorschlag
# Updates sammeln (S, U) (nutzt interne Helfer _get_col_letter
# Block 14)
# Setze Status S auf "X (Invalid Suggestion)" (Block 1 Column
# Map)
updates_for_row.append(
{'range': f'{s_letter}{i}', 'values': [["X (Invalid Suggestion)"]]})
# Loesche den Vorschlag in Spalte U (Block 1 Column Map)
updates_for_row.append(
{'range': f'{u_letter}{i}', 'values': [[""]]})
# KEIN ReEval-Flag (A) setzen in diesem Fall.
# Sammle die Updates fuer diese Zeile in der globalen Liste
# all_sheet_updates.
all_sheet_updates.extend(updates_for_row)
# Sende gesammelte Sheet Updates wenn das Update-Batch-Limit erreicht ist.
# update_batch_row_limit wird aus Config geholt (Block 1).
# Die Anzahl der Updates pro Zeile variiert stark (ca. 2 bei ungueltigem Vorschlag, ca. 10+ bei gueltigem).
# Pruefen Sie einfach die Laenge der gesammelten Liste.
if len(all_sheet_updates) >= update_batch_row_limit * \
5: # Grobe Schaetzung: im Schnitt 5 Updates pro Zeile
self.logger.debug(
f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry.
# Wenn es fehlschlaegt, wird es intern geloggt.
success = self.sheet_handler.batch_update_cells(
all_sheet_updates)
if success:
self.logger.info(
f" Sheet-Update fuer {len(all_sheet_updates)} Zellen erfolgreich.") # <<< GEÄNDERT
# Der Fehlerfall wird von batch_update_cells geloggt
# Leere die gesammelten Updates nach dem Senden.
all_sheet_updates = []
# Kleine Pause nach jeder geprueften Zeile (nutzt Config Block 1).
# Dieser Modus macht API calls (ueber
# is_valid_wikipedia_article_url), also Pause einbauen.
pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.2
# self.logger.debug(f"Warte {pause_duration:.2f}s nach
# Pruefung...") # Zu viel Laerm im Debug
time.sleep(pause_duration)
# --- Finale Sheet Updates senden ---
# Sende alle verbleibenden gesammelten Updates in einem letzten
# Batch-Update.
if all_sheet_updates:
self.logger.info(
f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT
# Nutzt die batch_update_cells Methode des Sheet Handlers (Block
# 14) mit Retry.
success = self.sheet_handler.batch_update_cells(all_sheet_updates)
if success:
# <<< GEÄNDERT
self.logger.info(f"FINALES Sheet-Update erfolgreich.")
# Der Fehlerfall wird von batch_update_cells geloggt
# Logge den Abschluss des Modus
self.logger.info(
f"Modus 'wiki_updates_from_chatgpt' abgeschlossen. {processed_rows_count} Zeilen geprueft, {updated_url_count} URLs kopiert & fuer ReEval markiert, {cleared_suggestion_count} ungueltige Vorschlaege geloescht/markiert, {skipped_count} Zeilen uebersprungen.") # <<< GEÄNDERT
def process_wiki_reextract_missing_an(
self,
start_sheet_row=None,
end_sheet_row=None,
limit=None):
"""
Startet eine erneute Wiki-Extraktion für Zeilen mit URL aber ohne Timestamp.
"""
"""
Identifiziert Zeilen, bei denen eine Wiki URL (M) vorhanden ist, aber der
Wikipedia Timestamp (AN) fehlt. Fuehrt _process_single_row fuer diese Zeilen aus,
beschraenkt auf den 'wiki'-Schritt und mit force_reeval=True, um die Extraktion
erneut zu versuchen.
Args:
start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AN).
end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet).
limit (int, optional): Maximale Anzahl ZU VERARBEITENDER Zeilen. Defaults to None (Unbegrenzt).
"""
# Verwenden Sie logger, da das Logging jetzt konfiguriert ist
# Logge die Konfiguration des Modus
self.logger.info(
f"Starte Modus 'wiki_reextract_missing_an' (M gefuellt & AN leer). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") # <<< GEÄNDERT
# --- Daten laden und Startzeile ermitteln ---
# Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt.
# Dieser Modus sucht nach leeren AN mit gefuelltem M. Die automatische Startzeile
# basierend auf leeren AN ist ein guter Startpunkt.
if start_sheet_row is None:
self.logger.info(
"Automatische Ermittlung der Startzeile basierend auf leeren AN...") # <<< GEÄNDERT
# Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AN (Block 1 Column Map).
# Standardmaessig ab Zeile 7
start_data_index_no_header = self.sheet_handler.get_start_row_index(
check_column_key="Wikipedia Timestamp", min_sheet_row=7)
# Wenn get_start_row_index -1 zurueckgibt (Fehler)
if start_data_index_no_header == -1:
self.logger.error(
"FEHLER bei automatischer Ermittlung der Startzeile. Breche Modus ab.") # <<< GEÄNDERT
return # Beende die Methode
# Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten
# Daten-Index
start_sheet_row = start_data_index_no_header + \
self.sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut
self.logger.info(
f"Automatisch ermittelte Startzeile (erste leere AN Zelle): {start_sheet_row}") # <<< GEÄNDERT
else:
# Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein.
# Der load_data Aufruf ist mit retry_on_failure dekoriert (Block
# 2).
if not self.sheet_handler.load_data():
self.logger.error(
"Fehler beim Laden der Daten fuer wiki_reextract_missing_an.") # <<< GEÄNDERT
return # Beende die Methode, wenn das Laden fehlschlaegt
# Holen Sie die gesamte Datenliste (inklusive Header) aus dem
# SheetHandler.
all_data = self.sheet_handler.get_all_data_with_headers()
# Annahme: header_rows ist als Attribut im SheetHandler verfuegbar
# (Block 14).
header_rows = self.sheet_handler._header_rows
total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet
# Berechne Endzeile, wenn nicht manuell gesetzt
if end_sheet_row is None:
end_sheet_row = total_sheet_rows # Bis zur letzten Zeile
# Logge den verarbeitungsbereich
self.logger.info(
f"Suchbereich fuer M gefuellt & AN leer: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT
# Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht
# ueber Gesamtzeilen)
if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows:
self.logger.info(
"Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") # <<< GEÄNDERT
return # Beende die Methode, wenn der Bereich leer ist
# --- Indizes ---
# Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP
# (Block 1) vorhanden sind
required_keys = ["Wiki URL", "Wikipedia Timestamp",
"CRM Name"] # M, AN, B (Pruefkriterien + Logging)
# Erstellen Sie ein Dictionary mit Schluesseln und Indizes
col_indices = {key: COLUMN_MAP.get(key) for key in required_keys}
# Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden
# wurden
if None in col_indices.values():
missing = [k for k, v in col_indices.items() if v is None]
self.logger.critical(
f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer wiki_reextract_missing_an: {missing}. Breche ab.") # <<< GEÄNDERT
return # Beende die Methode bei kritischem Fehler
# Ermitteln Sie die Indizes
m_col_idx = col_indices["Wiki URL"]
an_col_idx = col_indices["Wikipedia Timestamp"]
# --- Verarbeitung ---
# Zaehlt Zeilen, die an _process_single_row uebergeben wurden (im
# Rahmen des Limits zaehlen).
processed_count = 0
skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden.
# Iteriere durch die Datenzeilen im definierten Bereich (1-basierte
# Sheet-Zeilennummer)
for i in range(start_sheet_row, end_sheet_row + 1):
row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste
# Pruefen Sie, ob das Ende des Sheets erreicht wurde
if row_index_in_list >= total_sheet_rows:
break # Ende des Sheets erreicht
row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile
# Stellen Sie sicher, dass die Zeile nicht leer ist
if not any(cell and isinstance(cell, str) and cell.strip()
for cell in row):
# self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).")
# # Zu viel Laerm im Debug
skipped_count += 1 # Zaehlen als uebersprungene Zeile
continue # Springe zur naechsten Zeile
# --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist ---
# Kriterium: Wiki URL (M) ist vorhanden und gueltig aussehend.
# UND Wikipedia Timestamp (AN) ist leer.
# Holen Sie die Werte aus den entsprechenden Spalten (nutzt interne
# Helfer _get_cell_value_safe)
m_value = self._get_cell_value_safe(
row, "Wiki URL").strip() # Block 1 Column Map
an_value = self._get_cell_value_safe(
row, "Wikipedia Timestamp").strip() # Block 1 Column Map
# Pruefen Sie, ob M gefuellt und gueltig aussieht.
is_m_valid_looking = m_value and isinstance(
m_value,
str) and "wikipedia.org/wiki/" in m_value.lower() and m_value.lower() not in [
"k.a.",
"kein artikel gefunden",
"fehler bei suche",
"http:"] # Fuege "http:" hinzu basierend auf Log
# Pruefen Sie, ob AN leer ist.
is_an_empty = not an_value
# Verarbeitung ist noetig, wenn M gueltig aussieht UND AN leer ist.
processing_needed_for_row = is_m_valid_looking and is_an_empty
# Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level
log_check = (
i < start_sheet_row +
5) or (
i %
100 == 0) or (processing_needed_for_row)
if log_check:
company_name = self._get_cell_value_safe(
row, "CRM Name").strip() # Block 1 Column Map
self.logger.debug(
f"Zeile {i} ({company_name[:50]}... Wiki Re-extract Check): M ('{m_value[:50]}...') gueltig? {is_m_valid_looking}, AN leer? {is_an_empty}. Benötigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT
# Wenn die Verarbeitung fuer diese Zeile nicht noetig ist
if not processing_needed_for_row:
skipped_count += 1 # Zaehlen als uebersprungene Zeile
continue # Springe zur naechsten Zeile
# --- Wenn Verarbeitung noetig: Rufe _process_single_row auf ---
# Zaehle die Zeile, die an _process_single_row uebergeben wird (im
# Rahmen des Limits zaehlen)
processed_count += 1
# Pruefe das Limit fuer verarbeitete Zeilen
if limit is not None and isinstance(
limit, int) and limit > 0 and processed_count > limit:
# Wenn das Limit erreicht ist und es ein positives Limit gibt
self.logger.info(
f"Verarbeitungslimit ({limit}) fuer wiki_reextract_missing_an erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT
break # Brich die Schleife ab
self.logger.info(
f"Zeile {i}: M gefuellt & AN leer. Versuche Wiki-Re-Extraktion ueber _process_single_row...") # <<< GEÄNDERT
try:
# RUFE _process_single_row AUF (Block 19).
# Mit steps_to_run={'wiki'} und force_reeval=True,
# damit nur der Wiki-Schritt ausgefuehrt wird und Timestamps ignoriert werden.
# Im Re-Extract Modus loeschen wir das 'x'-Flag NICHT
# automatisch.
self._process_single_row(
row_num_in_sheet=i,
row_data=row, # Uebergibt die aktuellen Rohdaten der Zeile
steps_to_run={'wiki'},
# <<< NUR der Wiki-Schritt soll laufen
force_reeval=True,
# <<< Erzwingt die Ausfuehrung des 'wiki' Schritts (ignoriert AN, S).
clear_x_flag=False # <<< 'x'-Flag wird in diesem Modus NICHT geloescht
)
# _process_single_row (Block 19) loggt intern den Abschluss und
# fuehrt das Sheet-Update durch.
except Exception as e_proc:
# Wenn _process_single_row einen Fehler wirft (nachdem interne Retries aufgaben),
# fangen wir ihn hier, loggen ihn und fahren mit der naechsten
# Zeile fort.
self.logger.exception(
f"FEHLER bei Verarbeitung von Zeile {i} in wiki_reextract_missing_an: {e_proc}") # <<< GEÄNDERT
# Hier koennen Sie z.B. einen Fehlerindikator in eine spezielle Spalte im Sheet schreiben lassen.
# Dieses Update muesste dann separat oder im naechsten Lauf
# behandelt werden.
# _process_single_row beinhaltet bereits eine kleine Pause am Ende.
# Hier ist keine zusaetzliche Pause noetig, wenn _process_single_row erfolgreich war.
# Wenn _process_single_row eine Exception wirft, kann hier eine kurze Pause sinnvoll sein.
# time.sleep(0.1) # Optional: Kurze Pause bei Fehler nach Exception
# Logge den Abschluss des Modus
self.logger.info(
f"Modus 'wiki_reextract_missing_an' abgeschlossen. {processed_count} Zeilen an _process_single_row uebergeben, {skipped_count} Zeilen uebersprungen.") # <<< GEÄNDERT