- 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.
6689 lines
354 KiB
Python
6689 lines
354 KiB
Python
#!/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
|