- soup.find(class_='content') # Oder
- # Füge ggf. weitere spezifische Selektoren hinzu, die du oft siehst - ) - - if content_area: - debug_print(f"Gezielten Inhaltsbereich gefunden ({content_area.name}#{content_area.get('id')} oder .{content_area.get('class')}) für {url}") - else: - # --- Fallback: Body nehmen, ABER Banner versuchen zu entfernen --- - debug_print(f"Kein spezifischer Inhaltsbereich gefunden für {url}. Nutze Body und versuche Banner zu entfernen.") - content_area = soup.find('body') - if content_area: - # Versuche, häufige Cookie-Banner Strukturen zu entfernen - banner_selectors = [ - '[id*="cookie"]', # IDs die "cookie" enthalten - '[class*="cookie"]', # Klassen die "cookie" enthalten - '[id*="consent"]', # IDs die "consent" enthalten - '[class*="consent"]', # Klassen die "consent" enthalten - '[id*="banner"]', # IDs die "banner" enthalten (vorsichtig!) - '[class*="banner"]', # Klassen die "banner" enthalten (vorsichtig!) - '[role="dialog"]' # Oft für Popups/Banner genutzt (vorsichtig!) - ] - banners_removed_count = 0 - for selector in banner_selectors: - try: - potential_banners = content_area.select(selector) - for banner in potential_banners: - # Zusätzliche Prüfung: Enthält das Element typischen Banner-Text? - banner_text = banner.get_text(" ", strip=True).lower() - keywords = ["cookie", "zustimm", "ablehnen", "einverstanden", "datenschutz", "privacy", "akzeptier"] - if any(keyword in banner_text for keyword in keywords): - debug_print(f"Entferne potenzielles Banner ({selector}) mit Text: {banner_text[:100]}...") - banner.decompose() # Entferne das Element aus dem Baum - banners_removed_count += 1 - except Exception as e_select: - debug_print(f"Fehler beim Versuch Banner mit Selektor '{selector}' zu entfernen: {e_select}") - if banners_removed_count > 0: - debug_print(f"{banners_removed_count} potenzielle Banner-Elemente entfernt.") - - # --- Text extrahieren aus gefundenem Bereich (oder Body) --- - if content_area: - for script_or_style in content_area(["script", "style"]): # Skripte/Styles entfernen - script_or_style.decompose() - - text = content_area.get_text(separator=' ', strip=True) - text = re.sub(r'\s+', ' ', text) # Normalisiere Whitespace - - # --- Zusätzliche Prüfung: Ist der extrahierte Text *nur* Banner-Text? --- - banner_keywords_strict = ["cookie", "zustimmen", "ablehnen", "einverstanden", "datenschutz", "privacy", "akzeptier", "einstellung", "partner", "analyse", "marketing"] - text_lower = text.lower() - keyword_hits = sum(1 for keyword in banner_keywords_strict if keyword in text_lower) - - # Heuristik: Wenn der Text kurz ist UND viele Banner-Keywords enthält -> Verwerfen - if len(text) < 500 and keyword_hits >= 3: - debug_print(f"WARNUNG: Extrahierter Text für {url} scheint nur Cookie-Banner zu sein (Länge {len(text)}, {keyword_hits} Keywords). Verwerfe Text.") - return "k.A. (Nur Cookie-Banner erkannt)" - - result = text[:max_length] - debug_print(f"Website {url} erfolgreich gescrapt. Extrahierter Text (Länge {len(result)}): {result[:100]}...") - return result - else: - debug_print(f"Kein oder spezifischer Inhaltsbereich gefunden in {url}") - return "k.A." - except requests.exceptions.SSLError as e: - debug_print(f"SSL-Fehler beim Abrufen der Website {url}: {e}. Versuche ohne Zertifikatsprüfung...") - if verify_cert: - return get_website_raw(url, max_length, verify_cert=False) - else: - return "k.A." - except requests.exceptions.RequestException as e: - debug_print(f"Netzwerk-/HTTP-Fehler beim Abrufen der Website {url}: {e}") - return "k.A." - except Exception as e: - debug_print(f"Allgemeiner Fehler beim Scraping von {url}: {e}") - return "k.A." - -# Die Hilfsfunktion summarize_batch_openai wird weiterhin benötigt -# (Code dafür bleibt wie in der Antwort von 16:24 Uhr) -@retry_on_failure -def summarize_batch_openai(tasks_data): - """ - Fasst eine Liste von Rohtexten in einem einzigen OpenAI API Call zusammen. - Die Prüfung auf das Token-Limit wird jetzt primär der API überlassen. - - Args: - tasks_data (list): Eine Liste von Dictionaries, jedes enthält: - {'row_num': int, 'raw_text': str} - - Returns: - dict: Ein Dictionary, das Zeilennummern auf ihre Zusammenfassungen mappt. - z.B. {2122: "Zusammenfassung A", 2123: "Zusammenfassung B"} - Bei Fehlern oder fehlenden Zusammenfassungen wird "k.A." verwendet. - """ - if not tasks_data: return {} - - # Filtere Tasks, die gültigen Text haben - valid_tasks = [t for t in tasks_data if t.get("raw_text") and t["raw_text"] not in ["k.A.", "k.A. (Nur Cookie-Banner erkannt)", "k.A. (Fehler)"] and str(t.get("raw_text")).strip()] - if not valid_tasks: - debug_print("Keine gültigen Rohtexte für Batch-Zusammenfassung gefunden.") - return {t['row_num']: "k.A. (Kein gültiger Rohtext)" for t in tasks_data} - - debug_print(f"Starte Batch-Zusammenfassung für {len(valid_tasks)} gültige Texte (Zeilen: {[t['row_num'] for t in valid_tasks]})...") - - # --- Aggregierten Prompt erstellen --- - prompt_parts = [ - "Du bist ein KI-Assistent...", # (Rest des Prompts wie gehabt) - "RESULTAT : ", - "\n--- Texte zur Zusammenfassung ---" - ] - text_block = "" - row_numbers_in_batch = [] # Zeilen, die tatsächlich im Prompt landen - - # Baue den Textblock ohne interne Längenprüfung zusammen - for task in valid_tasks: - row_num = task['row_num'] - raw_text = task['raw_text'] - # Kürzen sollte in get_website_raw passieren, aber zur Sicherheit: - raw_text = raw_text[:1500] # Limitiere jeden Text auf max 1500 Zeichen im Prompt - - entry_text = f"\n--- TEXT Zeile {row_num} ---\n{raw_text}\n--- ENDE TEXT Zeile {row_num} ---\n" - text_block += entry_text - row_numbers_in_batch.append(row_num) # Füge die Zeilennummer hinzu - - # --- Interne Längenprüfung ENTFERNT --- - # max_chars_per_batch = 15000 # Nicht mehr relevant für die Logik hier - # if total_chars + len(entry_text) > max_chars_per_batch: # ENTFERNT - # debug_print(f"WARNUNG: ...") # ENTFERNT - # continue # ENTFERNT - - if not row_numbers_in_batch: - # Sollte nur passieren, wenn valid_tasks leer war - debug_print("Keine Texte im Batch für OpenAI.") - return {t['row_num']: "k.A. (Validierungsfehler?)" for t in tasks_data} - - prompt_parts.append(text_block) - prompt_parts.append("--- Ende der Texte ---") - prompt_parts.append("Bitte gib NUR die 'RESULTAT : ...' Zeilen zurück.") - final_prompt = "\n".join(prompt_parts) - - # Optional: Token zählen zur Info, aber nicht zur Blockade - try: - prompt_tokens = token_count(final_prompt) - debug_print(f"Geschätzte Prompt-Tokens für Batch: {prompt_tokens} (Limit ca. 4096 für gpt-3.5-turbo)") - if prompt_tokens > 3500: # Nur eine Warnung - debug_print("WARNUNG: Geschätzte Prompt-Tokens hoch, API könnte Fehler werfen.") - except Exception as e_tc: - debug_print(f"Fehler beim Token-Zählen: {e_tc}") - - - # --- OpenAI API Call (Die API wirft Fehler bei Token-Limit) --- - chat_response = call_openai_chat(final_prompt, temperature=0.2) - - # --- Antwort parsen (wie gehabt) --- - summaries = {row_num: "k.A. (Keine Antwort geparst)" for row_num in row_numbers_in_batch} - if chat_response: - # ... (Parsing-Logik bleibt gleich) ... - lines = chat_response.strip().split('\n'); parsed_count = 0 - for line in lines: - match = re.match(r"RESULTAT (\d+): (.*)", line.strip()) - if match: - row_num = int(match.group(1)); summary_text = match.group(2).strip() - if row_num in summaries: summaries[row_num] = summary_text; parsed_count += 1 - debug_print(f"Batch-Zusammenfassung: {parsed_count} von {len(row_numbers_in_batch)} erfolgreich geparst.") - if parsed_count < len(row_numbers_in_batch): debug_print(f"WARNUNG: Nicht alle Zusammenfassungen geparst. Antwort: {chat_response[:500]}...") - else: - debug_print("Fehler: Keine gültige Antwort von OpenAI für Batch-Zusammenfassung erhalten.") - # Wenn der API Call fehlschlägt (z.B. Token Limit), ist chat_response None, - # alle summaries bleiben "k.A." - - # Füge k.A. für Tasks hinzu, die ungültigen Rohtext hatten (aus valid_tasks gefiltert) - for task in tasks_data: - if task['row_num'] not in summaries: - summaries[task['row_num']] = "k.A. (Ungültiger Rohtext o.ä.)" - - return summaries - - -# ==================== OPENAI / CHATGPT FUNCTIONS ==================== - -@retry_on_failure -def call_openai_chat(prompt, temperature=0.3, model=None): - """Zentrale Funktion für OpenAI Chat API Aufrufe.""" - if not Config.API_KEYS.get('openai'): - debug_print("Fehler: OpenAI API Key nicht konfiguriert.") - return None - if not prompt: - debug_print("Fehler: Leerer Prompt für OpenAI.") - return None - - current_model = model if model else Config.TOKEN_MODEL - - try: - # Token zählen vor dem Senden (optional, aber gut für Debugging) - # prompt_tokens = token_count(prompt) - # debug_print(f"Sende Prompt an OpenAI ({current_model}, {prompt_tokens} Tokens)...") - - response = openai.ChatCompletion.create( - model=current_model, - messages=[{"role": "user", "content": prompt}], - temperature=temperature - ) - result = response.choices[0].message.content.strip() - - # Token zählen für die Antwort - # completion_tokens = token_count(result) - # total_tokens = response.usage.total_tokens - # debug_print(f"OpenAI Antwort erhalten ({completion_tokens} Completion Tokens, {total_tokens} Gesamt).") - - return result - except openai.error.InvalidRequestError as e: - debug_print(f"OpenAI Invalid Request Error: {e}") - # Hier könnte man prüfen, ob es am Token Limit liegt - if "maximum context length" in str(e): - debug_print("Fehler scheint Token Limit zu sein. Prompt evtl. zu lang.") - # TODO: Strategie für zu lange Prompts (kürzen, splitten?) - return None - except openai.error.OpenAIError as e: # Fängt RateLimitError, APIError etc. ab - debug_print(f"OpenAI API Fehler: {e}") - raise # Fehler weitergeben, damit retry_on_failure greifen kann - except Exception as e: - debug_print(f"Allgemeiner Fehler bei OpenAI-Aufruf: {e}") - raise # Fehler weitergeben - -def summarize_website_content(raw_text): - """Erstellt Zusammenfassung von Website-Rohtext via OpenAI.""" - if not raw_text or raw_text == "k.A." or raw_text.strip() == "": - return "k.A." - - # Kürze den Rohtext, falls er sehr lang ist, um Token zu sparen/Limits zu vermeiden - max_raw_length = 3000 # Zeichenlimit für den Input der Zusammenfassung - if len(raw_text) > max_raw_length: - debug_print(f"Kürze Rohtext für Zusammenfassung von {len(raw_text)} auf {max_raw_length} Zeichen.") - raw_text = raw_text[:max_raw_length] - - prompt = ( - "Du bist ein KI-Assistent, der Webinhalte analysiert.\n" - "Fasse den folgenden Text einer Unternehmenswebsite prägnant zusammen. " - "Konzentriere dich auf:\n" - "- Haupttätigkeitsfeld des Unternehmens\n" - "- Wichtigste Produkte und/oder Dienstleistungen\n" - "- Zielgruppe (falls erkennbar)\n\n" - f"Website-Text:\n```\n{raw_text}\n```\n\n" - "Zusammenfassung (max. 100 Wörter):" - ) - summary = call_openai_chat(prompt, temperature=0.2) - return summary if summary else "k.A." - - -def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kategorien, website_summary): - """ - Ordnet das Unternehmen basierend auf den angegebenen Informationen exakt einer Branche - aus dem Ziel-Branchenschema (nur Kurzformen) zu. Validiert den ChatGPT-Vorschlag - strikt gegen die erlaubten Kurzformen und führt einen Fallback auf die (extrahierte) - CRM-Kurzform durch, falls der Vorschlag ungültig ist. - - Args: - crm_branche (str): Branche laut CRM (kann noch Präfix enthalten). - beschreibung (str): Unternehmensbeschreibung (CRM). - wiki_branche (str): Branche aus Wikipedia (falls vorhanden). - wiki_kategorien (str): Wikipedia-Kategorien. - website_summary (str): Zusammenfassung des Website-Inhalts. - - Returns: - dict: Enthält "branch" (die finale, gültige Kurzform oder Fehler), - "consistency" ('ok', 'X', 'fallback_crm_valid', 'fallback_invalid') und - "justification" (Begründung von ChatGPT oder Fallback-Info). - """ - # Globale Variablen für Schema und erlaubte Branches verwenden - global ALLOWED_TARGET_BRANCHES, TARGET_SCHEMA_STRING - - # Grundlegende Prüfung: Ist das Schema überhaupt geladen? - if not ALLOWED_TARGET_BRANCHES: - debug_print("FEHLER in evaluate_branche_chatgpt: Ziel-Branchenschema (ALLOWED_TARGET_BRANCHES) ist leer. Abbruch.") - # Gib den CRM-Wert zurück, aber markiere als Fehler - return {"branch": crm_branche, "consistency": "error_schema_missing", "justification": "Fehler: Ziel-Schema nicht geladen"} - - # Erstelle ein Set/Dict der erlaubten Branches in Kleinbuchstaben für effizientes Nachschlagen - # Speichert die Originalschreibweise als Wert. - allowed_branches_lookup = {b.lower(): b for b in ALLOWED_TARGET_BRANCHES} - - # --- Prompt für ChatGPT erstellen --- - # Beginne mit den Regeln und der Liste der gültigen Kurzformen - prompt_parts = [TARGET_SCHEMA_STRING] # TARGET_SCHEMA_STRING sollte bereits die klare Anweisung enthalten - prompt_parts.append("\nOrdne das Unternehmen anhand folgender Angaben exakt einer Branche des Ziel-Branchenschemas (Kurzformen) zu:") - - # Füge nur vorhandene Informationen hinzu und kürze sie ggf. - if crm_branche and crm_branche != "k.A.": prompt_parts.append(f"- CRM-Branche (Referenz): {crm_branche}") - if beschreibung and beschreibung != "k.A.": prompt_parts.append(f"- Beschreibung: {beschreibung[:500]}") # Kürzen - if wiki_branche and wiki_branche != "k.A.": prompt_parts.append(f"- Wikipedia-Branche: {wiki_branche}") - if wiki_kategorien and wiki_kategorien != "k.A.": prompt_parts.append(f"- Wikipedia-Kategorien: {wiki_kategorien[:500]}") # Kürzen - if website_summary and website_summary != "k.A.": prompt_parts.append(f"- Website-Zusammenfassung: {website_summary[:500]}") # Kürzen - - # Fallback, wenn gar keine spezifischen Infos da sind - if len(prompt_parts) <= 2: - debug_print("Warnung in evaluate_branche_chatgpt: Zu wenige Informationen für Branchenevaluierung.") - return {"branch": crm_branche, "consistency": "error_no_info", "justification": "Fehler: Zu wenige Informationen für eine Einschätzung"} - - # Füge die strengen Anweisungen für das Antwortformat hinzu - prompt_parts.append("\nWICHTIG: Antworte NUR mit dem exakten Kurznamen einer Branche aus der obigen Liste. Verwende KEINE Präfixe wie 'Hersteller / Produzenten >' oder 'Service provider (Dienstleister) >'.") - prompt_parts.append("\nAntworte ausschließlich im folgenden Format (keine Einleitung, kein Schlusssatz):") - prompt_parts.append("Branche: ") - prompt_parts.append("Übereinstimmung: ") - prompt_parts.append("Begründung: ") - - prompt = "\n".join(prompt_parts) - - # --- ChatGPT aufrufen --- - chat_response = call_openai_chat(prompt, temperature=0.0) # Niedrige Temperatur für konsistente Zuordnung - - if not chat_response: - debug_print("Fehler in evaluate_branche_chatgpt: Keine Antwort von OpenAI erhalten.") - return {"branch": crm_branche, "consistency": "error_api_no_response", "justification": "Fehler: Keine Antwort von API"} - - # --- Antwort parsen --- - lines = chat_response.strip().split("\n") - result = {"branch": None, "consistency": None, "justification": ""} # Initialisiere mit None - suggested_branch = "" - for line in lines: - line_lower = line.lower() - if line_lower.startswith("branche:"): - suggested_branch = line.split(":", 1)[1].strip() - # Entferne mögliche Anführungszeichen - suggested_branch = suggested_branch.strip('"\'') - elif line_lower.startswith("übereinstimmung:"): - # Wir überschreiben die Konsistenz später basierend auf unserer Logik - pass - elif line_lower.startswith("begründung:"): - result["justification"] = line.split(":", 1)[1].strip() - - if not suggested_branch: - debug_print(f"Fehler in evaluate_branche_chatgpt: Konnte 'Branche:' nicht aus Antwort parsen: {chat_response}") - # Optional: Versuche Begründung als Branche zu nehmen? Eher nicht. - return {"branch": crm_branche, "consistency": "error_parsing", "justification": f"Fehler: Parsing der API Antwort fehlgeschlagen. Antwort: {chat_response}"} - - # --- Validierung des ChatGPT-Vorschlags --- - final_branch = None - suggested_branch_lower = suggested_branch.lower() - - if suggested_branch_lower in allowed_branches_lookup: - final_branch = allowed_branches_lookup[suggested_branch_lower] # Nimm korrekte Schreibweise - debug_print(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist gültig ('{final_branch}').") - # Konsistenz wird später gesetzt - result["consistency"] = "pending_comparison" # Temporärer Status - else: - # --- Fallback-Logik --- - debug_print(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist NICHT im Ziel-Schema ({len(ALLOWED_TARGET_BRANCHES)} Einträge) enthalten. Starte Fallback...") - - # Versuche Kurzform aus CRM-Branche zu extrahieren - crm_short_branch = "k.A." - if crm_branche and ">" in crm_branche: - crm_short_branch = crm_branche.split(">", 1)[1].strip() - elif crm_branche and crm_branche != "k.A.": # Wenn CRM schon Kurzform sein könnte - crm_short_branch = crm_branche.strip() - - # Prüfe, ob die extrahierte CRM-Kurzform gültig ist - if crm_short_branch != "k.A." and crm_short_branch.lower() in allowed_branches_lookup: - final_branch = allowed_branches_lookup[crm_short_branch.lower()] # Nimm korrekte Schreibweise - result["consistency"] = "fallback_crm_valid" # Setze Fallback-Status - # Kombiniere ChatGPT Begründung (falls vorhanden) mit Fallback-Info - fallback_reason = f"Fallback: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}'). Gültige CRM-Kurzform '{final_branch}' verwendet." - result["justification"] = f"{fallback_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})" - debug_print(f"Fallback auf gültige CRM-Kurzform erfolgreich: '{final_branch}'") - else: - # Wenn auch CRM-Kurzform ungültig oder nicht extrahierbar - final_branch = suggested_branch # Behalte ungültigen Vorschlag - result["consistency"] = "fallback_invalid" # Setze Fehler-Fallback-Status - error_reason = f"Fehler: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}') und keine gültige CRM-Kurzform ('{crm_short_branch}') als Fallback verfügbar." - result["justification"] = f"{error_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})" - debug_print(f"Fallback fehlgeschlagen. Ungültiger Vorschlag: '{final_branch}', Ungültige CRM-Kurzform: '{crm_short_branch}'") - # Alternativ: Gib einen speziellen Fehlerwert zurück - # final_branch = "FEHLER - UNGÜLTIGE ZUWEISUNG" - - # Setze den finalen Branch im Ergebnis-Dictionary - result["branch"] = final_branch if final_branch else "FEHLER" - - # --- Konsistenzprüfung (Finale Bewertung) --- - # Extrahiere CRM-Kurzform für den Vergleich (erneut oder Variable von oben) - crm_short_to_compare = "k.A." - if crm_branche and ">" in crm_branche: - crm_short_to_compare = crm_branche.split(">", 1)[1].strip() - elif crm_branche and crm_branche != "k.A.": - crm_short_to_compare = crm_branche.strip() - - # Vergleiche finalen Branch (falls nicht FEHLER) mit CRM-Kurzform (case-insensitive) - if result["branch"] != "FEHLER" and result["branch"].lower() == crm_short_to_compare.lower(): - # Wenn sie übereinstimmen UND *kein* Fallback stattgefunden hat, ist es 'ok'. - if result["consistency"] == "pending_comparison": - result["consistency"] = "ok" - # Wenn Fallback auf gültige CRM stattfand (Status 'fallback_crm_valid'), bleibt dieser Status. - elif result["consistency"] == "pending_comparison": - # Wenn sie nicht übereinstimmen und kein Fallback stattfand, ist es 'X'. - result["consistency"] = "X" - # Wenn der Status bereits 'fallback_crm_valid' oder 'fallback_invalid' ist, bleibt er unverändert. - elif result["consistency"] is None: # Sollte nicht passieren, aber zur Sicherheit - result["consistency"] = "error_unknown_state" - - - # Entferne den temporären Status, falls er noch da ist - if result["consistency"] == "pending_comparison": - result["consistency"] = "error_comparison_failed" - - # Debug-Ausgabe des finalen Ergebnisses vor Rückgabe - debug_print(f"Finale Branch-Evaluation: {result}") - - return result - -# TODO: Weitere ChatGPT-Funktionen (evaluate_fsm_suitability, etc.) analog überarbeiten: -# - Prompts verbessern (klarere Anweisungen, Kontext nur bei Bedarf) -# - call_openai_chat verwenden -# - Parsing der Antworten robuster machen - -def process_wiki_verification(crm_data, wiki_data_str): - # Platzhalter - Implementierung anpassen oder entfernen, falls durch _process_batch abgedeckt - debug_print(f"TODO: process_wiki_verification aufrufen/implementieren für {crm_data}") - return "k.A. (Not Implemented)" - -def evaluate_fsm_suitability(company_name, company_data): - # Platzhalter - Implementierung anpassen - debug_print(f"TODO: evaluate_fsm_suitability aufrufen/implementieren für {company_name}") - return {"suitability": "k.A.", "justification": "Not Implemented"} - -def evaluate_servicetechnicians_estimate(company_name, company_data): - # Platzhalter - Implementierung anpassen - debug_print(f"TODO: evaluate_servicetechnicians_estimate aufrufen/implementieren für {company_name}") - return "k.A. (Not Implemented)" - -def map_internal_technicians(value): - # Platzhalter - Implementierung anpassen - debug_print(f"TODO: map_internal_technicians aufrufen/implementieren für {value}") - return "k.A. (Not Implemented)" - -def evaluate_servicetechnicians_explanation(company_name, st_estimate, company_data): - # Platzhalter - Implementierung anpassen - debug_print(f"TODO: evaluate_servicetechnicians_explanation aufrufen/implementieren für {company_name}") - return "k.A. (Not Implemented)" - -def process_employee_estimation(company_name, wiki_paragraph, crm_employee): - # Platzhalter - Implementierung anpassen - debug_print(f"TODO: process_employee_estimation aufrufen/implementieren für {company_name}") - return "k.A. (Not Implemented)" - -def process_employee_consistency(crm_employee, wiki_employee, emp_estimate): - # Platzhalter - Implementierung anpassen - debug_print(f"TODO: process_employee_consistency aufrufen/implementieren für {crm_employee} vs {wiki_employee} vs {emp_estimate}") - return "k.A. (Not Implemented)" - -def evaluate_umsatz_chatgpt(company_name, wiki_umsatz): - # Platzhalter - Implementierung anpassen - debug_print(f"TODO: evaluate_umsatz_chatgpt aufrufen/implementieren für {company_name}") - return "k.A. (Not Implemented)" - - -# ==================== BATCH PROCESSING FUNCTIONS ==================== +# ==================== BATCH PROCESSING HELPER (Global) ==================== +# Diese Funktion wird von DataProcessor.process_verification_batch aufgerufen. +# Sie kann global bleiben oder eine private Methode von DataProcessor werden. +# Wenn sie global bleibt, benötigt sie das sheet Objekt. def _process_batch(sheet, batches, row_numbers): """ - Hilfsfunktion für process_verification_only: Verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen. - Aktualisiert NUR die Spalten S bis Y. Zeitstempel (AN, AO, AP) werden von - der aufrufenden Funktion oder anderen Prozessen gesetzt. + Hilfsfunktion für process_verification_batch: Verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen. + Aktualisiert NUR die Spalten S bis U. Zeitstempel werden von der aufrufenden Funktion gesetzt. Args: sheet (gspread.Worksheet): Das Worksheet-Objekt zum Schreiben. - batches (list): Liste der Prompt-Teile für den Batch. - row_numbers (list): Liste der zugehörigen Zeilennummern. - """ - if not batches: - return - - # --- Prompt Erstellung (wie gehabt) --- - aggregated_prompt = ( - "Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln für Unternehmen. " - "Für jeden der folgenden Einträge prüfe, ob der vorhandene Wikipedia-Artikel (URL, Absatz, Kategorien) plausibel zum Firmennamen und zur Beschreibung passt. " - "Gib das Ergebnis für jeden Eintrag ausschließlich im folgenden Format auf einer neuen Zeile aus:\n" - "Eintrag : \n\n" - "Mögliche Antworten:\n" - "- 'OK' (wenn der Artikel gut passt)\n" - "- 'X | Alternativer Artikel: | Begründung: ' (wenn der Artikel nicht passt, aber ein besserer gefunden wurde)\n" - "- 'X | Kein passender Artikel gefunden | Begründung: ' (wenn der Artikel nicht passt und kein besserer gefunden wurde)\n" - "- 'Kein Wikipedia-Eintrag vorhanden.' (wenn initial keine URL angegeben wurde und keine Suche erfolgreich war)\n\n" - "Einträge:\n" - "----------\n" - ) - aggregated_prompt += "".join(batches) # Join ohne zusätzliches \n - aggregated_prompt += "----------\nBitte nur die 'Eintrag X: Antwort'-Zeilen ausgeben." - - debug_print(f"Verarbeite Verifizierungs-Batch für Zeilen {row_numbers[0]} bis {row_numbers[-1]} (nur S-Y)...") # Hinweis angepasst - - # Token Count für den Prompt - prompt_tokens = token_count(aggregated_prompt) - debug_print(f"Token-Zahl für Verifizierungs-Batch: {prompt_tokens}") - - # --- ChatGPT Aufruf (wie gehabt) --- - chat_response = call_openai_chat(aggregated_prompt, temperature=0.0) - - if not chat_response: - debug_print(f"Fehler: Keine Antwort von OpenAI für Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]}.") - return - - # --- Antwort parsen (wie gehabt) --- - answers = {} - lines = chat_response.strip().split('\n') - for line in lines: - match = re.match(r"Eintrag (\d+): (.*)", line.strip()) - if match: - row_num = int(match.group(1)) - answer_text = match.group(2).strip() - if row_num in row_numbers: - answers[row_num] = answer_text - # else: # Weniger Lärm im Log - # debug_print(f"Warnung: Antwort für unerwartete Zeilennummer {row_num} im Batch erhalten: {answer_text}") - - # --- Batch-Update vorbereiten (NUR S bis Y) --- - updates = [] - # Timestamps und Version werden HIER NICHT mehr hinzugefügt - - for row_num in row_numbers: - answer = answers.get(row_num, "k.A. (Keine Antwort im Batch)") # Fallback - - # Variablen für die Spalten S-Y initialisieren - wiki_confirm = "" # Spalte S - alt_article = "" # Spalte T - wiki_explanation = "" # Spalte U - v_val, w_val, x_val, y_val = "", "", "", "" # Spalten V-Y - - # Logik zur Bestimmung der Werte für S, T, U basierend auf 'answer' (wie gehabt) - if answer.upper() == "OK": - wiki_confirm = "OK" - elif answer.upper() == "KEIN WIKIPEDIA-EINTRAG VORHANDEN.": - wiki_confirm = "X" - alt_article = "Kein Wikipedia-Eintrag vorhanden." - wiki_explanation = "Ursprünglich keine URL oder Suche erfolglos." - elif answer.startswith("X |"): - parts = answer.split("|", 2) - wiki_confirm = "X" - if len(parts) > 1: - detail = parts[1].strip() - if detail.startswith("Alternativer Artikel:"): - alt_article = detail.split(":", 1)[1].strip() - elif detail == "Kein passender Artikel gefunden": - alt_article = detail - else: - alt_article = detail - if len(parts) > 2: - reason_part = parts[2].strip() - if reason_part.startswith("Begründung:"): - wiki_explanation = reason_part.split(":", 1)[1].strip() - else: - wiki_explanation = reason_part - else: # Unerwartetes Format - wiki_confirm = "?" - wiki_explanation = f"Unerwartetes Format: {answer}" - - # Füge Updates NUR für S-Y zur Liste hinzu - updates.append({'range': f'S{row_num}', 'values': [[wiki_confirm]]}) - updates.append({'range': f'T{row_num}', 'values': [[alt_article]]}) - updates.append({'range': f'U{row_num}', 'values': [[wiki_explanation]]}) - updates.append({'range': f'V{row_num}:Y{row_num}', 'values': [[v_val, w_val, x_val, y_val]]}) - - # --- Führe das Batch-Update für S-Y durch --- - if updates: - try: - # Verwende das übergebene sheet-Objekt direkt - sheet.batch_update(updates, value_input_option='USER_ENTERED') - debug_print(f"Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]} (S-Y) erfolgreich in Google Sheet aktualisiert.") - except Exception as e: - # Gib eine spezifischere Fehlermeldung aus - debug_print(f"FEHLER beim Batch-Update (S-Y) für Zeilen {row_numbers[0]}-{row_numbers[-1]}: {type(e).__name__} - {e}") - # Optional: Fehler weitergeben, wenn retry gewünscht wird (nicht empfohlen für Sheet-Updates hier) - # raise e - else: - debug_print(f"Keine Updates (S-Y) für Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]} generiert.") - - # KEINE Pause hier mehr, wird in der aufrufenden Funktion gemacht - # time.sleep(Config.RETRY_DELAY) - - -def process_verification_only(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet): - """ - Batch-Prozess nur für Wikipedia-Verifizierung (Spalten S-Y). - Lädt Daten neu, prüft für jede Zeile im Bereich, ob Timestamp AX (Wiki Verif.) - bereits gesetzt ist, und überspringt diese ggf. Setzt AX für bearbeitete Zeilen. - AN wird hier *nicht* mehr gesetzt, das muss ggf. _process_single_row tun. - """ - debug_print(f"Starte Wikipedia-Verifizierungsmodus (Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...") - - if not sheet_handler.load_data(): - debug_print("FEHLER beim Laden der Daten in process_verification_only.") - return - all_data = sheet_handler.get_all_data_with_headers() - if not all_data or len(all_data) <= 5: - debug_print("FEHLER/WARNUNG: Keine Daten zum Verarbeiten in process_verification_only gefunden.") - return - - # Hole Index für AX Timestamp (Wiki Verif.) - timestamp_col_key = "Wiki Verif. Timestamp" # NEUER SCHLÜSSEL - timestamp_col_index = COLUMN_MAP.get(timestamp_col_key) - ts_col_letter = sheet_handler._get_col_letter(timestamp_col_index + 1) if timestamp_col_index is not None else "AX_FEHLER" - - if timestamp_col_index is None: - debug_print(f"FEHLER: Spaltenschlüssel '{timestamp_col_key}' nicht in COLUMN_MAP gefunden. Breche Wiki-Verifizierung ab.") - return - - batch_size = Config.BATCH_SIZE - current_batch = [] - current_row_numbers = [] - processed_count = 0 - skipped_count = 0 - - for i in range(start_row_index_in_sheet, end_row_index_in_sheet + 1): - row_index_in_list = i - 1 - if row_index_in_list >= len(all_data): continue - row = all_data[row_index_in_list] - - # --- Timestamp-Prüfung für jede Zeile (AX) --- - ts_value_ax = "INDEX_FEHLER" - ts_ax_is_set = False - if len(row) > timestamp_col_index: - ts_value_ax = row[timestamp_col_index] - ts_ax_is_set = bool(str(ts_value_ax).strip()) - - log_debug = (i < start_row_index_in_sheet + 5 or i > end_row_index_in_sheet - 5 or i % 500 == 0 or i in range(2122, 2132)) - if log_debug: - debug_print(f"Zeile {i} (Wiki Verif. Check): Prüfe Timestamp {ts_col_letter} (Index {timestamp_col_index}). Rohwert='{ts_value_ax}', Strip='{str(ts_value_ax).strip()}', Überspringen? -> {ts_ax_is_set}") - - if ts_ax_is_set: - skipped_count += 1 - continue - # --- Ende Timestamp-Prüfung --- - - # Nur wenn AX leer ist, wird die Zeile für den Batch vorbereitet - company_name = row[COLUMN_MAP.get("CRM Name", 1)] if len(row) > COLUMN_MAP.get("CRM Name", 1) else '' - crm_desc = row[COLUMN_MAP.get("CRM Beschreibung", 5)] if len(row) > COLUMN_MAP.get("CRM Beschreibung", 5) else '' - wiki_url_idx = COLUMN_MAP.get("Wiki URL") - wiki_url = row[wiki_url_idx] if wiki_url_idx is not None and len(row) > wiki_url_idx and row[wiki_url_idx].strip() not in ['', 'k.A.'] else 'k.A.' - wiki_para_idx = COLUMN_MAP.get("Wiki Absatz") - wiki_paragraph = row[wiki_para_idx] if wiki_para_idx is not None and len(row) > wiki_para_idx else 'k.A.' - wiki_cat_idx = COLUMN_MAP.get("Wiki Kategorien") - wiki_categories = row[wiki_cat_idx] if wiki_cat_idx is not None and len(row) > wiki_cat_idx else 'k.A.' - - entry_text = ( - f"Eintrag {i}:\n" - f" Firmenname: {company_name}\n" - f" CRM-Beschreibung: {crm_desc[:200]}...\n" - f" Wikipedia-URL: {wiki_url}\n" - f" Wiki-Absatz: {wiki_paragraph[:200]}...\n" - f" Wiki-Kategorien: {wiki_categories[:200]}...\n" - f"----\n" - ) - current_batch.append(entry_text) - current_row_numbers.append(i) - processed_count += 1 - - if len(current_batch) >= batch_size or i == end_row_index_in_sheet: - if current_batch: - # Rufe _process_batch auf (schreibt S-Y) - _process_batch(sheet_handler.sheet, current_batch, current_row_numbers) - - # Setze den AX Timestamp für die bearbeiteten Zeilen - wiki_ts_updates = [] - current_wiki_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - for row_num in current_row_numbers: - wiki_ts_updates.append({'range': f'{ts_col_letter}{row_num}', 'values': [[current_wiki_timestamp]]}) - - if wiki_ts_updates: - success_ts = sheet_handler.batch_update_cells(wiki_ts_updates) - if success_ts: - debug_print(f"Wiki Verif. Timestamp {ts_col_letter} für Batch {current_row_numbers[0]}-{current_row_numbers[-1]} gesetzt.") - else: - debug_print(f"FEHLER beim Setzen des Wiki Verif. Timestamps {ts_col_letter} für Batch.") - - time.sleep(Config.RETRY_DELAY) - - current_batch = [] - current_row_numbers = [] - - debug_print(f"Wikipedia-Verifizierungs-Batch abgeschlossen. {processed_count} Zeilen zur Verarbeitung weitergegeben, {skipped_count} Zeilen übersprungen.") - - -# Anpassung in _process_batch: Setzt jetzt *nicht* mehr AO/AP, sondern nur S-Y -def _process_batch(sheet, batches, row_numbers): - """ - Hilfsfunktion für process_verification_only: Verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen. - Aktualisiert NUR die Spalten S bis Y. Zeitstempel werden von der aufrufenden Funktion gesetzt. + batches (list): Eine Liste von Strings, jeder ist der Prompt-Teil für eine Zeile. + row_numbers (list): Liste der zugehörigen Sheet-Zeilennummern (1-basiert). """ if not batches: return - # (Prompt Erstellung wie gehabt) + # --- Prompt Erstellung --- aggregated_prompt = ( "Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln für Unternehmen. " "Für jeden der folgenden Einträge prüfe, ob der vorhandene Wikipedia-Artikel (URL, Absatz, Kategorien) plausibel zum Firmennamen und zur Beschreibung passt. " @@ -3104,26 +1768,25 @@ def _process_batch(sheet, batches, row_numbers): "Mögliche Antworten:\n" "- 'OK' (wenn der Artikel gut passt)\n" "- 'X | Alternativer Artikel: | Begründung: ' (wenn der Artikel nicht passt, aber ein besserer gefunden wurde)\n" - "- 'X | Kein passender Artikel gefunden | Begründung: ' (wenn der Artikel nicht passt und kein besserer gefunden wurde)\n" - "- 'Kein Wikipedia-Eintrag vorhanden.' (wenn initial keine URL angegeben wurde und keine Suche erfolgreich war - dieser Fall sollte selten sein, da die Suche vorher stattfindet)\n\n" + "- 'X | Kein passender Artikel gefunden | Begründung: ' (wenn der Artikel nicht passt und kein besserer gefunden wurde)\n\n" "Einträge:\n" "----------\n" ) aggregated_prompt += "".join(batches) aggregated_prompt += "----------\nBitte nur die 'Eintrag X: Antwort'-Zeilen ausgeben." - debug_print(f"Verarbeite Verifizierungs-Batch für Zeilen {row_numbers[0]} bis {row_numbers[-1]}.") - prompt_tokens = token_count(aggregated_prompt) - debug_print(f"Token-Zahl für Verifizierungs-Batch: {prompt_tokens}") + logging.info(f"Verarbeite Verifizierungs-Batch für Zeilen {row_numbers[0]} bis {row_numbers[-1]} ({len(batches)} Einträge).") + prompt_tokens = token_count(aggregated_prompt) # Annahme: token_count ist global + logging.debug(f"Token-Zahl für Verifizierungs-Batch: {prompt_tokens}") - chat_response = call_openai_chat(aggregated_prompt, temperature=0.0) + chat_response = call_openai_chat(aggregated_prompt, temperature=0.0) # Annahme: call_openai_chat ist global mit Retry if not chat_response: - debug_print(f"Fehler: Keine Antwort von OpenAI für Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]}.") - return + logging.error(f"Fehler: Keine Antwort von OpenAI für Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]}.") + return # Batch Verarbeitung fehlgeschlagen - # Parse die aggregierte Antwort (wie gehabt) - answers = {} + # Parse die aggregierte Antwort + answers = {} # {row_num: answer_text} lines = chat_response.strip().split('\n') for line in lines: match = re.match(r"Eintrag (\d+): (.*)", line.strip()) @@ -3132,21 +1795,24 @@ def _process_batch(sheet, batches, row_numbers): answer_text = match.group(2).strip() if row_num in row_numbers: answers[row_num] = answer_text - # Bereite Batch-Update nur für Spalten S-Y vor + # Bereite Batch-Update für Spalten S-U vor updates = [] - # current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Nicht mehr hier - # current_version = Config.VERSION # Nicht mehr hier + # Benötigte Spaltenindizes (Annahme: COLUMN_MAP ist global) + s_idx = COLUMN_MAP.get("Chat Wiki Konsistenzprüfung"); u_idx = COLUMN_MAP.get("Chat Vorschlag Wiki Artikel"); t_idx = COLUMN_MAP.get("Chat Begründung Wiki Inkonsistenz") + if None in [s_idx, u_idx, t_idx]: logging.error("FEHLER: Spaltenindizes für S, T, U fehlen in COLUMN_MAP."); return # Kann nicht schreiben + + # Konvertiere Indizes in Buchstaben + s_l = GoogleSheetHandler()._get_col_letter(s_idx + 1) # Temporäre Nutzung der Methode, da GoogleSheetHandler global nicht direkt zugänglich + t_l = GoogleSheetHandler()._get_col_letter(t_idx + 1) + u_l = GoogleSheetHandler()._get_col_letter(u_idx + 1) + for row_num in row_numbers: - answer = answers.get(row_num, "k.A. (Keine Antwort im Batch)") - # debug_print(f"Zeile {row_num} Verifizierungsantwort: '{answer}'") # Optional weniger Lärm + answer = answers.get(row_num, "k.A. (Keine Antwort im Batch)") # Fallback - wiki_confirm, alt_article, wiki_explanation = "", "", "" - v_val, w_val, x_val, y_val = "", "", "", "" + wiki_confirm = ""; alt_article = ""; wiki_explanation = "" if answer.upper() == "OK": wiki_confirm = "OK" - elif answer.upper() == "KEIN WIKIPEDIA-EINTRAG VORHANDEN.": - wiki_confirm, alt_article, wiki_explanation = "X", "Kein Wikipedia-Eintrag vorhanden.", "Ursprünglich keine URL oder Suche erfolglos." elif answer.startswith("X |"): parts = answer.split("|", 2) wiki_confirm = "X" @@ -3154,863 +1820,51 @@ def _process_batch(sheet, batches, row_numbers): detail = parts[1].strip() if detail.startswith("Alternativer Artikel:"): alt_article = detail.split(":", 1)[1].strip() elif detail == "Kein passender Artikel gefunden": alt_article = detail - else: alt_article = detail + else: alt_article = detail # Unerwartetes Format im Detail if len(parts) > 2: reason_part = parts[2].strip() if reason_part.startswith("Begründung:"): wiki_explanation = reason_part.split(":", 1)[1].strip() - else: wiki_explanation = reason_part - else: - wiki_confirm, wiki_explanation = "?", f"Unerwartetes Format: {answer}" + else: wiki_explanation = reason_part # Unerwartetes Format in Begründung + else: # Unerwartetes Format oder "Kein Wikipedia-Eintrag vorhanden." (sollte durch Suche vorher abgefangen sein) + wiki_confirm, wiki_explanation = "?", f"Unerwartetes Format: {answer[:100]}" + alt_article = "" - # Füge Updates für S-Y hinzu - updates.append({'range': f'S{row_num}', 'values': [[wiki_confirm]]}) - updates.append({'range': f'T{row_num}', 'values': [[alt_article]]}) - updates.append({'range': f'U{row_num}', 'values': [[wiki_explanation]]}) - updates.append({'range': f'V{row_num}:Y{row_num}', 'values': [[v_val, w_val, x_val, y_val]]}) + # Füge Updates für S, T, U hinzu (basierend auf Spaltenbeschreibung: S=Konstistenz, T=Begründung, U=Vorschlag) + updates.append({'range': f'{s_l}{row_num}', 'values': [[wiki_confirm]]}) + updates.append({'range': f'{t_l}{row_num}', 'values': [[wiki_explanation]]}) # T ist Begründung + updates.append({'range': f'{u_l}{row_num}', 'values': [[alt_article]]}) # U ist Vorschlag - # Führe das Batch-Update für S-Y durch + # Führe das Batch-Update für S-U durch if updates: - # Direkten Sheet-Zugriff nutzen, da sheet übergeben wird + # sheet wird übergeben, nutze es direkt try: - sheet.batch_update(updates) - debug_print(f"Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]} (S-Y) erfolgreich in Google Sheet aktualisiert.") + sheet.batch_update(updates, value_input_option='USER_ENTERED') + logging.info(f"Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]} (S-U) erfolgreich in Google Sheet aktualisiert.") except Exception as e: - debug_print(f"FEHLER beim Batch-Update (S-Y) für Batch {row_numbers[0]}-{row_numbers[-1]}: {e}") - else: - debug_print(f"Keine Updates (S-Y) für Verifizierungs-Batch {row_numbers[0]}-{row_numbers[-1]} generiert.") + logging.error(f"FEHLER beim Batch-Update (S-U) für Batch {row_numbers[0]}-{row_numbers[-1]}: {e}") + # Hier sollte der Fehler für Retry an den Aufrufer (process_verification_batch) weitergegeben werden. + # Da _process_batch als globaler Helfer konzipiert ist, werfen wir den Fehler hier nicht direkt. + # Die retry-Logik muss in process_verification_batch um den Aufruf von _process_batch herum sein. + # Oder _process_batch wird eine Methode und nutzt den @retry_on_failure Decorator. + # Aktuell ist es ein globaler Helfer. Lassen wir es so, der Aufrufer muss retryen. + pass # Nicht reraisen, um andere Batches nicht zu blockieren - # Kurze Pause nach jedem Batch-API-Call (jetzt in der aufrufenden Funktion) - # time.sleep(Config.RETRY_DELAY) # Entfernt - -# Komplette Funktion process_website_batch (prüft jetzt Timestamp AT mit erzwungenem Debugging) -# Komplette Funktion process_website_batch (MIT Batched Google Sheet Updates) -# Komplette Funktion process_website_batch (NEUE STRUKTUR - ECHTER BATCH WORKFLOW) -# Komplette Funktion process_website_batch (NUR SCRAPING) -# Komplette Funktion process_website_batch (Korrigierte Config-Referenzen) -def process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet): - """ - Batch-Prozess NUR für Website-Scraping (Rohtext AR). - Lädt Daten neu, prüft Spalte AR auf Inhalt ('', 'k.A.', etc.) und überspringt Zeilen mit Inhalt. - Setzt AR + AP für bearbeitete Zeilen. Sendet Updates gebündelt. - """ - debug_print(f"Starte Website-Scraping NUR ROHDATEN (Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...") - - # --- Lade Daten --- - if not sheet_handler.load_data(): return - all_data = sheet_handler.get_all_data_with_headers() - if not all_data or len(all_data) <= 5: return - header_rows = 5 - - # --- Indizes und Buchstaben --- - rohtext_col_key = "Website Rohtext" - rohtext_col_index = COLUMN_MAP.get(rohtext_col_key) - website_col_idx = COLUMN_MAP.get("CRM Website") - version_col_idx = COLUMN_MAP.get("Version") - if None in [rohtext_col_index, website_col_idx, version_col_idx]: - debug_print(f"FEHLER: Benötigte Indizes für process_website_batch fehlen.") - return - rohtext_col_letter = sheet_handler._get_col_letter(rohtext_col_index + 1) - version_col_letter = sheet_handler._get_col_letter(version_col_idx + 1) - - # --- Worker-Funktion für Scraping (unverändert) --- - def scrape_raw_text_task(task_info): - row_num = task_info['row_num']; url = task_info['url']; raw_text = "k.A."; error = None - try: raw_text = get_website_raw(url) # Annahme: get_website_raw ist definiert - except Exception as e: error = f"Scraping Fehler Zeile {row_num}: {e}"; debug_print(error) - return {"row_num": row_num, "raw_text": raw_text, "error": error} - - # --- Hauptlogik: Iteriere und sammle Batches --- - tasks_for_processing_batch = [] - all_sheet_updates = [] - total_processed_count = 0 - total_skipped_count = 0 - total_skipped_url_count = 0 - total_error_count = 0 - - # Werte aus Config holen - processing_batch_size = Config.PROCESSING_BATCH_SIZE - max_scraping_workers = Config.MAX_SCRAPING_WORKERS - update_batch_row_limit = Config.UPDATE_BATCH_ROW_LIMIT - empty_values_for_skip = ["", "k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"] - - for i in range(start_row_index_in_sheet, end_row_index_in_sheet + 1): - row_index_in_list = i - 1 - if row_index_in_list >= len(all_data): continue - row = all_data[row_index_in_list] - - # --- Prüfung, ob AR schon Inhalt hat --- - should_skip = False - cell_value_ar_str_lower = "INDEX_FEHLER" - if len(row) > rohtext_col_index: - cell_value_ar_str_lower = str(row[rohtext_col_index]).strip().lower() - if cell_value_ar_str_lower not in empty_values_for_skip: - should_skip = True - - log_debug = (i < start_row_index_in_sheet + 5 or i > end_row_index_in_sheet - 5 or i % 500 == 0) - if log_debug: - debug_print(f"Zeile {i} (Website AR Check): Prüfe Inhalt Spalte {rohtext_col_letter}. Wert='{cell_value_ar_str_lower}'. Überspringen (da schon Inhalt)? -> {should_skip}") - - if should_skip: - total_skipped_count += 1 - continue - # --- Ende AR Prüfung --- - - # URL Prüfung - website_url = row[website_col_idx] if len(row) > website_col_idx else "" - if not website_url or website_url.strip().lower() == "k.a.": - total_skipped_url_count += 1 - continue - - tasks_for_processing_batch.append({"row_num": i, "url": website_url}) - - # --- Verarbeitungs-Batch ausführen --- - if len(tasks_for_processing_batch) >= processing_batch_size or i == end_row_index_in_sheet: - if tasks_for_processing_batch: - batch_start_row = tasks_for_processing_batch[0]['row_num'] - batch_end_row = tasks_for_processing_batch[-1]['row_num'] - batch_task_count = len(tasks_for_processing_batch) - debug_print(f"\n--- Starte Scraping-Batch ({batch_task_count} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") - - scraping_results = {} - batch_error_count = 0 # Fehlerzähler für diesen spezifischen Batch - debug_print(f" Scrape {batch_task_count} Websites parallel (max {max_scraping_workers} worker)...") - with concurrent.futures.ThreadPoolExecutor(max_workers=max_scraping_workers) as executor: - future_to_task = {executor.submit(scrape_raw_text_task, task): task for task in tasks_for_processing_batch} - for future in concurrent.futures.as_completed(future_to_task): - task = future_to_task[future] - # --- KORRIGIERTER TRY-EXCEPT Block --- - try: - result = future.result() - scraping_results[result['row_num']] = result['raw_text'] - if result['error']: - batch_error_count += 1 - total_error_count += 1 - except Exception as exc: - row_num = task['row_num'] - err_msg = f"Generischer Fehler Scraping Task Zeile {row_num}: {exc}" - debug_print(err_msg) - scraping_results[row_num] = "k.A. (Fehler)" - batch_error_count += 1 - total_error_count +=1 - # --- Ende Korrektur --- - - current_batch_processed_count = len(scraping_results) # Anzahl Ergebnisse (inkl. Fehler) - total_processed_count += current_batch_processed_count - debug_print(f" Scraping für Batch beendet. {current_batch_processed_count} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).") - - # Sheet Updates vorbereiten (AR und AP) - if scraping_results: - current_version = Config.VERSION - batch_sheet_updates = [] - for row_num, raw_text_res in scraping_results.items(): - # Updates für AR und AP - row_updates = [ - {'range': f'{rohtext_col_letter}{row_num}', 'values': [[raw_text_res]]}, - {'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]} - ] - batch_sheet_updates.extend(row_updates) - all_sheet_updates.extend(batch_sheet_updates) # Sammle für größeren Batch-Update - - tasks_for_processing_batch = [] # Batch leeren - - # Sheet Updates senden (wenn update_batch_row_limit erreicht) - # Prüfe die Anzahl der *Zeilen*, für die Updates gesammelt wurden - # Da wir jetzt Updates für alle Ergebnisse sammeln, prüfen wir direkt die Länge von all_sheet_updates - if len(all_sheet_updates) >= update_batch_row_limit * 2: # *2 weil 2 Updates pro Zeile - debug_print(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") - success = sheet_handler.batch_update_cells(all_sheet_updates) - if success: debug_print(f" Sheet-Update bis Zeile {batch_end_row} erfolgreich.") # Logge Endzeile des Batches - else: debug_print(f" FEHLER beim Sheet-Update bis Zeile {batch_end_row}.") - all_sheet_updates = [] # Zurücksetzen nach Senden - - # Finale Sheet Updates senden - if all_sheet_updates: - debug_print(f"Sende finale Sheet-Updates ({len(all_sheet_updates)} Zellen)...") - sheet_handler.batch_update_cells(all_sheet_updates) - - debug_print(f"Website-Scraping NUR ROHDATEN abgeschlossen. {total_processed_count} Websites verarbeitet (inkl. Fehler), {total_error_count} Fehler, {total_skipped_count} Zeilen wg. Inhalt übersprungen, {total_skipped_url_count} Zeilen ohne URL übersprungen.") - - - -# NEUE Funktion process_website_summarization_batch -def process_website_summarization_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet): - """ - Batch-Prozess NUR für Website-Zusammenfassung (AS). - Lädt Daten neu, prüft, ob Rohtext (AR) vorhanden und Zusammenfassung (AS) fehlt. - Fasst Rohtexte im Batch via OpenAI zusammen und setzt AS + AP. - """ - debug_print(f"Starte Website-Zusammenfassung (OpenAI Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...") - - # --- Konfiguration --- - openai_batch_size = Config.OPENAI_BATCH_SIZE_LIMIT # Holt Wert aus Config (jetzt z.B. 1) - update_batch_row_limit = Config.UPDATE_BATCH_ROW_LIMIT # z.B. 50 - - # --- Lade Daten --- - if not sheet_handler.load_data(): return - all_data = sheet_handler.get_all_data_with_headers() - if not all_data or len(all_data) <= 5: return - header_rows = 5 - - # --- Indizes und Buchstaben --- - rohtext_col_idx = COLUMN_MAP.get("Website Rohtext") - summary_col_idx = COLUMN_MAP.get("Website Zusammenfassung") - version_col_idx = COLUMN_MAP.get("Version") - if None in [rohtext_col_idx, summary_col_idx, version_col_idx]: return debug_print(f"FEHLER: Indizes fehlen.") - summary_col_letter = sheet_handler._get_col_letter(summary_col_idx + 1) - version_col_letter = sheet_handler._get_col_letter(version_col_idx + 1) - - # --- Verarbeitung --- - tasks_for_openai_batch = [] - all_sheet_updates = [] - rows_in_current_update_batch = 0 - processed_count = 0 - skipped_no_rohtext = 0 - skipped_summary_exists = 0 - - for i in range(start_row_index_in_sheet, end_row_index_in_sheet + 1): - row_index_in_list = i - 1 - if row_index_in_list >= len(all_data): continue - row = all_data[row_index_in_list] - - # Prüfung 1: Ist Rohtext vorhanden und gültig? - raw_text = "" - if len(row) > rohtext_col_idx: raw_text = str(row[rohtext_col_idx]).strip() - if not raw_text or raw_text == "k.A." or raw_text == "k.A. (Nur Cookie-Banner erkannt)" or raw_text == "k.A. (Fehler)": - skipped_no_rohtext += 1; continue - - # Prüfung 2: Fehlt die Zusammenfassung (AS)? - summary_exists = False - if len(row) > summary_col_idx and str(row[summary_col_idx]).strip() and str(row[summary_col_idx]).strip() != "k.A.": - summary_exists = True - if summary_exists: skipped_summary_exists += 1; continue - - # Task hinzufügen - tasks_for_openai_batch.append({'row_num': i, 'raw_text': raw_text}) - processed_count += 1 - - # --- OpenAI Batch verarbeiten, wenn voll oder letzte Zeile --- - if tasks_for_openai_batch and \ - (len(tasks_for_openai_batch) >= openai_batch_size or (processed_count > 0 and i == end_row_index_in_sheet)): - debug_print(f" Verarbeite OpenAI Batch für {len(tasks_for_openai_batch)} Aufgaben (Start: {tasks_for_openai_batch[0]['row_num']})...") - summaries_result = summarize_batch_openai(tasks_for_openai_batch) # Ruft modifizierte Funktion auf - - # Sheet Updates für diesen OpenAI Batch vorbereiten - current_version = Config.VERSION - for task in tasks_for_openai_batch: # Iteriere über die *gesendeten* Tasks - row_num = task['row_num'] - summary = summaries_result.get(row_num, "k.A. (Fehler Batch Zuordnung)") - row_updates = [ - {'range': f'{summary_col_letter}{row_num}', 'values': [[summary]]}, - {'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]} - ] - all_sheet_updates.extend(row_updates) - rows_in_current_update_batch += 1 - - tasks_for_openai_batch = [] # OpenAI Batch leeren - - # --- Gesammelte Sheet Updates senden --- - if all_sheet_updates and \ - (rows_in_current_update_batch >= update_batch_row_limit or (processed_count > 0 and i == end_row_index_in_sheet)): - debug_print(f" Sende Sheet-Update für {rows_in_current_update_batch} Zusammenfassungen...") - success = sheet_handler.batch_update_cells(all_sheet_updates) - if success: debug_print(f" Sheet-Update bis Zeile {i} erfolgreich.") - else: debug_print(f" FEHLER beim Sheet-Update bis Zeile {i}.") - all_sheet_updates = []; rows_in_current_update_batch = 0 - - # Letzten Sheet Update Batch senden - if all_sheet_updates: - debug_print(f"Sende LETZTES Sheet-Update für {rows_in_current_update_batch} Zusammenfassungen...") - sheet_handler.batch_update_cells(all_sheet_updates) - - debug_print(f"Website-Zusammenfassungs-Batch abgeschlossen. {processed_count} Zusammenfassungen angefordert, {skipped_no_rohtext} wg. fehlendem Rohtext übersprungen, {skipped_summary_exists} wg. vorhandener Zusammenfassung übersprungen.") - -# Komplette Funktion process_branch_batch (prüft jetzt Timestamp AO mit erzwungenem Debugging) -# Komplette Funktion process_branch_batch (MIT Korrektur und Prüfung auf AO) -def process_branch_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet): - """ - Batch-Prozess für Brancheneinschätzung mit paralleler Verarbeitung via Threads. - Prüft Timestamp AO, führt evaluate_branche_chatgpt parallel aus (limitiert), - setzt W, X, Y, AO + AP und sendet Sheet-Updates GEBÜNDELT PRO VERARBEITUNGS-BATCH. - """ - debug_print(f"Starte Brancheneinschätzung (Parallel Batch) für Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}...") - - if not sheet_handler.load_data(): return - all_data = sheet_handler.get_all_data_with_headers() - if not all_data or len(all_data) <= 5: return - header_rows = 5 - - # --- Indizes und Buchstaben --- - timestamp_col_key = "Timestamp letzte Prüfung"; timestamp_col_index = COLUMN_MAP.get(timestamp_col_key) - branche_crm_idx = COLUMN_MAP.get("CRM Branche"); beschreibung_idx = COLUMN_MAP.get("CRM Beschreibung") - branche_wiki_idx = COLUMN_MAP.get("Wiki Branche"); kategorien_wiki_idx = COLUMN_MAP.get("Wiki Kategorien") - summary_web_idx = COLUMN_MAP.get("Website Zusammenfassung"); version_col_idx = COLUMN_MAP.get("Version") - branch_w_idx = COLUMN_MAP.get("Chat Vorschlag Branche"); branch_x_idx = COLUMN_MAP.get("Chat Konsistenz Branche") - branch_y_idx = COLUMN_MAP.get("Chat Begründung Abweichung Branche") - required_indices = [timestamp_col_index, branche_crm_idx, beschreibung_idx, branche_wiki_idx, kategorien_wiki_idx, summary_web_idx, version_col_idx, branch_w_idx, branch_x_idx, branch_y_idx] - if None in required_indices: return debug_print(f"FEHLER: Indizes fehlen.") - ts_col_letter = sheet_handler._get_col_letter(timestamp_col_index + 1) - version_col_letter = sheet_handler._get_col_letter(version_col_idx + 1) - branch_w_letter = sheet_handler._get_col_letter(branch_w_idx + 1) - branch_x_letter = sheet_handler._get_col_letter(branch_x_idx + 1) - branch_y_letter = sheet_handler._get_col_letter(branch_y_idx + 1) - - # --- Konfiguration --- - MAX_BRANCH_WORKERS = Config.MAX_BRANCH_WORKERS - OPENAI_CONCURRENCY_LIMIT = Config.OPENAI_CONCURRENCY_LIMIT - openai_semaphore_branch = threading.Semaphore(OPENAI_CONCURRENCY_LIMIT) - PROCESSING_BRANCH_BATCH_SIZE = Config.PROCESSING_BRANCH_BATCH_SIZE - update_batch_row_limit = Config.UPDATE_BATCH_ROW_LIMIT # Wird derzeit nicht verwendet, da wir pro Batch senden - - # --- Worker Funktion --- - def evaluate_branch_task(task_data): - 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_branch: - # debug_print(f" Task {row_num}: Warte auf Semaphore...") # Sehr detailliertes Logging - # time.sleep(0.1) # Minimale Pause reduziert manchmal Race Conditions bei hoher Last - # debug_print(f" Task {row_num}: Semaphore erhalten, starte evaluate_branche_chatgpt...") - result = evaluate_branche_chatgpt( task_data['crm_branche'], task_data['beschreibung'], task_data['wiki_branche'], task_data['wiki_kategorien'], task_data['website_summary']) - # debug_print(f" Task {row_num}: evaluate_branche_chatgpt beendet.") - except Exception as e: - error = f"Fehler bei Branchenevaluation Zeile {row_num}: {e}"; debug_print(error); result['justification'] = error[:500]; result['consistency'] = 'error_task' - return {"row_num": row_num, "result": result, "error": error} - - # --- Hauptverarbeitung --- - tasks_for_processing_batch = [] - total_processed_count = 0; total_skipped_count = 0; total_error_count = 0 - - if not ALLOWED_TARGET_BRANCHES: load_target_schema(); - if not ALLOWED_TARGET_BRANCHES: return debug_print("FEHLER: Ziel-Schema nicht geladen.") - - for i in range(start_row_index_in_sheet, end_row_index_in_sheet + 1): - row_index_in_list = i - 1 - if row_index_in_list >= len(all_data): continue - row = all_data[row_index_in_list] - - # Timestamp-Prüfung (AO) - should_skip = False - if len(row) > timestamp_col_index and str(row[timestamp_col_index]).strip(): should_skip = True - if should_skip: total_skipped_count += 1; continue - - # Task sammeln - task_data = { "row_num": i, "crm_branche": row[branche_crm_idx] if len(row) > branche_crm_idx else "", "beschreibung": row[beschreibung_idx] if len(row) > beschreibung_idx else "", "wiki_branche": row[branche_wiki_idx] if len(row) > branche_wiki_idx else "", "wiki_kategorien": row[kategorien_wiki_idx] if len(row) > kategorien_wiki_idx else "", "website_summary": row[summary_web_idx] if len(row) > summary_web_idx else ""} - tasks_for_processing_batch.append(task_data) - - # --- Verarbeitungs-Batch ausführen --- - if len(tasks_for_processing_batch) >= PROCESSING_BRANCH_BATCH_SIZE or i == end_row_index_in_sheet: - if tasks_for_processing_batch: - batch_start_row = tasks_for_processing_batch[0]['row_num']; batch_end_row = tasks_for_processing_batch[-1]['row_num'] - batch_task_count = len(tasks_for_processing_batch) - debug_print(f"\n--- Starte Branch-Evaluation Batch ({batch_task_count} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") - - results_list = []; batch_error_count = 0 - debug_print(f" Evaluiere {batch_task_count} Zeilen parallel (max {MAX_BRANCH_WORKERS} worker, {OPENAI_CONCURRENCY_LIMIT} OpenAI gleichzeitig)...") - # *** BEGINN PARALLELE VERARBEITUNG *** - with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_BRANCH_WORKERS) as executor: - future_to_task = {executor.submit(evaluate_branch_task, task): task for task in tasks_for_processing_batch} - # Warte auf Ergebnisse und sammle sie - for future in concurrent.futures.as_completed(future_to_task): - task = future_to_task[future] - try: result_data = future.result(); results_list.append(result_data); - except Exception as exc: - row_num = task['row_num']; err_msg = f"Generischer Fehler Branch Task Zeile {row_num}: {exc}"; debug_print(err_msg) - results_list.append({"row_num": row_num, "result": {"branch": "FEHLER", "consistency": "error_task", "justification": err_msg[:500]}, "error": err_msg}) - batch_error_count += 1; total_error_count +=1 - # Zähle Fehler aus dem Ergebnis-Dict - if results_list[-1]['error']: batch_error_count += 1; total_error_count +=1 - - # *** ENDE PARALLELE VERARBEITUNG *** - current_batch_processed_count = len(results_list) - total_processed_count += current_batch_processed_count - debug_print(f" Branch-Evaluation für Batch beendet. {current_batch_processed_count} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).") - - # Sheet Updates vorbereiten FÜR DIESEN BATCH - if results_list: - current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - current_version = Config.VERSION - batch_sheet_updates = [] - # Sortiere Ergebnisse nach Zeilennummer für geordnetes Schreiben (optional) - results_list.sort(key=lambda x: x['row_num']) - for res_data in results_list: - row_num = res_data['row_num']; result = res_data['result'] - # Logge das individuelle Ergebnis VOR dem Update - debug_print(f" Zeile {row_num}: Ergebnis -> Branch='{result.get('branch')}', Consistency='{result.get('consistency')}', Justification='{result.get('justification', '')[:50]}...'") - row_updates = [ - {'range': f'{branch_w_letter}{row_num}', 'values': [[result.get("branch", "Fehler")]]}, - {'range': f'{branch_x_letter}{row_num}', 'values': [[result.get("consistency", "Fehler")]]}, - {'range': f'{branch_y_letter}{row_num}', 'values': [[result.get("justification", "Fehler")]]}, - {'range': f'{ts_col_letter}{row_num}', 'values': [[current_timestamp]]}, - {'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]} - ] - batch_sheet_updates.extend(row_updates) - - # Sende Updates für DIESEN Batch SOFORT - if batch_sheet_updates: - debug_print(f" Sende Sheet-Update für {len(results_list)} Zeilen ({len(batch_sheet_updates)} Zellen)...") - success = sheet_handler.batch_update_cells(batch_sheet_updates) - if success: debug_print(f" Sheet-Update für Batch {batch_start_row}-{batch_end_row} erfolgreich.") - else: debug_print(f" FEHLER beim Sheet-Update für Batch {batch_start_row}-{batch_end_row}.") - else: debug_print(f" Keine Sheet-Updates für Batch {batch_start_row}-{batch_end_row} vorbereitet.") - - tasks_for_processing_batch = [] # Batch leeren - debug_print(f"--- Verarbeitungs-Batch {batch_start_row}-{batch_end_row} abgeschlossen ---") - # Kurze Pause NACHDEM ein Batch komplett verarbeitet und geschrieben wurde - # debug_print(" Warte 1 Sekunde...") # Test-Log - time.sleep(1) - - # !!! HIER DARF KEIN SLEEP STEHEN !!! - # time.sleep(Config.RETRY_DELAY) # <<< DIESE ZEILE MUSS WEG SEIN in deinem Code! - - debug_print(f"Brancheneinschätzung (Parallel Batch) abgeschlossen. {total_processed_count} Zeilen verarbeitet (inkl. Fehler), {total_error_count} Fehler, {total_skipped_count} Zeilen wg. Timestamp übersprungen.") - - -# Annahmen: -# - Funktionen debug_print, process_verification_only, process_website_batch, process_branch_batch sind definiert. -# - sheet_handler ist eine initialisierte Instanz von GoogleSheetHandler (mit der korrekten get_start_row_index Methode). -# - Globale Konstante header_rows (oder besser, hol sie vom sheet_handler?) - -# Komplette run_dispatcher Funktion (Start immer basierend auf AO) -# Komplette run_dispatcher Funktion (Keine Änderungen hier nötig) -def run_dispatcher(mode, sheet_handler, row_limit=None): - """ - Wählt den passenden Batch-Prozess basierend auf dem Modus. - Ermittelt die Startzeile dynamisch basierend auf der relevanten Spalte für den Modus. - """ - debug_print(f"Starte Dispatcher im Modus '{mode}' mit row_limit={row_limit}.") - header_rows = 5 - - # Startspalte für jeden Modus - start_col_key = "Timestamp letzte Prüfung" # Standard AO - min_start_row = 7 - if mode == "website": start_col_key = "Website Rohtext" # AR - elif mode == "wiki": start_col_key = "Wiki Verif. Timestamp" # AX - elif mode == "branch": start_col_key = "Timestamp letzte Prüfung" # AO - elif mode == "summarize": start_col_key = "Website Zusammenfassung" # AS - elif mode == "combined": start_col_key = "Timestamp letzte Prüfung" # AO - - debug_print(f"Dispatcher: Ermittle Startzeile basierend auf Spalte '{start_col_key}'...") - # get_start_row_index prüft jetzt auf exakt "" - start_data_index = sheet_handler.get_start_row_index(check_column_key=start_col_key, min_sheet_row=min_start_row) - - if start_data_index == -1: return debug_print(f"FEHLER: Startspalte '{start_col_key}' prüfen!") - - start_row_index_in_sheet = start_data_index + header_rows + 1 - total_sheet_rows = len(sheet_handler.sheet_values) - - # Prüfungen (wie gehabt) - if start_data_index >= len(sheet_handler.get_data()): return debug_print("Start nach Ende.") - if start_row_index_in_sheet > total_sheet_rows: return debug_print("Ungültige Startzeile.") - - # Endzeile - if row_limit is not None and row_limit > 0: end_row_index_in_sheet = min(start_row_index_in_sheet + row_limit - 1, total_sheet_rows) - elif row_limit == 0: return debug_print("Limit 0.") - else: end_row_index_in_sheet = total_sheet_rows - debug_print(f"Dispatcher: Verarbeitung geplant für Sheet-Zeilen {start_row_index_in_sheet} bis {end_row_index_in_sheet}.") - if start_row_index_in_sheet > end_row_index_in_sheet: return debug_print("Start nach Ende (berechnet).") - - # Modusauswahl - try: - if mode == "wiki": process_verification_only(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet) - elif mode == "website": process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet) # Prüft AR, Setzt AR+AP - elif mode == "branch": process_branch_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet) - elif mode == "summarize": process_website_summarization_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet) - elif mode == "combined": - debug_print("--- Start Combined Mode: Wiki ---"); process_verification_only(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet); time.sleep(1) - debug_print("--- Start Combined Mode: Website Scraping ---"); process_website_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet); time.sleep(1) - debug_print("--- Start Combined Mode: Website Summarization ---"); process_website_summarization_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet); time.sleep(1) - debug_print("--- Start Combined Mode: Branch ---"); process_branch_batch(sheet_handler, start_row_index_in_sheet, end_row_index_in_sheet) - debug_print("--- Combined Mode abgeschlossen ---") - else: debug_print(f"Ungültiger Modus '{mode}'.") - except Exception as e: debug_print(f"FEHLER im Dispatcher: {e}"); import traceback; debug_print(traceback.format_exc()) - -# --- Ende run_dispatcher Funktion --- - -# ==================== SERP API / LINKEDIN FUNCTIONS ==================== - -@retry_on_failure -def serp_website_lookup(company_name): - """Ermittelt Website via SERP API (Google Suche).""" - serp_key = Config.API_KEYS.get('serpapi') - if not serp_key: - debug_print("Fehler: SerpAPI Key nicht verfügbar für Website Lookup.") - return "k.A." - if not company_name: return "k.A." - - # Blacklist unerwünschter Domains - blacklist = ["bloomberg.com", "northdata.de", "finanzen.net", "handelsblatt.com", "wikipedia.org", "linkedin.com"] - - query = f'{company_name} offizielle Website' # Präzisere Query - params = { - "engine": "google", - "q": query, - "api_key": serp_key, - "hl": "de", - "gl": "de" # Geolocation auf Deutschland setzen - } - api_url = "https://serpapi.com/search" - - try: - response = requests.get(api_url, params=params, timeout=10) - response.raise_for_status() - data = response.json() - - # 1. Knowledge Graph prüfen (oft die offizielle Seite) - if "knowledge_graph" in data and "website" in data["knowledge_graph"]: - kg_url = data["knowledge_graph"]["website"] - if kg_url and not any(bad_domain in kg_url for bad_domain in blacklist): - normalized_url = simple_normalize_url(kg_url) - if normalized_url != "k.A.": - debug_print(f"SERP Lookup: Website '{normalized_url}' aus Knowledge Graph für '{company_name}' gefunden.") - return normalized_url - - # 2. Organische Ergebnisse prüfen - if "organic_results" in data: - for result in data["organic_results"]: - url = result.get("link", "") - # Prüfe Blacklist und ob es eine "echte" Website ist (nicht nur Suche etc.) - if url and not any(bad_domain in url for bad_domain in blacklist) and url.startswith("http"): - normalized_url = simple_normalize_url(url) - if normalized_url != "k.A.": - # Zusätzliche Plausibilitätsprüfung: Enthält die Domain Teile des Firmennamens? - domain_part = normalized_url.replace('www.', '').split('.')[0] - if domain_part in normalize_company_name(company_name): - debug_print(f"SERP Lookup: Website '{normalized_url}' aus Organic Results für '{company_name}' gefunden.") - return normalized_url - else: - debug_print(f"SERP Lookup: URL '{normalized_url}' übersprungen (Domain passt nicht zu '{company_name}').") - - debug_print(f"SERP Lookup: Keine passende Website für '{company_name}' gefunden.") - return "k.A." - except requests.exceptions.RequestException as e: - debug_print(f"Fehler beim SERP API Website Lookup für '{company_name}': {e}") - return "k.A." - except Exception as e: - debug_print(f"Allgemeiner Fehler beim SERP API Website Lookup für '{company_name}': {e}") - return "k.A." - - -@retry_on_failure -def search_linkedin_contacts(company_name, website, position_query, crm_kurzform, num_results=10): - """Sucht LinkedIn Kontakte via SERP API.""" - serp_key = Config.API_KEYS.get('serpapi') - if not serp_key: - debug_print("Fehler: SerpAPI Key nicht verfügbar für LinkedIn Suche.") - return [] - if not all([company_name, position_query, crm_kurzform]): - return [] - - # Query anpassen für bessere Ergebnisse - query = f'site:linkedin.com/in "{position_query}" "{crm_kurzform}"' # Suche nach Kurzform im Titel - # query = f'site:linkedin.com/in "{position_query}" "{company_name}"' # Original Query - params = { - "engine": "google", - "q": query, - "api_key": serp_key, - "hl": "de", - "gl": "de", - "num": num_results # Google's num Parameter (max 100, aber oft weniger geliefert) - } - api_url = "https://serpapi.com/search" - - try: - response = requests.get(api_url, params=params, timeout=15) # Längerer Timeout - response.raise_for_status() - data = response.json() - contacts = [] - - if "organic_results" in data: - for result in data["organic_results"]: - title = result.get("title", "") - linkedin_url = result.get("link", "") - - # Filter: Muss LinkedIn URL sein und Kurzform muss im Titel vorkommen - if not linkedin_url or "linkedin.com/in/" not in linkedin_url: - continue - if crm_kurzform.lower() not in title.lower(): - debug_print(f"LinkedIn Treffer übersprungen: Kurzform '{crm_kurzform}' nicht in Titel '{title}'") - continue - - # Extrahiere Name und Position aus Titel - name_part = "" - pos_part = position_query # Fallback auf Suchbegriff - - # Versuche gängige Trennzeichen - separators = ["–", "-", "|", " at ", " bei "] - title_cleaned = title.replace("...", "").strip() # Bereinige Titel - - found_sep = False - for sep in separators: - if sep in title_cleaned: - parts = title_cleaned.split(sep, 1) - name_part = parts[0].strip() - # Versuche, LinkedIn/Profil etc. aus Namen zu entfernen - name_part = name_part.replace(" | LinkedIn", "").replace(" - LinkedIn", "").replace(" - Profil", "").strip() - - # Positionsteil kann komplex sein, nehme alles nach dem Trenner - potential_pos = parts[1].strip() - # Entferne Firmenteil, wenn er dem Kurznamen ähnelt - if crm_kurzform.lower() in potential_pos.lower(): - potential_pos = potential_pos.replace(crm_kurzform, "", 1).strip() # Nur erste Instanz ersetzen - # Entferne generische Endungen - potential_pos = potential_pos.split(" | LinkedIn")[0].split(" - LinkedIn")[0].strip() - pos_part = potential_pos if potential_pos else position_query - found_sep = True - break - - if not found_sep: # Kein Trennzeichen gefunden - name_part = title_cleaned.split(" | LinkedIn")[0].split(" - LinkedIn")[0].strip() - # Prüfe, ob der Suchbegriff im verbleibenden Namensteil ist - if position_query.lower() in name_part.lower(): - name_part = name_part.replace(position_query, "", 1).strip() # Versuche Position zu entfernen - - # Teile Namen in Vor- und Nachname - firstname = "" - lastname = "" - name_parts = name_part.split() - if len(name_parts) > 1: - firstname = name_parts[0] - lastname = " ".join(name_parts[1:]) - elif len(name_parts) == 1: - firstname = name_parts[0] # Nur Vorname gefunden? - - if not firstname: # Wenn Name nicht extrahiert werden konnte, überspringe - debug_print(f"Kontakt übersprungen: Name konnte nicht extrahiert werden aus Titel '{title}'") - continue - - contact_data = { - "Firmenname": company_name, # Originalname für Kontext - "CRM Kurzform": crm_kurzform, - "Website": website, - "Vorname": firstname, - "Nachname": lastname, - "Position": pos_part, - "LinkedInURL": linkedin_url - } - contacts.append(contact_data) - debug_print(f"Gefundener LinkedIn Kontakt: {firstname} {lastname} - {pos_part}") - - debug_print(f"LinkedIn Suche für '{position_query}' bei '{crm_kurzform}' ergab {len(contacts)} Kontakte.") - return contacts - - except requests.exceptions.RequestException as e: - debug_print(f"Fehler bei der SERP API LinkedIn Suche: {e}") - return [] - except Exception as e: - debug_print(f"Allgemeiner Fehler bei der SERP API LinkedIn Suche: {e}") - return [] - - -# Funktion count_linkedin_contacts wurde entfernt, da search_linkedin_contacts jetzt die Liste liefert -# und len() darauf angewendet werden kann. - - -def process_contact_research(sheet_handler): - """Sucht LinkedIn Kontakte und trägt sie in 'Contacts' Sheet ein.""" - debug_print("Starte Contact Research (LinkedIn)...") - - main_sheet = sheet_handler.sheet - all_data = sheet_handler.get_all_data_with_headers() - header_rows = 5 - - # Finde Startzeile basierend auf Timestamp in Spalte AM (Index 38) - timestamp_col_index = COLUMN_MAP["Contact Search Timestamp"] - start_row_index_in_sheet = -1 - for i in range(header_rows + 1, len(all_data) + 1): - if i < 7: continue # Normalerweise ab Zeile 7 - row_index_in_list = i - 1 - row = all_data[row_index_in_list] - if len(row) <= timestamp_col_index or not row[timestamp_col_index].strip(): - start_row_index_in_sheet = i - break - - if start_row_index_in_sheet == -1: - debug_print("Keine Zeile ohne Contact Search Timestamp (Spalte AM, ab Zeile 7) gefunden. Überspringe.") - return - - debug_print(f"Contact Research startet ab Zeile {start_row_index_in_sheet}.") - - # Kontakte-Blatt öffnen oder erstellen - try: - contacts_sheet = sheet_handler.sheet.spreadsheet.worksheet("Contacts") - debug_print("Blatt 'Contacts' gefunden.") - except gspread.exceptions.WorksheetNotFound: - debug_print("Blatt 'Contacts' nicht gefunden, erstelle neu...") - contacts_sheet = sheet_handler.sheet.spreadsheet.add_worksheet(title="Contacts", rows="1000", cols="12") - header = ["Firmenname", "CRM Kurzform", "Website", "Geschlecht", "Vorname", "Nachname", "Position", - "Suchbegriffskategorie", "E-Mail-Adresse", "LinkedIn-Link", "Timestamp"] - contacts_sheet.update(values=[header], range_name="A1:K1") - # Optional: Alignment Demo hier nicht mehr aufrufen - # alignment_demo(contacts_sheet) # NICHT MEHR NÖTIG/FALSCH - debug_print("Neues Blatt 'Contacts' erstellt und Header eingetragen.") - - # Positionen, nach denen gesucht wird - positions_to_search = ["Serviceleiter", "Leiter Kundendienst", "IT-Leiter", "Leiter IT", "Geschäftsführer", "Vorstand", "Disponent", "Einsatzleiter"] - - # Gehe Zeilen im Hauptblatt durch - for i in range(start_row_index_in_sheet, len(all_data) + 1): - row_index_in_list = i - 1 - row = all_data[row_index_in_list] - - company_name = row[COLUMN_MAP["CRM Name"]] if len(row) > COLUMN_MAP["CRM Name"] else "" - crm_kurzform = row[COLUMN_MAP["CRM Kurzform"]] if len(row) > COLUMN_MAP["CRM Kurzform"] else "" - website = row[COLUMN_MAP["CRM Website"]] if len(row) > COLUMN_MAP["CRM Website"] else "" - - if not all([company_name, crm_kurzform, website]): - debug_print(f"Zeile {i}: Übersprungen (fehlende CRM Daten: Name, Kurzform oder Website).") - continue - - debug_print(f"Zeile {i}: Suche Kontakte für '{crm_kurzform}'...") - all_found_contacts = [] - contact_counts = {pos: 0 for pos in ["Serviceleiter", "IT-Leiter", "Geschäftsführer", "Disponent"]} # Für die Zählung im Hauptblatt - - for position in positions_to_search: - # Suche max. 5 Kontakte pro Position, um API Calls/Kosten zu begrenzen - found_contacts = search_linkedin_contacts(company_name, website, position, crm_kurzform, num_results=5) - - # Zählung für das Hauptblatt (vereinfachte Kategorien) - if "serviceleiter" in position.lower() or "kundendienst" in position.lower() or "einsatzleiter" in position.lower(): - contact_counts["Serviceleiter"] += len(found_contacts) - elif "it-leiter" in position.lower() or "leiter it" in position.lower(): - contact_counts["IT-Leiter"] += len(found_contacts) - elif "geschäftsführer" in position.lower() or "vorstand" in position.lower(): - contact_counts["Geschäftsführer"] += len(found_contacts) - elif "disponent" in position.lower(): - contact_counts["Disponent"] += len(found_contacts) - - # Füge gefundene Kontakte zur Liste hinzu (mit Suchkategorie) - for contact in found_contacts: - contact["Suchbegriffskategorie"] = position - all_found_contacts.append(contact) - - time.sleep(1.5) # Kleine Pause zwischen SerpAPI-Aufrufen - - # Verarbeite gefundene Kontakte und schreibe ins Contacts-Sheet - rows_to_append = [] - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - unique_contacts = {c['LinkedInURL']: c for c in all_found_contacts}.values() # Deduplizieren basierend auf URL - - for contact in unique_contacts: - firstname = contact.get("Vorname", "") - lastname = contact.get("Nachname", "") - gender_value = get_gender(firstname) - email = get_email_address(firstname, lastname, website) - - contact_row = [ - contact.get("Firmenname", ""), - contact.get("CRM Kurzform", ""), - contact.get("Website", ""), - gender_value, - firstname, - lastname, - contact.get("Position", ""), - contact.get("Suchbegriffskategorie", ""), - email, - contact.get("LinkedInURL", ""), - timestamp - ] - rows_to_append.append(contact_row) - - if rows_to_append: - try: - # Verwende append_rows für Effizienz - contacts_sheet.append_rows(rows_to_append, value_input_option='USER_ENTERED') - debug_print(f"Zeile {i}: {len(rows_to_append)} neue Kontakte zum 'Contacts'-Blatt hinzugefügt.") - except Exception as e: - debug_print(f"Zeile {i}: Fehler beim Hinzufügen von Kontakten zum Sheet: {e}") - # Evtl. einzeln versuchen bei Fehler? - - # Aktualisiere Trefferzahlen und Timestamp im Hauptblatt (Batch Update) - main_sheet_updates = [] - main_sheet_updates.append({'range': f'AI{i}', 'values': [[str(contact_counts["Serviceleiter"])]]}) - main_sheet_updates.append({'range': f'AJ{i}', 'values': [[str(contact_counts["IT-Leiter"])]]}) - main_sheet_updates.append({'range': f'AK{i}', 'values': [[str(contact_counts["Geschäftsführer"])]]}) - main_sheet_updates.append({'range': f'AL{i}', 'values': [[str(contact_counts["Disponent"])]]}) - main_sheet_updates.append({'range': f'AM{i}', 'values': [[timestamp]]}) # Contact Search Timestamp - - sheet_handler.batch_update_cells(main_sheet_updates) - debug_print(f"Zeile {i}: Kontaktzahlen im Hauptblatt aktualisiert: {contact_counts} – Timestamp in AM gesetzt.") - - # Pause nach Verarbeitung einer Firma - time.sleep(Config.RETRY_DELAY) - - debug_print("Contact Research abgeschlossen.") - -# ==================== ALIGNMENT DEMO (Hauptblatt) ==================== -# ==================== ALIGNMENT DEMO (Hauptblatt) ==================== -# Diese Funktion ist bereits im Code vorhanden (Zeile ~1230 in der vorherigen Version) -# Sie bleibt unverändert: -def alignment_demo(sheet): - """Schreibt die Header-Struktur (Zeilen 1-5, jetzt bis Spalte AX) ins angegebene Sheet.""" - new_headers = [ - [ # Spaltenname (Zeile 1) - "ReEval Flag", "CRM Name", "CRM Kurzform", "CRM Website", "CRM Ort", "CRM Beschreibung", "CRM Branche", "CRM Beschreibung Branche extern", "CRM Anzahl Techniker", "CRM Umsatz", "CRM Anzahl Mitarbeiter", "CRM Vorschlag Wiki URL", "Wiki URL", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Chat Wiki Konsistenzprüfung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", "Begründung bei Abweichung", "Chat Vorschlag Branche", "Chat Konsistenz Branche", "Chat Begründung Abweichung Branche", "Chat Prüfung FSM Relevanz", "Chat Begründung für FSM Relevanz", "Chat Schätzung Anzahl Mitarbeiter", "Chat Konsistenzprüfung Mitarbeiterzahl", "Chat Begründung Abweichung Mitarbeiterzahl", "Chat Einschätzung Anzahl Servicetechniker", "Chat Begründung Abweichung Anzahl Servicetechniker", "Chat Schätzung Umsatz", "Chat Begründung Abweichung Umsatz", "Linked Serviceleiter gefunden", "Linked It-Leiter gefunden", "Linked Management gefunden", "Linked Disponent gefunden", "Contact Search Timestamp", "Wikipedia Timestamp", "Timestamp letzte Prüfung", "Version", "Tokens", "Website Rohtext", "Website Zusammenfassung", - "Website Scrape Timestamp", # AT - "Geschätzter Techniker Bucket", # AU - "Finaler Umsatz (Wiki>CRM)", # AV - "Finaler Mitarbeiter (Wiki>CRM)", # AW - "Wiki Verif. Timestamp", # AX - "SerpAPI Wiki Search Timestamp" # AY (NEU) - ], - [ # Quelle der Daten (Zeile 2) - "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "CRM", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Wikipediascraper", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "Chat GPT API", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "LinkedIn (via SerpApi)", "System", "System", "System", "System", "System", "Web Scraper", "Chat GPT API", - "System", # AT - "ML Modell / Skript", # AU - "Skript (Wiki/CRM)", # AV - "Skript (Wiki/CRM)", # AW - "System", # AX - "System" # AY (NEU) - ], - [ # Feldkategorie (Zeile 3) - "Prozess", "Firmenname", "Firmenname", "Website", "Ort", "Beschreibung (Text)", "Branche", "Branche", "Anzahl Servicetechniker", "Umsatz", "Anzahl Mitarbeiter", "Wikipedia Artikel URL", "Wikipedia Artikel", "Beschreibung (Text)", "Branche", "Umsatz", "Anzahl Mitarbeiter", "Kategorien (Text)", "Verifizierung", "Begründung bei Abweichung", "Wikipedia Artikel", "Wikipedia Artikel", "Branche", "Branche", "Branche", "FSM Relevanz", "FSM Relevanz", "Anzahl Mitarbeiter", "Anzahl Mitarbeiter", "Anzahl Mitarbeiter", "Anzahl Servicetechniker", "Anzahl Servicetechniker", "Umsatz", "Umsatz", "Kontakte zur Firma", "Kontakte zur Firma", "Kontakte zur Firma", "Kontakte zur Firma", "Timestamp", "Timestamp", "Timestamp", "Version des Skripts die verwendet wurde", "ChatGPT Tokens", "Website-Content", "Website Zusammenfassung", - "Timestamp", # AT - "Anzahl Servicetechniker Bucket", # AU - "Umsatz", # AV - "Anzahl Mitarbeiter", # AW - "Timestamp", # AX - "Timestamp" # AY (NEU) - ], - [ # Kurze Beschreibung (Zeile 4) - "Systemspalte...", "Enthält den Firmennamen...", "Manuell gepflegte Kurzform...", "Website des Unternehmens.", "Ort des Unternehmens.", "Kurze Beschreibung...", "Aktuelle Branchenzuweisung...", "Externe Branchenbeschreibung...", "Recherchierte Anzahl...", "Umsatz in Mio. € (CRM).", "Anzahl Mitarbeiter (CRM).", "Vorgeschlagene Wikipedia URL...", "Wikipedia URL...", "Erster Absatz...", "Wikipedia-Branche...", "Wikipedia-Umsatz...", "Wikipedia-Mitarbeiterzahl...", "Liste der Wikipedia-Kategorien.", "\"OK\" oder \"X\" – Ergebnis...", "Begründung bei Inkonsistenz...", "Chat-Vorschlag Wiki Artikel...", "Nicht genutzt...", "Branchenvorschlag via ChatGPT...", "Vergleich: Übereinstimmung CRM vs. ...", "Begründung bei abweichender...", "FSM-Relevanz: Bewertung...", "Begründung zur FSM-Bewertung.", "Schätzung Anzahl Mitarbeiter...", "Vergleich CRM vs. Wiki vs. ...", "Begründung bei Mitarbeiterabweichung...", "Schätzung Servicetechniker...", "Begründung bei Abweichung...", "Schätzung Umsatz via ChatGPT.", "Begründung bei Umsatzabweichung.", "Anzahl Kontakte (Serviceleiter)...", "Anzahl Kontakte (IT-Leiter)...", "Anzahl Kontakte (Management)...", "Anzahl Kontakte (Disponent)...", "Timestamp der Kontaktsuche.", "Timestamp der Wikipedia-Suche/Extraktion.", "Timestamp der ChatGPT-Bewertung / Letzte Prüfung der Zeile.", "Ausgabe der Skriptversion...", "Token-Zählung...", "Roh extrahierter Text...", "Zusammenfassung des Webseiteninhalts...", - "Timestamp des letzten Website-Scrapings (AR, AS).", # AT - "Geschätzter Bucket (1-7) für Servicetechniker...", # AU - "Konsolidierter Umsatz (Mio €) nach Priorität Wiki > CRM.", # AV - "Konsolidierte Mitarbeiterzahl nach Priorität Wiki > CRM.", # AW - "Timestamp der letzten Wiki-Verifikation (Spalten S-Y).", # AX - "Timestamp der letzten SerpAPI-Suche nach fehlender Wiki-URL (Modus find_wiki_serp)." # AY (NEU) - ], - [ # Aufgabe / Funktion (Zeile 5) - Ergänzt um AX - "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Datenquelle", "Wird durch Wikipedia Scraper bereitgestellt", "Wird zunächst nicht verwendet...", "Wird u.a. zur finalen Ermittlung...", "Wird u.a. mit CRM-Umsatz...", "Wird u.a. mit CRM-Anzahl...", "Wenn Website-Daten fehlen...", "\"Es soll durch ChatGPT geprüft werden...", "\"Liegt eine Inkonsistenz...", "\"Sollte durch die Wikipedia-Suche...", "XXX derzeit nicht verwendet...", "\"ChatGPT soll anhand der vorliegenden...", "Die in Spalte CRM festgelegte...", "Weicht die von ChatGPT ermittelte...", "ChatGPT soll anhand der vorliegenden Daten prüfen...", "Die in 'Chat Begründung für FSM Relevanz'...", "Nur wenn kein Wikipedia-Eintrag...", "Entspricht die durch ChatGPT ermittelte...", "Weicht die von ChatGPT geschätzte...", "ChatGPT soll auf Basis öffentlich...", "Weicht die von ChatGPT geschätzte...", "Nur wenn kein Wikipedia-Eintrag...", "ChatGPT soll signifikante Umsatzabweichungen...", "Über SerpAPI wird zusammen...", "Über SerpAPI wird zusammen...", "Über SerpAPI wird zusammen...", "Über SerpAPI wird zusammen...", "Wenn die Kontaktsuche gestartet wird...", "Wenn die Wikipedia-Suche gestartet wird...", "Wenn die ChatGPT-Bewertung gestartet wird...", "Wird durch das System befüllt", "Wird durch tiktoken berechnet", "Wird durch Web Scraper...", "Wird durch ChatGPT API...", - "Timestamp wird gesetzt, wenn Website Rohtext/Zusammenfassung geschrieben werden.", # AT - "Ergebnis der Schätzung durch das trainierte ML-Modell.", # AU - "Vom Skript berechneter Wert, priorisiert Wiki > CRM...", # AV - "Vom Skript berechneter Wert, priorisiert Wiki > CRM...", # AW - "Timestamp wird gesetzt, wenn Wiki-Verifikation (S-Y) durchgeführt wurde.", # AX - "Timestamp wird gesetzt, nachdem versucht wurde, eine fehlende Wiki-URL via SerpAPI zu finden." # AY (NEU) - ] - ] - num_cols = len(new_headers[0]) - # ... (Rest der Funktion zum Schreiben der Header bleibt gleich, verwendet jetzt AY als Endspalte) ... - def colnum_string(n): # Hilfsfunktion bleibt - string = "" - while n > 0: n, remainder = divmod(n - 1, 26); string = chr(65 + remainder) + string - return string - end_col_letter = colnum_string(num_cols) - header_range = f"A1:{end_col_letter}{len(new_headers)}" - try: - sheet.update(values=new_headers, range_name=header_range) - logging.info(f"Alignment-Demo abgeschlossen: Header in Bereich {header_range} geschrieben.") - except Exception as e: - logging.error(f"FEHLER beim Schreiben der Alignment-Demo Header: {e}") +# --- Ende Batch Processing Helper --- # ==================== DATA PROCESSOR ==================== +# Annahmen: GoogleSheetHandler, WikipediaScraper Klassen sind definiert +# Annahmen: Alle globalen Helper-Funktionen (clean_text, get_numeric_filter_value, etc.) sind definiert +# Annahme: COLUMN_MAP, Config, logging sind verfügbar. + class DataProcessor: """ Verarbeitet Daten aus dem Google Sheet, führt verschiedene Anreicherungs- - und Analyseprozesse durch, inklusive Timestamp-basierter Überspringung - und erzwungener Neuverarbeitung im Re-Eval-Modus. - Enthält auch die Datenvorbereitung für das ML-Modell. + und Analyseprozesse durch, inklusive Timestamp-basierter Überspringung, + erzwungener Neuverarbeitung und granularer Schrittauswahl. Orchestriert + die verschiedenen Verarbeitungsmodi (sequenziell, batch, re-eval, kriterien). + Enthält auch die Datenvorbereitung und das Training des ML-Modells. """ - def __init__(self, sheet_handler, wiki_scraper): # <<< KORRIGIERTE SIGNATUR! Akzeptiert sheet_handler UND wiki_scraper + def __init__(self, sheet_handler, wiki_scraper): """ Initialisiert den DataProcessor mit Handlern. @@ -4022,376 +1876,576 @@ class DataProcessor: if sheet_handler is None: logging.critical("DataProcessor Init FEHLER: Kein gültiger sheet_handler übergeben!") raise ValueError("DataProcessor benötigt einen gültigen GoogleSheetHandler.") - # NEUE PRÜFUNG für wiki_scraper if wiki_scraper is None: logging.critical("DataProcessor Init FEHLER: Kein gültiger wiki_scraper übergeben!") raise ValueError("DataProcessor benötigt einen gültigen WikipediaScraper.") self.sheet_handler = sheet_handler - self.wiki_scraper = wiki_scraper # <<< Speichert den übergebenen wiki_scraper - - # ENTFERNEN SIE den try/except Block, der versucht, WikipediaScraper() HIER ZU INITIALISIEREN! - # try: - # # Erstelle eine Instanz des Scrapers für diesen Prozessor - # # Annahme: WikipediaScraper ist importiert und korrekt - # self.wiki_scraper = WikipediaScraper() # <<< DIESER BLOCK MUSS WEG - # except NameError: - # logging.critical("DataProcessor Init FEHLER: WikipediaScraper Klasse nicht gefunden/importiert!") - # raise - # except Exception as e: - # logging.critical(f"DataProcessor Init FEHLER beim Initialisieren von WikipediaScraper: {e}") - # raise - + self.wiki_scraper = wiki_scraper # Speichert den übergebenen wiki_scraper + # Fügen Sie hier Instanzvariablen für weitere Handler hinzu: + # self.website_scraper = website_scraper # Beispiel + # self.api_client = api_client # Beispiel logging.info("DataProcessor initialisiert.") - # --- Methode: Verarbeitung einer einzelnen Zeile --- + # --- Private Helfermethode: Zugriff auf Zellwert mit row_data --- + # Diese Methode gehört in die Klasse und nimmt die rohe Zeilendatenliste entgegen + def _get_cell_value(self, row_data, key): + """Lokale Hilfsfunktion zum sicheren Zugriff auf Zellwerte innerhalb von Methoden, die row_data als Parameter erhalten.""" + idx = COLUMN_MAP.get(key) + if idx is not None and len(row_data) > idx: + return row_data[idx] if row_data[idx] is not None else '' + return "" + + # --- Private Helfermethode: Prüft ob ein Schritt nötig ist (basierend auf Timestamp/Status) --- # Diese Methode gehört in die Klasse - # @retry_on_failure # Achtung: retry_on_failure macht bei dieser Methode WENIG Sinn, - # da sie interne Logik steuert, keine externen Calls. - # Besser: retry auf den einzelnen Schritten (API/Scrape) + def _is_step_processing_needed(self, row_data, step_key, force_reeval, related_inputs_updated=False): + """ + Prüft, ob ein spezifischer Verarbeitungsschritt für diese Zeile ausgeführt werden soll, + basierend auf Timestamp, force_reeval und ob Eingangsdaten aktualisiert wurden. + + Args: + row_data (list): Die rohen Daten für die Zeile. + step_key (str): Schlüssel des Timestamps in COLUMN_MAP, der diesen Schritt markiert (z.B. "Wikipedia Timestamp", "Timestamp letzte Prüfung"). Für Schritte ohne dedizierten Timestamp kann None übergeben werden, dann ist das Kriterium NUR related_inputs_updated oder force_reeval. + force_reeval (bool): Erzwingt die Ausführung, ignoriert Timestamps. + related_inputs_updated (bool, optional): Flag, ob Eingangsdaten für diesen Schritt gerade aktualisiert wurden (z.B. Wiki Daten für Branch Eval). Defaults to False. + + Returns: + bool: True, wenn der Schritt ausgeführt werden soll, sonst False. + """ + if force_reeval: + # logging.debug(f" -> Step Check '{step_key}': True (force_reeval aktiv)") # Zu laut + return True + + # Schritt hat keinen spezifischen Timestamp + if step_key is None: + # Logik: Wenn kein Timestamp, ist der Schritt nötig, wenn Inputs aktualisiert wurden. + # (Oder man definiert eine andere Logik, z.B. immer laufen wenn Flags gesetzt?) + # Für Abhängigkeiten ist related_inputs_updated der Trigger. + # Ohne force_reeval und ohne Timestamp ist der Schritt nur nötig, wenn Inputs neu sind. + needs_processing = related_inputs_updated + # logging.debug(f" -> Step Check '{step_key}' (Ohne TS): Nötig? {needs_processing} (Inputs aktualisiert? {related_inputs_updated})") # Zu laut + return needs_processing + + + timestamp_col_index = COLUMN_MAP.get(step_key) + if timestamp_col_index is None: + logging.error(f" -> Step Check Fehler: Timestamp Schlüssel '{step_key}' nicht in COLUMN_MAP gefunden.") + return False # Kann nicht geprüft werden + + ts_value = row_data[timestamp_col_index] if len(row_data) > timestamp_col_index else "" + ts_is_set = bool(str(ts_value).strip()) + + # Ein Schritt ist nötig, wenn der Timestamp fehlt ODER relevante Inputs gerade aktualisiert wurden + needs_processing = not ts_is_set or related_inputs_updated + + # logging.debug(f" -> Step Check '{step_key}': Nötig? {needs_processing} (TS gesetzt? {ts_is_set}, Inputs aktualisiert? {related_inputs_updated})") # Zu laut + return needs_processing + + + # --- Die zentrale Methode zur Verarbeitung einer einzelnen Zeile --- + # Diese Methode gehört in die Klasse + # @retry_on_failure # Retry macht hier wenig Sinn, besser auf den einzelnen API Calls innerhalb def _process_single_row(self, row_num_in_sheet, row_data, process_wiki=True, process_chatgpt=True, process_website=True, force_reeval=False): """ - Verarbeitet die Daten für eine einzelne Zeile im Sheet. - Führt Website-Scraping/Lookup, Wikipedia-Extraktion/Validierung - und ChatGPT-Evaluationen durch, basierend auf Timestamps/Status - oder dem force_reeval Flag. Schreibt Ergebnisse zurück. + Verarbeitet die Daten für eine einzelne Zeile basierend auf ausgewählten Schritten + und Timestamp/Status-Logik (falls nicht force_reeval). Diese ist die zentrale + Logik für sequenzielle, re-eval und kriterienbasierte Modi. Args: row_num_in_sheet (int): Die 1-basierte Zeilennummer im Google Sheet. row_data (list): Die rohen Listendaten für diese Zeile. - process_wiki (bool, optional): Soll Wiki-Verarbeitung durchgeführt werden?. Defaults to True. - process_chatgpt (bool, optional): Sollen ChatGPT-Evaluationen durchgeführt werden?. Defaults to True. - process_website (bool, optional): Soll Website-Verarbeitung durchgeführt werden?. Defaults to True. - force_reeval (bool, optional): Ignoriert Timestamps und erzwingt Neuverarbeitung. Defaults to False. + process_wiki (bool, optional): Soll die Gruppe Wiki-Schritte ausgeführt werden?. Defaults to True. + process_chatgpt (bool, optional): Soll die Gruppe ChatGPT-Schritte ausgeführt werden?. Defaults to True. + process_website (bool, optional): Soll die Gruppe Website-Schritte ausgeführt werden?. Defaults to True. + force_reeval (bool, optional): Ignoriert Timestamps und erzwingt Ausführung ausgewählter Schritte. Defaults to False. """ - logging.info(f"--- Starte Verarbeitung für Zeile {row_num_in_sheet} {'(Re-Eval)' if force_reeval else ''} ---") + # Logge welche Gruppen von Schritten für DIESE Zeile versucht werden sollen (basierend auf den Flags) + groups_to_attempt_log = [] + if process_website: groups_to_attempt_log.append("Website") + if process_wiki: groups_to_attempt_log.append("Wiki") + if process_chatgpt: groups_to_attempt_log.append("ChatGPT") + # Hinweis: Dies sind nur Gruppen-Flags. Detailliertere Step-Flags können im Refactoring hier verwendet werden. + # z.B. flags_for_steps = {'process_wiki_extraction': True, 'process_branch_evaluation': False, ...} + + logging.info(f"--- Starte Verarbeitung für Zeile {row_num_in_sheet} ({'Re-Eval' if force_reeval else 'Standard'}) - Gruppen: {', '.join(groups_to_attempt_log) if groups_to_attempt_log else 'Keine ausgewählt!'} ---") + updates = [] now_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - any_processing_done = False - wiki_data_updated_in_this_run = False # Flag, ob Wiki-Daten NEU extrahiert/gesetzt wurden + any_processing_done = False # Flag, ob überhaupt etwas in dieser Zeile verarbeitet wurde (für Version-Timestamp) - # Hilfsfunktion für sicheren Zellenzugriff - def get_cell_value(key): - idx = COLUMN_MAP.get(key) - if idx is not None and len(row_data) > idx: - # Stelle sicher, dass der Wert nicht None ist, falls Sheet-Zelle leer ist - return row_data[idx] if row_data[idx] is not None else '' - return "" # Gebe leeren String für fehlende Spalten zurück + # Flags, die signalisieren, ob ein VORHERIGER Schritt erfolgreich aktualisiert wurde + # Dies wird für NACHFOLGENDE Schritte als related_inputs_updated verwendet + wiki_data_updated_in_this_run = False # Hat Wiki Search/Extract Daten in diesem Lauf geändert? + website_data_updated_in_this_run = False # Hat Website Scraping/Summarize in diesem Lauf Daten geändert? - # Initiale Werte lesen - company_name = get_cell_value("CRM Name") - website_url = get_cell_value("CRM Website"); original_website = website_url - crm_branche = get_cell_value("CRM Branche"); crm_beschreibung = get_cell_value("CRM Beschreibung") - konsistenz_s = get_cell_value("Chat Wiki Konsistenzprüfung").strip() # Trimme hier schon - # Lade vorhandene Wiki-Daten (könnten alt sein, werden ggf. überschrieben) - final_wiki_data = { - 'url': get_cell_value("Wiki URL") or 'k.A.', 'first_paragraph': get_cell_value("Wiki Absatz") or 'k.A.', - 'branche': get_cell_value("Wiki Branche") or 'k.A.', 'umsatz': get_cell_value("Wiki Umsatz") or 'k.A.', - 'mitarbeiter': get_cell_value("Wiki Mitarbeiter") or 'k.A.', 'categories': get_cell_value("Wiki Kategorien") or 'k.A.' + # Initiale Werte lesen (aus den erhaltenen row_data) - Nutze private Helfermethode + company_name = self._get_cell_value(row_data, "CRM Name") + website_url_crm = self._get_cell_value(row_data, "CRM Website") # CRM Website URL (Initial) + crm_branche = self._get_cell_value(row_data, "CRM Branche") + crm_beschreibung = self._get_cell_value(row_data, "CRM Beschreibung") + konsistenz_s = self._get_cell_value(row_data, "Chat Wiki Konsistenzprüfung").strip() # Trimme hier schon + + # Lade aktuelle Daten (könnten alt sein, werden ggf. überschrieben) für Inputs nachfolgender Schritte + # Wir lesen hier die Werte, die ZU BEGINN der Verarbeitung dieser Zeile im Sheet stehen. + # Wenn ein Schritt Daten aktualisiert (z.B. Wiki-Daten), wird die lokale Variable (z.B. final_wiki_data) aktualisiert. + # Nachfolgende Schritte in DIESER Zeile nutzen dann die aktualisierte lokale Variable. + current_wiki_data = { + 'url': self._get_cell_value(row_data, "Wiki URL") or 'k.A.', + 'first_paragraph': self._get_cell_value(row_data, "Wiki Absatz") or 'k.A.', + 'branche': self._get_cell_value(row_data, "Wiki Branche") or 'k.A.', + 'umsatz': self._get_cell_value(row_data, "Wiki Umsatz") or 'k.A.', + 'mitarbeiter': self._get_cell_value(row_data, "Wiki Mitarbeiter") or 'k.A.', + 'categories': self._get_cell_value(row_data, "Wiki Kategorien") or 'k.A.' } - - # --- 1. Website Handling (Prüft AT oder force_reeval) --- - website_ts_missing = not get_cell_value("Website Scrape Timestamp").strip() - # Website Verarbeitung notwendig, wenn: - # - process_website True ist UND - # ( force_reeval True ist ODER Timestamp AT fehlt ) - website_processing_needed = process_website and (force_reeval or website_ts_missing) - - if website_processing_needed: - any_processing_done = True - logging.info(f"Zeile {row_num_in_sheet}: Starte Website Verarbeitung (Grund: {'Re-Eval' if force_reeval else 'AT fehlt'})...") - # Website Lookup nur, wenn URL leer ist - if not website_url or website_url.strip().lower() == "k.a.": - logging.debug(" -> Suche Website via SERP...") - # Annahme: serp_website_lookup existiert und nutzt logging/retry - new_website = serp_website_lookup(company_name) - if new_website != "k.A.": - website_url = new_website # Update die lokale Variable für den weiteren Schritt - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["CRM Website"] + 1)}{row_num_in_sheet}', 'values': [[website_url]]}) - logging.info(f" -> Neue Website gefunden und für Update vorgemerkt: {website_url}") - else: - logging.warning(f" -> Keine neue Website via SERP gefunden für '{company_name}'.") - - if website_url and website_url.strip().lower() != "k.a.": - logging.debug(f" -> Scrape Rohtext von {website_url}...") - # Annahme: get_website_raw existiert und nutzt logging/retry - new_website_raw = get_website_raw(website_url) - website_raw = new_website_raw # Lokale Variable aktualisieren - - # Zusammenfassung nur, wenn Rohtext extrahiert wurde - if website_raw != "k.A." and website_raw.strip(): - logging.debug(f" -> Fasse Rohtext zusammen (Länge: {len(str(website_raw))})...") - # Annahme: summarize_website_content existiert und nutzt logging/retry - new_website_summary = summarize_website_content(website_raw) - website_summary = new_website_summary # Lokale Variable aktualisieren - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[website_summary]]}) - else: - logging.warning(" -> Kein gültiger Rohtext zum Zusammenfassen vorhanden.") - website_summary = "k.A." - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]}) - - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [[website_raw]]}) # Rohtext immer schreiben (k.A. oder Inhalt) - - else: - logging.warning(f" -> Keine gültige Website URL vorhanden/gefunden für '{company_name}'. Website Verarbeitung übersprungen.") - website_raw, website_summary = "k.A.", "k.A." - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]}) - - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Scrape Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # Timestamp AT immer setzen, wenn Verarbeitung versucht wurde - - elif process_website: - logging.debug(f"Zeile {row_num_in_sheet}: Überspringe Website Verarbeitung (AT vorhanden und kein Re-Eval).") - -# --- 2. Wikipedia Verarbeitung (Prüft AN, Status S='X (URL Copied)' oder force_reeval) --- - wiki_ts_an_missing = not get_cell_value("Wikipedia Timestamp").strip() - status_s_indicates_reparse = konsistenz_s.upper() == "X (URL COPIED)" # Prüfe getrimmten Wert - # Wiki Verarbeitung notwendig, wenn: - # - process_wiki True ist UND - # ( force_reeval True ist ODER Timestamp AN fehlt ODER Status S ist 'X (URL Copied)' ) - wiki_processing_needed = process_wiki and (force_reeval or wiki_ts_an_missing or status_s_indicates_reparse) + # Erstelle lokale Variablen für die aktuellen Daten, die im Lauf aktualisiert werden können + final_wiki_data = dict(current_wiki_data) - if wiki_processing_needed: - any_processing_done = True + current_website_raw = self._get_cell_value(row_data, "Website Rohtext") or "k.A." + current_website_summary = self._get_cell_value(row_data, "Website Zusammenfassung") or "k.A." + # Lokale Variablen, die im Lauf aktualisiert werden können + final_website_raw = current_website_raw + final_website_summary = current_website_summary - # Konstruiere den 'Grund' String separat VOR dem Logging-Aufruf - if force_reeval: - grund_message = 'Re-Eval' - else: - # Dieser f-String ist nun einfacher und nicht mehr Teil eines komplexen Ausdrucks im äußeren f-String - grund_message = f"AN fehlt? {wiki_ts_an_missing}, S='X (URL Copied)'? {status_s_indicates_reparse}" + # --- 1. Website Handling (Lookup, Scraping AR, Summarize AS) --- + # Dieser Block wird nur ausgeführt, wenn die GRUPPE "Website" ausgewählt ist + if process_website: - logging.info(f"Zeile {row_num_in_sheet}: Starte Wikipedia Verarbeitung (Grund: {grund_message})...") + # Website Scraping (AR) & Summarize (AS) sind nötig, wenn: + # (_is_step_processing_needed für AT ODER force_reeval) UND Website-URL vorhanden. + # Hier prüfen wir den Timestamp AT. Website-Lookup hat keinen eigenen Timestamp. + website_scrape_needed = self._is_step_processing_needed(row_data, "Website Scrape Timestamp", force_reeval, related_inputs_updated=False) # related_inputs_updated für Website ist immer False - url_in_m = get_cell_value("Wiki URL").strip() - url_to_extract = None - search_was_needed = False # Flag, ob eine neue Suche durchgeführt wurde + if website_scrape_needed: + any_processing_done = True + logging.info(f"Zeile {row_num_in_sheet}: Starte Website Verarbeitung (Scraping/Summarize) (Grund: {'Re-Eval' if force_reeval else 'AT fehlt'})...") - # --- Kernlogik für Re-Eval oder Initiallauf / S="X (URL Copied)" --- - # Priorität: - # 1. force_reeval: Nimm M, wenn gültig. Sonst Suche. - # 2. S == "X (URL Copied)": Ignoriere M, mache Suche. - # 3. AN fehlt: Wenn M gültig, valide M. Sonst Suche. - # 4. Sonst (AN da, S nicht "X (URL Copied)", kein reeval): Überspringe. + # Website Lookup (D) nur, wenn URL fehlt ( unabhängig von steps-Flags, da es ein Pre-Requisite ist) + # UND der Website-Step überhaupt ausgewählt ist. + website_url_to_process = website_url_crm # Starte mit der CRM URL + if not website_url_crm or website_url_crm.strip().lower() == "k.a.": + logging.debug(" -> Suche Website via SERP (URL fehlt)...") + new_website = serp_website_lookup(company_name) # Globaler Funktion mit Retry + if new_website != "k.A.": + website_url_to_process = new_website # Update die URL für die Verarbeitung + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["CRM Website"] + 1)}{row_num_in_sheet}', 'values': [[website_url_to_process]]}) + logging.info(f" -> Neue Website gefunden und für Update vorgemerkt: {website_url_to_process}") + else: + logging.warning(f" -> Keine neue Website via SERP gefunden für '{company_name}'.") + # website_url_to_process bleibt die ursprüngliche (fehlende) URL + + + if website_url_to_process and website_url_to_process.strip().lower() not in ["k.a.", ""]: + logging.debug(f" -> Scrape Rohtext von {website_url_to_process}...") + final_website_raw = get_website_raw(website_url_to_process) # Globaler Funktion mit Retry + + # Website Summary (AS) wird gemacht, wenn Scraping erfolgreich war. + if final_website_raw != "k.A." and final_website_raw.strip(): + logging.debug(f" -> Fasse Rohtext zusammen (Länge: {len(final_website_raw)})...") + final_website_summary = summarize_website_content(final_website_raw) # Globaler Funktion mit Retry + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[final_website_summary]]}) + else: + logging.warning(" -> Kein gültiger Rohtext zum Zusammenfassen vorhanden.") + final_website_summary = "k.A." # Sicherstellen, dass lokale Variable korrekt ist + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]}) + + + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [[final_website_raw]]}) # Rohtext immer schreiben (k.A. oder Inhalt) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Scrape Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # Timestamp AT setzen + + # Flag setzen, da Website-Daten aktualisiert wurden (AR und/oder AS) + website_data_updated_in_this_run = True - if force_reeval: - logging.debug(" -> Re-Eval Modus aktiv.") - if url_in_m and url_in_m.lower() not in ["k.a.", "kein artikel gefunden"] and url_in_m.lower().startswith("http"): - # Im Re-Eval Modus nehmen wir die URL aus M an, ohne erneute Validierung oder Suche (Vertrauen auf M)! - logging.info(f" -> Re-Eval: Nutze vorhandene URL aus Spalte M direkt: {url_in_m}") - url_to_extract = url_in_m else: - # Wenn M leer/ungültig ist, auch im Re-Eval Modus neu suchen - logging.warning(f" -> Re-Eval: Spalte M ist leer oder ungültig ('{url_in_m}'). Starte neue Suche...") - search_was_needed = True - validated_page = self.wiki_scraper.search_company_article(company_name, website_url) # Annahme: self.wiki_scraper existiert - if validated_page: - url_to_extract = validated_page.url - else: # Suche erfolglos - final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'} - wiki_data_updated_in_this_run = True - elif status_s_indicates_reparse: - logging.warning(f" -> Status S ist 'X (URL Copied)', ignoriere URL '{url_in_m}' in M und starte neue Suche...") - search_was_needed = True - validated_page = self.wiki_scraper.search_company_article(company_name, website_url) - if validated_page: url_to_extract = validated_page.url - else: final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}; wiki_data_updated_in_this_run = True - elif wiki_ts_an_missing: # Nur wenn AN fehlt und S nicht 'X(Copied)' ist, UND kein reeval - if url_in_m and url_in_m.lower() not in ["k.a.", "kein artikel gefunden"] and url_in_m.lower().startswith("http"): - # Prüfe Validität nur im Initiallauf, wenn M schon befüllt ist - logging.debug(f" -> AN fehlt, prüfe Validität der URL aus M: {url_in_m}") - try: - # Extrahieren des Titels aus der URL für wikipedia.page - # Hier könnte ein Fehler passieren, wenn URL kein '/wiki/' hat - title_from_url = url_in_m.split('/wiki/')[-1].replace('_', ' ') - page_from_m = wikipedia.page(title_from_url, auto_suggest=False, preload=True) - # Validierung des Artikels - if self.wiki_scraper._validate_article(page_from_m, company_name, website_url): - url_to_extract = page_from_m.url - logging.info(f" -> Vorhandene URL aus M '{url_to_extract}' ist valide und wird verwendet.") - else: - logging.warning(f" -> Vorhandene URL aus M '{page_from_m.title}' ist NICHT valide. Starte neue Suche...") - search_was_needed = True - validated_page = self.wiki_scraper.search_company_article(company_name, website_url) - if validated_page: url_to_extract = validated_page.url - else: final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}; wiki_data_updated_in_this_run = True - except wikipedia.exceptions.PageError: - logging.warning(f" -> Seite für vorhandene URL aus M '{url_in_m}' nicht gefunden (PageError). Starte neue Suche...") - search_was_needed = True - validated_page = self.wiki_scraper.search_company_article(company_name, website_url) - if validated_page: url_to_extract = validated_page.url - else: final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}; wiki_data_updated_in_this_run = True - except wikipedia.exceptions.DisambiguationError as e_disamb_m: - logging.info(f" -> Vorhandene URL aus M '{url_in_m}' ist eine Begriffsklärung. Starte Suche...") - search_was_needed = True - validated_page = self.wiki_scraper.search_company_article(company_name, website_url) - if validated_page: url_to_extract = validated_page.url - else: final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}; wiki_data_updated_in_this_run = True - except Exception as e_val_m: # Fängt auch URL parsing Fehler hier ab - logging.exception(f" -> Fehler beim Prüfen der URL aus M '{url_in_m}': {e_val_m}. Starte neue Suche...") - search_was_needed = True - validated_page = self.wiki_scraper.search_company_article(company_name, website_url) - if validated_page: url_to_extract = validated_page.url - else: final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}; wiki_data_updated_in_this_run = True - else: - # M ist leer/ungültig und AN fehlt -> Suche starten - logging.info(f" -> AN fehlt und M leer/ungültig. Starte Wikipedia-Suche für '{company_name}'...") + logging.warning(f" -> Keine gültige Website URL vorhanden/gefunden für '{company_name}'. Website Verarbeitung (Scraping/Summarize) übersprungen.") + final_website_raw, final_website_summary = "k.A.", "k.A." # Sicherstellen, dass lokale Vars gesetzt sind + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Scrape Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # AT trotzdem setzen, um Versuch zu markieren + + + # --- 2. Wikipedia Handling (Search M, Extract N-R, Verify S-U) --- + # Dieser Block wird nur ausgeführt, wenn die GRUPPE "Wiki" ausgewählt ist + if process_wiki: + + # Wiki Search & Extraction (M, N-R) ist nötig, wenn: + # (_is_step_processing_needed für AN ODER S='X (URL Copied)'?) + wiki_search_extract_needed = self._is_wiki_search_extract_needed(row_data, force_reeval) # _is_wiki_search_extract_needed prüft AN und S='X' und force_reeval + + if wiki_search_extract_needed: + any_processing_done = True + # Grund-Message wird von _is_wiki_search_extract_needed implizit geprüft, hier im Log wiederholen + logging.info(f"Zeile {row_num_in_sheet}: Starte Wikipedia Verarbeitung (Search/Extract) (Grund: {'Re-Eval' if force_reeval else 'Standard (AN fehlt oder S=X)'})...") + + url_in_m = self._get_cell_value(row_data, "Wiki URL").strip() # Lese URL, die ZU BEGINN da war + url_to_extract = None + search_was_needed = False + + # --- Logik für URL-Bestimmung (wie zuvor, mit kleinen Anpassungen) --- + # Wenn force_reeval: Nutze M direkt, wenn gültig. Sonst Suche. + # Wenn S="X (URL Copied)": Ignoriere M, mache Suche. + # Wenn AN fehlt (Standard): Wenn M gültig, valide M. Sonst Suche. + # Beachte: Wir nutzen hier die URL, die ZU BEGINN der Zeilenverarbeitung in M stand. + + if force_reeval: + logging.debug(" -> Wiki Search/Extract: Re-Eval Modus aktiv.") + if url_in_m and url_in_m.lower() not in ["k.a.", "kein artikel gefunden"] and url_in_m.lower().startswith("http"): + logging.info(f" -> Re-Eval: Nutze vorhandene URL aus Spalte M direkt: {url_in_m}") + url_to_extract = url_in_m + else: + logging.warning(f" -> Re-Eval: Spalte M ist leer oder ungültig ('{url_in_m}'). Starte neue Suche...") + search_was_needed = True + elif konsistenz_s.upper() == "X (URL COPIED)": # S='X (URL Copied)' ist ein direkter Such-Trigger + logging.warning(f" -> Status S ist 'X (URL Copied)', ignoriere URL '{url_in_m}' in M und starte neue Suche...") search_was_needed = True - validated_page = self.wiki_scraper.search_company_article(company_name, website_url) + elif not self._get_cell_value(row_data, "Wikipedia Timestamp").strip(): # Nur wenn AN fehlt und S nicht 'X(Copied)' ist, UND kein reeval + if url_in_m and url_in_m.lower() not in ["k.a.", "kein artikel gefunden"] and url_in_m.lower().startswith("http"): + logging.debug(f" -> AN fehlt, prüfe Validität der URL aus M: {url_in_m}") + try: + # Extrahiere Titel für wikipedia.page (kann fehlschlagen) + title_from_url = url_in_m.split('/wiki/')[-1].replace('_', ' ') + # Nutze self.wiki_scraper Methode für Validierung + if self.wiki_scraper._validate_article(None, company_name, website_url_crm): # validate_article braucht page obj, aber wir haben nur URL + # Das ist kompliziert. is_valid_wikipedia_article_url prüft ob es ein Artikel ist. + # validate_article prüft ob der Artikel ZUR FIRMA passt. + # Wenn AN fehlt und M da ist: Prüfen, ob M auf einen validen Artikel verweist, UND ob dieser Artikel zur Firma passt. + # is_valid_wikipedia_article_url (global) prüft nur auf Artikel. + # validate_article (scraper Methode) prüft auf Passung zur Firma. + # Ideal wäre: check_page() aus search_company_article, die beides tut. + # Da check_page lokal ist: Duplizieren wir die Logik hier oder machen check_page zu einer scraper Methode. + # Machen wir check_page zu einer Scraper Methode. + + # NEU: Rufe Scraper Methode auf, die URL prüft UND validiert + validated_page = self.wiki_scraper.check_url_and_validate(url_in_m, company_name, website_url_crm) # <<< NEUE SCRAPER METHODE NÖTIG + if validated_page: + url_to_extract = validated_page.url + logging.info(f" -> Vorhandene URL aus M '{url_to_extract}' ist valide und passt zur Firma.") + else: + logging.warning(f" -> Vorhandene URL aus M '{url_in_m}' ist NICHT valide oder passt nicht zur Firma. Starte neue Suche...") + search_was_needed = True + + except Exception as e_val_m: # Fängt Fehler bei URL parsing oder check_url_and_validate ab + logging.warning(f" -> Fehler/Problem bei Prüfung der URL aus M '{url_in_m}': {type(e_val_m).__name__} - {e_val_m}. Starte neue Suche...") + search_was_needed = True + + else: + logging.info(f" -> AN fehlt und M leer/ungültig. Starte Wikipedia-Suche für '{company_name}'...") + search_was_needed = True + + # Führe Suche aus, wenn search_was_needed True ist + if search_was_needed: + # Nutze self.wiki_scraper + validated_page = self.wiki_scraper.search_company_article(company_name, website_url_crm) # Nutze CRM Website URL if validated_page: url_to_extract = validated_page.url else: final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'} wiki_data_updated_in_this_run = True - - # Datenextraktion, wenn eine URL bestimmt wurde - if url_to_extract and url_to_extract != 'Kein Artikel gefunden': - logging.info(f" -> Extrahiere Daten von URL: {url_to_extract}...") - # Annahme: self.wiki_scraper.extract_company_data existiert und nutzt logging - extracted_data = self.wiki_scraper.extract_company_data(url_to_extract) - if extracted_data: - final_wiki_data = extracted_data - wiki_data_updated_in_this_run = True # Markieren, dass extrahierte Daten da sind - logging.info(f" -> Datenextraktion erfolgreich.") - else: - logging.error(f" -> Fehler bei Datenextraktion von {url_to_extract}. Setze Daten auf 'k.A.'") - # Behalte die URL, aber setze alle anderen Felder auf k.A. - final_wiki_data = {'url': url_to_extract, 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'} - wiki_data_updated_in_this_run = True # Markieren, dass überschrieben wird - - # Sheet Updates für M-R und AN (nur wenn Wiki-Verarbeitung lief) - if wiki_processing_needed: # Hier wurde bereits geprüft, ob Wiki-Verarbeitung notwendig war - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki URL"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('url', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Absatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('first_paragraph', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Branche"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('branche', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('umsatz', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('mitarbeiter', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Kategorien"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('categories', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wikipedia Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # AN Timestamp setzen - -# Setze S ('Chat Wiki Konsistenzprüfung') zurück, wenn eine Neubewertung nötig ist: - # - Immer bei force_reeval - # - Wenn die URL in M geändert wurde (entweder durch Suche oder weil M vorher leer war) - # - Wenn Status S zuvor "X (URL Copied)" war - url_changed = (url_in_m != final_wiki_data.get('url')) # Prüft ob die NEUE URL anders ist als die ursprünglich in M - - # Prüfen, ob das Zurücksetzen des Status S überhaupt notwendig ist - if force_reeval or status_s_indicates_reparse or url_changed: - s_idx = COLUMN_MAP.get("Chat Wiki Konsistenzprüfung") - if s_idx is not None: - s_let = self.sheet_handler._get_col_letter(s_idx + 1) - # Füge das Update zum Zurücksetzen von S hinzu - updates.append({'range': f'{s_let}{row_num_in_sheet}', 'values': [["?"]]}) # Fragezeichen für Neubewertung - - # Bestimme den Grund-String VOR dem Logging-Aufruf - grund_message_parts = [] - if force_reeval: - grund_message_parts.append('Re-Eval') - # Beachten Sie: Hier verwenden wir einen normalen String, KEINEN f-String, - # für den Text "S='X (URL Copied)'". Wir escapen die einfachen Anführungszeichen - # nicht, weil wir die äußeren Anführungszeichen des String-Literals ändern (auf doppelt), - # oder wir lassen die einfachen Anführungszeichen einfach unescaped im String. - # Letzteres ist in einem normalen String erlaubt. - if status_s_indicates_reparse: - grund_message_parts.append("S='X (URL Copied)'") # Nutzen Sie doppelte Anführungszeichen außen - if url_changed: - grund_message_parts.append('URL geändert') - - # Verbinde die Gründe, falls mehrere zutreffen - grund_message = ", ".join(grund_message_parts) - - # Logge nun mit dem vorbereiteten Grund-String - logging.info(f" -> Status S zurückgesetzt auf '?' für erneute Verifikation (Grund: {grund_message}).") - - # else: # Diesen else-Zweig gab es vorher nicht, ist auch nicht nötig - # logging.debug(f"Zeile {row_num_in_sheet}: Status S nicht zurückgesetzt (AN da, S nicht X(Copied), kein Re-Eval).") + logging.warning(f" -> Wikipedia Suche für '{company_name}' fand keinen validen Artikel.") - elif process_wiki: # Dieser elif-Zweig gehört weiterhin zum if wiki_processing_needed: Block - logging.debug(f"Zeile {row_num_in_sheet}: Überspringe Wikipedia Verarbeitung (AN vorhanden, kein S='X (URL Copied)' und kein Re-Eval).") + # Datenextraktion, wenn eine URL zum Extrahieren bestimmt wurde (kann auch "Kein Artikel gefunden" sein) + if url_to_extract: # Dies ist der URL, der *jetzt* in M stehen sollte (oder Kein Artikel gefunden) + logging.info(f" -> Extrahiere Daten von URL: {url_to_extract}...") + # Prüfe, ob die URL überhaupt valide ist, bevor extrahiert wird + if url_to_extract != 'Kein Artikel gefunden' and url_to_extract.lower().startswith("http"): + # Nutze self.wiki_scraper + extracted_data = self.wiki_scraper.extract_company_data(url_to_extract) + if extracted_data: + # Aktualisiere die lokale Variable final_wiki_data + final_wiki_data.update(extracted_data) + wiki_data_updated_in_this_run = True + logging.info(f" -> Datenextraktion erfolgreich.") + else: + logging.error(f" -> Fehler bei Datenextraktion von {url_to_extract}. Setze extrahierte Daten auf 'k.A.'") + # URL bleibt, aber extrahierte Felder werden auf k.A. gesetzt + final_wiki_data.update({'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}) + wiki_data_updated_in_this_run = True # Markiere als aktualisiert, auch wenn mit k.A. + else: + # Wenn url_to_extract "Kein Artikel gefunden" ist oder ungültig, setze extrahierte Felder auf k.A. + # URL (M) wird ja oben schon gesetzt + logging.info(f" -> Keine gültige URL zum Extrahieren ({url_to_extract}). Setze extrahierte Daten auf 'k.A.'.") + final_wiki_data.update({'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}) + # wiki_data_updated_in_this_run = True # Bereits oben gesetzt, wenn search_was_needed - # --- 3. ChatGPT Evaluationen (Branch etc.) (Prüft AO oder force_reeval oder wiki_data_updated_in_this_run) --- - chat_ts_ao_missing = not get_cell_value("Timestamp letzte Prüfung").strip() - # Chat Evaluationen notwendig, wenn: - # - process_chatgpt True ist UND - # ( force_reeval True ist ODER Timestamp AO fehlt ODER Wiki Daten gerade aktualisiert wurden ) - run_chat_eval = process_chatgpt and (force_reeval or chat_ts_ao_missing or wiki_data_updated_in_this_run) - - if run_chat_eval: - any_processing_done = True - logging.info(f"Zeile {row_num_in_sheet}: Starte ChatGPT Evaluationen (Grund: {'Re-Eval' if force_reeval else f'AO fehlt? {chat_ts_ao_missing}, Wiki gerade aktualisiert? {wiki_data_updated_in_this_run}'})...") - - # Annahme: evaluate_branche_chatgpt existiert und nutzt logging/retry - # Nutze die (ggf. neu extrahierten) final_wiki_data - branch_result = evaluate_branche_chatgpt( - crm_branche, crm_beschreibung, - final_wiki_data.get('branche', 'k.A.'), - final_wiki_data.get('categories', 'k.A.'), - website_summary - ) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('branch', 'Fehler')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenz Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('consistency', 'Fehler')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('justification', 'Fehler')]]}) - - # --- Hier Platz für weitere ChatGPT-Calls, die AO als Trigger nutzen --- - # z.B. FSM Relevanz, Mitarbeiter/Umsatz Schätzung etc. - # Stelle sicher, dass diese Funktionen existieren und die benötigten Daten nutzen - - # Beispiel (Pseudo-Code, implementiere diese Funktionen falls nötig): - # fsm_result = evaluate_fsm_suitability(company_name, {'wiki': final_wiki_data, 'web_summary': website_summary, 'crm_desc': crm_beschreibung}) - # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Prüfung FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[fsm_result.get('suitability', 'Fehler')]]}) - # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung für FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[fsm_result.get('justification', 'Fehler')]]}) - - # emp_estimate_result = evaluate_employee_chatgpt(...) - # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schätzung Anzahl Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[emp_estimate_result.get('estimate', 'Fehler')]]}) - # ... etc. - - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Timestamp letzte Prüfung"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # AO Timestamp setzen - - elif process_chatgpt: - logging.debug(f"Zeile {row_num_in_sheet}: Überspringe ChatGPT Evaluationen (AO vorhanden, Wiki nicht gerade aktualisiert und kein Re-Eval).") + # Sheet Updates für M-R und AN (nur wenn dieser Wiki Search/Extract Schritt lief) + # Diese Updates spiegeln die final_wiki_data am Ende dieses Blocks wider. + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki URL"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('url', 'k.A.')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Absatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('first_paragraph', 'k.A.')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Branche"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('branche', 'k.A.')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('umsatz', 'k.A.')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('mitarbeiter', 'k.A.')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Kategorien"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('categories', 'k.A.')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wikipedia Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # AN Timestamp setzen - # --- 4. Abschließende Updates --- + # Setze S ('Chat Wiki Konsistenzprüfung') und AX zurück, wenn eine Neubewertung nötig ist + # S und AX werden durch die Wiki Verify Batch Logik gesetzt/geprüft. + # Hier setzen wir S und AX nur zurück, wenn sich die URL geändert hat ODER force_reeval war + # ODER S vorher X(Copied) war. Das triggert die Wiki Verify Batch Logik später. + # Lese die URL, die ZU BEGINN in M stand, für diesen Vergleich + url_changed = (self._get_cell_value(row_data, "Wiki URL").strip() != final_wiki_data.get('url', 'k.A.')) + if force_reeval or konsistenz_s.upper() == "X (URL COPIED)" or url_changed: # konsistenz_s ist der Wert ZU BEGINN + s_idx = COLUMN_MAP.get("Chat Wiki Konsistenzprüfung"); ax_idx = COLUMN_MAP.get("Wiki Verif. Timestamp") + if s_idx is not None: + s_let = self.sheet_handler._get_col_letter(s_idx + 1) + updates.append({'range': f'{s_let}{row_num_in_sheet}', 'values': [["?"]]}) # Fragezeichen für Neubewertung + grund_message_parts = [] + if force_reeval: grund_message_parts.append('Re-Eval') + if konsistenz_s.upper() == "X (URL COPIED)": grund_message_parts.append("S='X (URL Copied)'") + if url_changed: grund_message_parts.append('URL geändert') + grund_message = ", ".join(grund_message_parts) + logging.info(f" -> Status S zurückgesetzt auf '?' für erneute Verifikation (Grund: {grund_message}).") + if ax_idx is not None: + ax_let = self.sheet_handler._get_col_letter(ax_idx + 1) + updates.append({'range': f'{ax_let}{row_num_in_sheet}', 'values': [['']]}) # AX leeren, triggert Wiki Verify Batch + + + # --- 3. Wikipedia Verifizierung (S-U, AX) --- + # Dies ist ein UNTER-Schritt der Wiki-Verarbeitungsgruppe. + # Er wird ausgeführt, wenn die GRUPPE "Wiki" ausgewählt ist (process_wiki=True) + # UND (_is_step_processing_needed für AX ODER Wiki Daten gerade aktualisiert wurden) + # Wir müssen hier prüfen, ob der spezifische Verify-Schritt ausgewählt ist, falls wir granularer steuern wollen. + # Mit den aktuellen Flags (process_wiki, process_chatgpt, process_website) ist Verify Teil von process_wiki. + # Also: Wenn process_wiki True UND (_is_step_processing_needed für AX ODER wiki_data_updated_in_this_run) + + wiki_verify_needed = process_wiki and self._is_step_processing_needed(row_data, "Wiki Verif. Timestamp", force_reeval, wiki_data_updated_in_this_run) + + + if wiki_verify_needed: + any_processing_done = True + logging.info(f"Zeile {row_num_in_sheet}: Starte Wikipedia Verifizierung (Grund: {'Re-Eval' if force_reeval else f'AX fehlt oder Wiki Daten aktualisiert ({wiki_data_updated_in_this_run})'})...") + + # Hier ist die Logik, die den ChatGPT-Call für die Verifizierung macht (zeilenweise) + # Annahme: call_openai_chat ist global mit Retry + # Annahme: COLUMN_MAP Indizes für S, T, U sind vorhanden + + # Daten für die Verifizierung sammeln (nutze final_wiki_data) + company_name = self._get_cell_value(row_data, "CRM Name") + crm_desc = self._get_cell_value(row_data, "CRM Beschreibung") + + entry_text = ( + f"Eintrag {row_num_in_sheet}:\n" + f" Firmenname: {company_name}\n" + f" CRM-Beschreibung: {crm_desc[:200]}...\n" + f" Wikipedia-URL: {final_wiki_data.get('url', 'k.A.')}\n" # Nutze final_wiki_data + f" Wiki-Absatz: {final_wiki_data.get('first_paragraph', 'k.A.')[:200]}...\n" # Nutze final_wiki_data + f" Wiki-Kategorien: {final_wiki_data.get('categories', 'k.A.')[:200]}...\n" # Nutze final_wiki_data + f"----\n" + ) + + # Prompt für EINE Zeile erstellen + prompt = ( # ... Prompt Definition wie oben in Teil 3 ... ) + "Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln für Unternehmen.\n" + "Prüfe, ob der folgende Wikipedia-Artikel plausibel zum Firmennamen und zur Beschreibung passt.\n" + "Gib das Ergebnis ausschließlich im folgenden Format aus:\n" + "Antwort: \n\n" + "Mögliche Antworten (Kurzform):\n" + "- 'OK' (wenn der Artikel gut passt)\n" + "- 'X | Alternativer Artikel: | Begründung: '\n" + "- 'X | Kein passender Artikel gefunden | Begründung: '\n\n" + "Eintrag:\n" + "----------\n" + f"{entry_text}" + "----------\nBitte nur die 'Antwort: ...'-Zeile ausgeben." + ) + + chat_response = call_openai_chat(prompt, temperature=0.0) + + wiki_confirm = ""; alt_article = ""; wiki_explanation = "" + + if chat_response: + match = re.match(r"Antwort: (.*)", chat_response.strip()) + if match: + answer_text = match.group(1).strip() + logging.debug(f"Zeile {row_num_in_sheet} Verifizierungsantwort: '{answer_text}'") + if answer_text.upper() == "OK": wiki_confirm = "OK" + elif answer_text.startswith("X |"): + parts = answer_text.split("|", 2) + wiki_confirm = "X" + if len(parts) > 1: detail = parts[1].strip(); + if len(parts) > 2: wiki_explanation = parts[2].split(":", 1)[1].strip() if parts[2].strip().startswith("Begründung:") else parts[2].strip() + # Anpassung: T ist Begründung, U ist Vorschlag + if detail.startswith("Alternativer Artikel:"): alt_article = detail.split(":", 1)[1].strip() + elif detail == "Kein passender Artikel gefunden": alt_article = detail + else: wiki_explanation = f"Unerwartetes X-Detail: {detail}" # Wenn Detail nicht URL/Kein gefunden, setze als Begründung + alt_article = alt_article or "" # Sicherstellen, dass alt_article ein String ist + wiki_explanation = wiki_explanation or "" # Sicherstellen, dass explanation String ist + + else: wiki_confirm, wiki_explanation = "?", f"Unerwartetes Format: {answer_text[:100]}"; alt_article = "" + else: wiki_confirm = "?"; wiki_explanation = f"Parsing Fehler: {chat_response[:100]}"; alt_article = "" + logging.error(f"Zeile {row_num_in_sheet}: Parsing Fehler für Verifizierungsantwort: {chat_response}") + else: wiki_confirm = "Fehler"; wiki_explanation = "API Fehler oder keine Antwort"; alt_article = "" + logging.error(f"Zeile {row_num_in_sheet}: API Fehler oder keine Antwort für Verifizierungs-Prompt.") + + + # Füge Updates für S, T, U hinzu (basierend auf Spaltenbeschreibung: S=Konstistenz, T=Begründung, U=Vorschlag) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Wiki Konsistenzprüfung"] + 1)}{row_num_in_sheet}', 'values': [[wiki_confirm]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Wiki Inkonsistenz"] + 1)}{row_num_in_sheet}', 'values': [[wiki_explanation]]}) # T ist Begründung + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Wiki Artikel"] + 1)}{row_num_in_sheet}', 'values': [[alt_article]]}) # U ist Vorschlag + + # Setze AX Timestamp, wenn dieser Schritt gemacht wurde + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Verif. Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) + + + # --- 4. ChatGPT Evaluationen (Branch W-Y, FSM Z-AA, MA AB-AD, Umsatz AG-AH) --- + # Dieser Block wird nur ausgeführt, wenn die GRUPPE "ChatGPT" ausgewählt ist + if process_chatgpt: + + # Branch Evaluation (W-Y) ist nötig, wenn: (_is_step_processing_needed für AO ODER Inputs (Wiki/Web) wurden aktualisiert) + chat_ts_ao_missing = not self._get_cell_value(row_data, "Timestamp letzte Prüfung").strip() + branch_eval_needed = self._is_step_processing_needed(row_data, "Timestamp letzte Prüfung", force_reeval, wiki_data_updated_in_this_run or website_data_updated_in_this_run) + + if branch_eval_needed: + any_processing_done = True + logging.info(f"Zeile {row_num_in_sheet}: Starte Branchen Evaluation (Grund: {'Re-Eval' if force_reeval else f'AO fehlt oder Inputs aktualisiert ({wiki_data_updated_in_this_run or website_data_updated_in_this_run})'})...") + + # Annahme: evaluate_branche_chatgpt existiert (global) und nutzt logging/retry + # Nutze die (ggf. neu extrahierten) final_wiki_data und final_website_summary + branch_result = evaluate_branche_chatgpt( + crm_branche, crm_beschreibung, + final_wiki_data.get('branche', 'k.A.'), # Nutze final_wiki_data + final_wiki_data.get('categories', 'k.A.'), # Nutze final_wiki_data + final_website_summary # Nutze final_website_summary + ) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('branch', 'Fehler')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenz Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('consistency', 'Fehler')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('justification', 'Fehler')]]}) + + # Setze AO Timestamp, wenn Branch Evaluation gemacht wurde + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Timestamp letzte Prüfung"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) + + + # --- Weitere ChatGPT-Schätzungen und Konsistenzprüfungen --- + # Diese laufen JETZT mit, wenn die GRUPPE "ChatGPT" ausgewählt war UND Branch Eval nötig war. + # Im Refactoring werden dies granularere, wählbare Schritte mit eigenen Flags. + + # FSM Evaluation (Z-AA) + # Annahme: evaluate_fsm_suitability existiert (global) und nutzt logging/retry + fsm_result = evaluate_fsm_suitability(company_name, {'wiki': final_wiki_data, 'web_summary': final_website_summary, 'crm_desc': crm_beschreibung}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Prüfung FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[fsm_result.get('suitability', 'Fehler')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung für FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[fsm_result.get('justification', 'Fehler')]]}) + + # Mitarbeiterzahl Schätzung (AB) + # Annahme: process_employee_estimation existiert (global) und nutzt logging/retry + # Benötigt Wiki Paragraph, CRM Employee als Input + estimated_emp_value_str = process_employee_estimation(company_name, final_wiki_data.get('first_paragraph', 'k.A.'), self._get_cell_value(row_data, "CRM Anzahl Mitarbeiter")) # Ergebnis wird in AB geschrieben + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schätzung Anzahl Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[estimated_emp_value_str]]}) # Annahme: gibt String zurück + + + # Mitarbeiter Konsistenzprüfung (AC, AD) + # Annahme: process_employee_consistency existiert (global) + # Braucht CRM, Wiki, und geschätzte MA (nehme den geschätzten Wert aus updates oder row_data, hier updates besser) + # Finden Sie den geschätzten Wert aus den Updates, falls vorhanden, sonst nehmen Sie den alten Wert aus row_data + estimated_emp_value_for_consistency = next((item['values'][0][0] for item in updates if item['range'].startswith(self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schätzung Anzahl Mitarbeiter"] + 1))), self._get_cell_value(row_data, "Chat Schätzung Anzahl Mitarbeiter")) + emp_consistency = process_employee_consistency(self._get_cell_value(row_data, "CRM Anzahl Mitarbeiter"), final_wiki_data.get('mitarbeiter', 'k.A.'), estimated_emp_value_for_consistency) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenzprüfung Mitarbeiterzahl"] + 1)}{row_num_in_sheet}', 'values': [[emp_consistency.get('consistency', 'Fehler')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Mitarbeiterzahl"] + 1)}{row_num_in_sheet}', 'values': [[emp_consistency.get('justification', 'Fehler')]]}) + + + # Umsatz Schätzung (AG) + # Annahme: evaluate_umsatz_chatgpt existiert (global) + # Benötigt Wiki Umsatz (aus extrahierten Daten) als Input + estimated_umsatz_value_str = evaluate_umsatz_chatgpt(company_name, final_wiki_data.get('umsatz', 'k.A.')) # Ergebnis wird in AG geschrieben + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schätzung Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[estimated_umsatz_value_str]]}) # Annahme: gibt String zurück + + + # Umsatz Konsistenzprüfung (AH) + # Annahme: evaluate_umsatz_chatgpt_consistency existiert (global) + # Braucht CRM, Wiki, und geschätzten Umsatz + estimated_umsatz_value_for_consistency = next((item['values'][0][0] for item in updates if item['range'].startswith(self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schätzung Umsatz"] + 1))), self._get_cell_value(row_data, "Chat Schätzung Umsatz")) + umsatz_consistency = evaluate_umsatz_chatgpt_consistency(self._get_cell_value(row_data, "CRM Umsatz"), final_wiki_data.get('umsatz', 'k.A.'), estimated_umsatz_value_for_consistency) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[umsatz_consistency.get('justification', 'Fehler')]]}) + + # --- 5. ML Schätzung Servicetechniker (AU) --- + # Dieses sollte ein separater Prozess sein, der NACHDEM die Inputs (W, AV, AW) verfügbar sind, läuft. + # Also NICHT hier in _process_single_row. + + + # --- 6. Abschließende Updates --- # Version wird gesetzt, wenn IRGENDEINE Verarbeitung in dieser Zeile stattgefunden hat if any_processing_done: - # Annahme: Config ist verfügbar updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Version"] + 1)}{row_num_in_sheet}', 'values': [[Config.VERSION]]}) - # --- 5. Batch Update für diese Zeile --- + # --- 7. Batch Update für diese Zeile --- if updates: - # Info-Log über die Anzahl der Updates für diese spezifische Zeile logging.info(f"Zeile {row_num_in_sheet}: Sende Batch-Update mit {len(updates)} Operationen für diese Zeile...") - success = self.sheet_handler.batch_update_cells(updates) # Annahme: batch_update_cells nutzt logging intern - if not success: - logging.error(f"Zeile {row_num_in_sheet}: FEHLER beim Batch-Update.") + success = self.sheet_handler.batch_update_cells(updates) # Nutze self.sheet_handler + if not success: logging.error(f"Zeile {row_num_in_sheet}: FEHLER beim Batch-Update.") else: - # Info-Log, wenn nichts zu tun war if not any_processing_done: logging.info(f"Zeile {row_num_in_sheet}: Keine Updates zum Schreiben (alle relevanten Schritte übersprungen).") - logging.info(f"--- Verarbeitung für Zeile {row_num_in_sheet} abgeschlossen ---") - # Kleine Pause nach der Verarbeitung jeder Zeile, um API-Limits zu respektieren, auch bei sequenziellen Modi - # Der Wert kann in Config angepasst werden. 0.1s ist sehr kurz, 0.5-1.0s ist realistischer. - # Annahme: Config.RETRY_DELAY ist in Sekunden, also durch 10 oder 20 teilen - # logging.debug(f"Wartezeit nach Zeile {row_num_in_sheet}: {max(0.1, getattr(Config, 'RETRY_DELAY', 5) / 20):.2f}s") + # Kleine Pause nach der Verarbeitung jeder Zeile, um API-Limits zu respektieren time.sleep(max(0.1, getattr(Config, 'RETRY_DELAY', 5) / 20)) + # --- Private Helfer für Timestamp/Status Checks --- + # Diese werden von _process_single_row aufgerufen. + # Stellen Sie sicher, dass diese Methoden IN der Klasse DataProcessor sind. + + def _get_cell_value(self, row_data, key): # Implementierung wurde oben kopiert, muss hier in die Klasse + """Lokale Hilfsfunktion zum sicheren Zugriff auf Zellwerte innerhalb von Methoden, die row_data als Parameter erhalten.""" + idx = COLUMN_MAP.get(key) + if idx is not None and len(row_data) > idx: + return row_data[idx] if row_data[idx] is not None else '' + return "" + + def _is_step_processing_needed(self, row_data, step_key, force_reeval, related_inputs_updated=False): # Implementierung wurde oben kopiert, muss hier in die Klasse + """ + Prüft, ob ein spezifischer Verarbeitungsschritt für diese Zeile ausgeführt werden soll, + basierend auf Timestamp, force_reeval und ob Eingangsdaten aktualisiert wurden. + """ + if force_reeval: return True + if step_key is None: return related_inputs_updated # Ohne Timestamp, nur wenn Inputs neu + + timestamp_col_index = COLUMN_MAP.get(step_key) + if timestamp_col_index is None: logging.error(f" -> Step Check Fehler: Timestamp Schlüssel '{step_key}' nicht in COLUMN_MAP gefunden."); return False + + ts_value = row_data[timestamp_col_index] if len(row_data) > timestamp_col_index else "" + ts_is_set = bool(str(ts_value).strip()) + + needs_processing = not ts_is_set or related_inputs_updated + return needs_processing + + # _is_wiki_search_extract_needed Helfer (spezifisch für AN & S='X (URL Copied)') + def _is_wiki_search_extract_needed(self, row_data, force_reeval): # related_inputs_updated hier nicht relevant + """Prüft, ob Wikipedia Search/Extraction nötig ist (AN Timestamp oder S='X (URL Copied)' oder force_reeval).""" + # Wiki Search/Extraction ist nötig, wenn: force_reeval ODER AN fehlt ODER S='X (URL Copied)' + # Nutze private Helfermethode _get_cell_value + wiki_ts_an_missing = not self._get_cell_value(row_data, "Wikipedia Timestamp").strip() + status_s_indicates_reparse = self._get_cell_value(row_data, "Chat Wiki Konsistenzprüfung").strip().upper() == "X (URL COPIED)" + + return force_reeval or wiki_ts_an_missing or status_s_indicates_reparse + + # _is_wiki_verification_needed Helfer (spezifisch für AX) + def _is_wiki_verification_needed(self, row_data, force_reeval, wiki_data_updated_in_this_run): # Abhängig von wiki_data_updated_in_this_run + """Prüft, ob Wikipedia Verifizierung nötig ist (AX Timestamp oder Wiki Daten aktualisiert).""" + # Wiki Verifizierung (S-U, AX) ist nötig, wenn: force_reeval ODER AX fehlt ODER Wiki Daten gerade aktualisiert wurden + return self._is_step_processing_needed(row_data, "Wiki Verif. Timestamp", force_reeval, wiki_data_updated_in_this_run) + + # _is_branch_evaluation_needed Helfer (spezifisch für AO) + def _is_branch_evaluation_needed(self, row_data, force_reeval, inputs_updated_in_this_run): # Abhängig von wiki_data_updated_in_this_run ODER website_data_updated_in_this_run + """Prüft, ob Branch Evaluation nötig ist (AO Timestamp oder Inputs (Wiki/Web) aktualisiert).""" + # Branch Evaluation ist nötig, wenn: force_reeval ODER AO fehlt ODER Inputs (Wiki/Web) wurden aktualisiert + return self._is_step_processing_needed(row_data, "Timestamp letzte Prüfung", force_reeval, inputs_updated_in_this_run) + + # Fügen Sie hier weitere _is_xxx_needed Methoden für andere Schritte hinzu (FSM, MA, Umsatz Schätzung) + # Diese prüfen jeweils ihren spezifischen Trigger (eigenen Timestamp ODER Inputs). + # Z.B. FSM hat keinen eigenen TS, wird getriggert wenn Branch Eval inputs (Wiki/Web) aktualisiert ODER Branch Eval selbst neu gemacht wurde. + # --- Methode für den Re-Eval Modus --- # Diese Methode gehört in die Klasse def process_reevaluation_rows(self, row_limit=None, clear_flag=True, - # NEUE PARAMETER hinzugefügt: - process_wiki_steps=True, - process_chatgpt_steps=True, - process_website_steps=True): + process_wiki_steps=True, # <-- Flags als Parameter + process_chatgpt_steps=True, # <-- Flags als Parameter + process_website_steps=True): # <-- Flags als Parameter """ Verarbeitet nur Zeilen, die in Spalte A mit 'x' markiert sind. Ruft _process_single_row für jede dieser Zeilen auf mit force_reeval=True. @@ -4402,26 +2456,22 @@ class DataProcessor: Args: row_limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None. clear_flag (bool, optional): Flag 'x' nach erfolgreicher Verarbeitung löschen. Defaults to True. - process_wiki_steps (bool, optional): Soll der Wiki-Schritt in _process_single_row ausgeführt werden?. Defaults to True. - process_chatgpt_steps (bool, optional): Sollen ChatGPT-Schritte in _process_single_row ausgeführt werden?. Defaults to True. - process_website_steps (bool, optional): Soll der Website-Schritt in _process_single_row ausgeführt werden?. Defaults to True. - # Fügen Sie hier ggf. weitere Parameter hinzu, wenn Sie granularere Schritte in _process_single_row haben. + process_wiki_steps (bool, optional): Soll die Gruppe Wiki-Schritte ausgeführt werden?. Defaults to True. + process_chatgpt_steps (bool, optional): Soll die Gruppe ChatGPT-Schritte ausgeführt werden?. Defaults to True. + process_website_steps (bool, optional): Soll die Gruppe Website-Schritte ausgeführt werden?. Defaults to True. """ logging.info(f"Starte Re-Evaluierungsmodus (Spalte A = 'x'). Max. Zeilen: {row_limit if row_limit is not None else 'Unbegrenzt'}") - # Logge, welche Schritte für Re-Eval ausgewählt wurden selected_steps_log = [] if process_wiki_steps: selected_steps_log.append("Wiki") if process_chatgpt_steps: selected_steps_log.append("ChatGPT") if process_website_steps: selected_steps_log.append("Website") logging.info(f"Ausgewählte Schritte für Re-Eval: {', '.join(selected_steps_log) if selected_steps_log else 'Keine ausgewählt!'} (force_reeval=True)") - - # Daten neu laden vor der Verarbeitung - # ... (Code zum Laden der Daten, Finden der x-markierten Zeilen wie gehabt) ... if not self.sheet_handler.load_data(): return logging.error("Fehler beim Laden der Daten für Re-Evaluation.") all_data = self.sheet_handler.get_all_data_with_headers() header_rows = 5 if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten für Re-Evaluation gefunden.") + reeval_col_idx = COLUMN_MAP.get("ReEval Flag") if reeval_col_idx is None: return logging.critical("FEHLER: 'ReEval Flag' Spaltenindex nicht in COLUMN_MAP gefunden.") @@ -4436,20 +2486,19 @@ class DataProcessor: logging.info(f"{found_count} Zeilen mit ReEval-Flag 'x' gefunden.") if found_count == 0: return logging.info("Keine Zeilen zur Re-Evaluation markiert.") - processed_count = 0 updates_clear_flag = [] rows_actually_processed = [] for task in rows_to_process: - if row_limit is not None and processed_count >= row_limit: - logging.info(f"Zeilenlimit ({row_limit}) für Re-Evaluation erreicht. Breche weitere Verarbeitung ab.") + if limit is not None and processed_count >= limit: + logging.info(f"Zeilenlimit ({limit}) für Re-Evaluation erreicht. Breche weitere Verarbeitung ab.") break row_num = task['row_num'] row_data = task['data'] try: - # RUFE _process_single_row MIT DEN NEUEN PARAMETERN AUF: + # Rufe _process_single_row mit force_reeval=True und den ausgewählten Flags auf self._process_single_row( row_num_in_sheet = row_num, row_data = row_data, @@ -4461,219 +2510,1791 @@ class DataProcessor: processed_count += 1 rows_actually_processed.append(row_num) - # Vorbereiten des Updates zum Löschen des 'x'-Flags (wie gehabt) if clear_flag: flag_col_letter = self.sheet_handler._get_col_letter(reeval_col_idx + 1) if flag_col_letter: updates_clear_flag.append({'range': f'{flag_col_letter}{row_num}', 'values': [['']]}) else: logging.error(f"Fehler: Konnte Spaltenbuchstaben für 'ReEval Flag' ({reeval_col_idx+1}) nicht ermitteln.") - except Exception as e_proc: - logging.exception(f"FEHLER bei Re-Evaluation von Zeile {row_num}: {e_proc}") + except Exception as e_proc: logging.exception(f"FEHLER bei Re-Evaluation von Zeile {row_num}: {e_proc}") - # Lösche Flags am Ende (wie gehabt) if clear_flag and updates_clear_flag: logging.info(f"Lösche ReEval-Flags für {len(updates_clear_flag)} erfolgreich verarbeitete Zeilen ({rows_actually_processed})...") success = self.sheet_handler.batch_update_cells(updates_clear_flag) if success: logging.info("ReEval-Flags erfolgreich gelöscht.") else: logging.error("FEHLER beim Löschen der ReEval-Flags.") + logging.info(f"Re-Evaluierung abgeschlossen. {processed_count} Zeilen verarbeitet (Limit war: {limit}, Gefunden: {found_count}).") - logging.info(f"Re-Evaluierung abgeschlossen. {processed_count} Zeilen verarbeitet (Limit war: {row_limit}, Gefunden: {found_count}).") - - - # --- Methode für SERP API Website Lookup --- + # --- Methode für sequenzielle Verarbeitung (full_run) --- # Diese Methode gehört in die Klasse - def process_serp_website_lookup_for_empty(self): + def process_sequential(self, start_sheet_row, num_to_process, + process_wiki=True, process_chatgpt=True, process_website=True): + """ + Verarbeitet eine feste Anzahl von Zeilen beginnend bei einer bestimmten Sheet-Zeile + sequenziell, eine nach der anderen, unter Verwendung von _process_single_row. + _process_single_row prüft dabei die Timestamps/Status (force_reeval=False). + + Args: + start_sheet_row (int): Die 1-basierte Startzeilennummer im Sheet. + num_to_process (int): Die maximale Anzahl der zu verarbeitenden Zeilen. + process_wiki (bool, optional): Soll die Gruppe Wiki-Schritte ausgeführt werden?. Defaults to True. + process_chatgpt (bool, optional): Soll die Gruppe ChatGPT-Schritte ausgeführt werden?. Defaults to True. + process_website (bool, optional): Soll die Gruppe Website-Schritte ausgeführt werden?. Defaults to True. + """ + header_rows = 5 # Annahme + + logging.info(f"Starte sequenzielle Verarbeitung von {num_to_process} Zeilen ab Sheet-Zeile {start_sheet_row}...") + groups_to_attempt_log = [] + if process_website: groups_to_attempt_log.append("Website") + if process_wiki: groups_to_attempt_log.append("Wiki") + if process_chatgpt: groups_to_attempt_log.append("ChatGPT") + logging.info(f"Ausgewählte Schritte: {', '.join(groups_to_attempt_log) if groups_to_attempt_log else 'Keine ausgewählt!'} (Standard-Timestamp/Status-Logik)") + + + if not self.sheet_handler.load_data(): logging.error("Fehler beim Laden der Daten für sequenzielle Verarbeitung."); return + all_data = self.sheet_handler.get_all_data_with_headers() + total_sheet_rows = len(all_data) + + if start_sheet_row > total_sheet_rows or start_sheet_row <= header_rows: + logging.warning(f"Start-Sheet-Zeile {start_sheet_row} liegt außerhalb des gültigen Datenbereichs ({header_rows+1} bis {total_sheet_rows}). Keine Verarbeitung.") + return + + end_sheet_row_inclusive = min(start_sheet_row + num_to_process - 1, total_sheet_rows) + + logging.info(f"Sequenzielle Verarbeitung geplant für Sheet-Zeilen {start_sheet_row} bis {end_sheet_row_inclusive}.") + if start_sheet_row > end_sheet_row_inclusive: logging.warning("Start nach Ende (berechnet nach Limit). Keine Verarbeitung."); return + + processed_count = 0 + for i in range(start_sheet_row, end_sheet_row_inclusive + 1): + row_num_in_sheet = i + row_data = all_data[i - 1] + + try: + self._process_single_row( + row_num_in_sheet = row_num_in_sheet, + row_data = row_data, + process_wiki = process_wiki, # <<< ÜBERGIBT DIE STEUERUNG + process_chatgpt = process_chatgpt, # <<< ÜBERGIBT DIE STEUERUNG + process_website = process_website, # <<< ÜBERGIBT DIE STEUERUNG + force_reeval = False # <<< WICHTIG: Standard-Timestamp/Status-Logik + ) + processed_count += 1 + + except Exception as e_proc: logging.exception(f"FEHLER bei sequenzieller Verarbeitung von Zeile {row_num_in_sheet}: {e_proc}") + + logging.info(f"Sequenzielle Verarbeitung abgeschlossen. {processed_count} Zeilen bearbeitet im Bereich [{start_sheet_row}, {end_sheet_row_inclusive}].") + + # --- Methode zum Prozessieren von Zeilen nach Kriterien (NEU) --- + # Diese Methode gehört in die Klasse + def process_rows_matching_criteria(self, criteria_func, limit=None, + process_wiki=True, process_chatgpt=True, process_website=True, + force_step_reeval=False): + """ + Sucht Zeilen im Sheet, die ein gegebenes Kriterium erfüllen (definiert durch criteria_func). + Verarbeitet eine begrenzte Anzahl dieser passenden Zeilen unter Verwendung von _process_single_row. + + Args: + criteria_func (callable): Eine Funktion, die eine Zeile (list) nimmt und True zurückgibt, wenn das Kriterium erfüllt ist. + limit (int, optional): Maximale Anzahl passender Zeilen, die verarbeitet werden sollen. Defaults to None (alle passenden). + process_wiki (bool, optional): Soll die Gruppe Wiki-Schritte ausgeführt werden?. Defaults to True. + process_chatgpt (bool, optional): Soll die Gruppe ChatGPT-Schritte ausgeführt werden?. Defaults to True. + process_website (bool, optional): Soll die Gruppe Website-Schritte ausgeführt werden?. Defaults to True. + force_step_reeval (bool, optional): Bestimmt, ob _process_single_row mit force_reeval=True aufgerufen wird (ignoriert Timestamps für ausgewählte Schritte). Defaults to False. + """ + logging.info(f"Starte Verarbeitung von Zeilen nach Kriterien. Limit: {limit if limit is not None else 'Unbegrenzt'}") + logging.info(f"Verwendetes Kriterium: {criteria_func.__name__}") + groups_to_attempt_log = [] + if process_website: groups_to_attempt_log.append("Website") + if process_wiki: groups_to_attempt_log.append("Wiki") + if process_chatgpt: groups_to_attempt_log.append("ChatGPT") + logging.info(f"Ausgewählte Schritte: {', '.join(groups_to_attempt_log) if groups_to_attempt_log else 'Keine ausgewählt!'}") + logging.info(f"force_reeval für Schritte: {force_step_reeval}") + + + if not self.sheet_handler.load_data(): logging.error("Fehler beim Laden der Daten für kriterienbasierte Verarbeitung."); return + all_data = self.sheet_handler.get_all_data_with_headers() + header_rows = 5 + if not all_data or len(all_data) <= header_rows: logging.warning("Keine Daten für kriterienbasierte Verarbeitung gefunden."); return + + rows_to_process = [] + logging.info("Suche nach Zeilen, die dem Kriterium entsprechen...") + for idx_in_list in range(header_rows, len(all_data)): + row_data = all_data[idx_in_list] + row_num_in_sheet = idx_in_list + 1 + + try: + if criteria_func(row_data): # Nutze die globale Kriterien-Funktion + rows_to_process.append({'row_num': row_num_in_sheet, 'data': row_data}) + except Exception as e_crit: logging.error(f"FEHLER beim Prüfen des Kriteriums für Zeile {row_num_in_sheet}: {e_crit}"); + + found_count = len(rows_to_process) + logging.info(f"{found_count} Zeilen entsprechen dem Kriterium '{criteria_func.__name__}'.") + if found_count == 0: logging.info("Keine Zeilen gefunden, die dem Kriterium entsprechen."); return + + processed_count = 0 + for task in rows_to_process: + if limit is not None and processed_count >= limit: + logging.info(f"Limit ({limit}) für kriterienbasierte Verarbeitung erreicht. Breche weitere Verarbeitung ab.") + break + + row_num = task['row_num'] + row_data = task['data'] + try: + self._process_single_row( + row_num_in_sheet = row_num, + row_data = row_data, + process_wiki = process_wiki, # <<< ÜBERGIBT DIE STEUERUNG + process_chatgpt = process_chatgpt, # <<< ÜBERGIBT DIE STEUERUNG + process_website = process_website, # <<< ÜBERGIBT DIE STEUERUNG + force_reeval = force_step_reeval # <<< Bestimmt, ob Timestamps ignoriert werden + ) + processed_count += 1 + + except Exception as e_proc: logging.exception(f"FEHLER bei Verarbeitung einer Kriterium-Zeile ({row_num}): {e_proc}") + + logging.info(f"Kriterienbasierte Verarbeitung abgeschlossen. {processed_count} von {found_count} gefundenen Zeilen bearbeitet (Limit war: {limit}).") + + + # --- Batch-Verarbeitung Methoden (Werden von run_batch_dispatcher aufgerufen) --- + # Diese Methoden führen eine spezifische Aufgabe für einen Batch aus, basierend auf einem Timestamp. + # Sie rufen NICHT _process_single_row auf. + + def process_verification_batch(self, limit=None): + """ + Batch-Prozess NUR für Wikipedia-Verifizierung (Spalten S-U, AX). + Findet Startzeile ab erster Zelle mit leerem AX. + """ + logging.info(f"Starte Wikipedia-Verifizierungs-Batch. Limit: {limit if limit is not None else 'Unbegrenzt'}") + if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.") + all_data = self.sheet_handler.get_all_data_with_headers() + header_rows = 5 + if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten gefunden.") + + timestamp_col_key = "Wiki Verif. Timestamp"; timestamp_col_index = COLUMN_MAP.get(timestamp_col_key) + if timestamp_col_index is None: return logging.critical(f"FEHLER: Schlüssel '{timestamp_col_key}' nicht in COLUMN_MAP gefunden.") + ts_col_letter = self.sheet_handler._get_col_letter(timestamp_col_index + 1) + + start_data_index = self.sheet_handler.get_start_row_index(check_column_key=timestamp_col_key, min_sheet_row=header_rows + 1) + if start_data_index == -1: return logging.error(f"FEHLER bei Startzeilensuche auf Spalte '{timestamp_col_key}'.") + if start_data_index >= len(self.sheet_handler.get_data()): return logging.info("Alle Zeilen mit Timestamp gefüllt. Nichts zu tun.") + + start_sheet_row = start_data_index + header_rows + 1 + total_sheet_rows = len(all_data) + end_sheet_row = total_sheet_rows # Default bis Ende + + if limit is not None and limit >= 0: + end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows) + if limit == 0: return logging.info("Limit 0.") + + if start_sheet_row > end_sheet_row: logging.warning("Start nach Ende (Limit)."); return + + logging.info(f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} für Wiki Verifizierung (Batch).") + + batch_size = Config.BATCH_SIZE + current_batch = [] + current_row_numbers = [] + processed_count = 0 + + for i in range(start_sheet_row, end_sheet_row + 1): + row_index_in_list = i - 1 + row = all_data[row_index_in_list] + + # Vorbereitung für den Prompt (Daten holen) + company_name = self._get_cell_value(row, "CRM Name") + crm_desc = self._get_cell_value(row, "CRM Beschreibung") + wiki_url = self._get_cell_value(row, "Wiki URL") + wiki_paragraph = self._get_cell_value(row, "Wiki Absatz") + wiki_categories = self._get_cell_value(row, "Wiki Kategorien") + + + # Füge nur hinzu, wenn relevante Wiki-Daten da sind ODER URL existiert + if wiki_url != 'k.A.' or wiki_paragraph != 'k.A.' or wiki_categories != 'k.A.': + entry_text = ( + f"Eintrag {i}:\n" + f" Firmenname: {company_name}\n" + f" CRM-Beschreibung: {crm_desc[:200]}...\n" + f" Wikipedia-URL: {wiki_url}\n" + f" Wiki-Absatz: {wiki_paragraph[:200]}...\n" + f" Wiki-Kategorien: {wiki_categories[:200]}...\n" + f"----\n" + ) + current_batch.append(entry_text) + current_row_numbers.append(i) + processed_count += 1 + + if len(current_batch) >= batch_size or i == end_sheet_row: + if current_batch: + # Rufe _process_batch auf (globale Helferfunktion oder private Methode) + # Angenommen, _process_batch ist global definiert + try: + _process_batch(self.sheet_handler.sheet, current_batch, current_row_numbers) + + # Setze den AX Timestamp für die bearbeiteten Zeilen, NUR wenn _process_batch nicht exception geworfen hat + wiki_ts_updates = [] + current_wiki_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + for row_num in current_row_numbers: + wiki_ts_updates.append({'range': f'{ts_col_letter}{row_num}', 'values': [[current_wiki_timestamp]]}) + + if wiki_ts_updates: + success_ts = self.sheet_handler.batch_update_cells(wiki_ts_updates) + if success_ts: logging.debug(f"Wiki Verif. Timestamp {ts_col_letter} für Batch {current_row_numbers[0]}-{current_row_numbers[-1]} gesetzt.") + else: logging.error(f"FEHLER beim Setzen des Wiki Verif. Timestamps {ts_col_letter} für Batch.") + except Exception as e_batch: + logging.error(f"FEHLER bei Verarbeitung von Batch {current_row_numbers[0]}-{current_row_numbers[-1]} in _process_batch: {e_batch}") + # Hier könnten Sie die Zeilen im Sheet markieren, die Fehler hatten + pass # Fahren Sie mit dem nächsten Batch fort + + time.sleep(Config.RETRY_DELAY) + + current_batch = [] + current_row_numbers = [] + + logging.info(f"Wikipedia-Verifizierungs-Batch abgeschlossen. {processed_count} Zeilen in Batches verarbeitet (aus Bereich {start_sheet_row}-{end_sheet_row}).") + + # process_website_batch Methode + def process_website_batch(self, limit=None): + """ + Batch-Prozess NUR für Website-Scraping (Rohtext AR, Timestamp AT). + Findet Startzeile ab erster Zelle mit leerem AT. + """ + logging.info(f"Starte Website-Scraping Batch. Limit: {limit if limit is not None else 'Unbegrenzt'}") + if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.") + all_data = self.sheet_handler.get_all_data_with_headers() + header_rows = 5 + if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten gefunden.") + + rohtext_col_key = "Website Rohtext"; rohtext_col_index = COLUMN_MAP.get(rohtext_col_key) + website_col_idx = COLUMN_MAP.get("CRM Website") + version_col_idx = COLUMN_MAP.get("Version") + timestamp_col_key = "Website Scrape Timestamp"; timestamp_col_index = COLUMN_MAP.get(timestamp_col_key) + + if None in [rohtext_col_index, website_col_idx, version_col_idx, timestamp_col_index]: return logging.critical(f"FEHLER: Benötigte Indizes für process_website_batch fehlen.") + rohtext_col_letter = self.sheet_handler._get_col_letter(rohtext_col_index + 1) + version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1) + ts_col_letter = self.sheet_handler._get_col_letter(timestamp_col_index + 1) + + start_data_index = self.sheet_handler.get_start_row_index(check_column_key=timestamp_col_key, min_sheet_row=header_rows + 1) + if start_data_index == -1: return logging.error(f"FEHLER bei Startzeilensuche auf Spalte '{timestamp_col_key}'.") + if start_data_index >= len(self.sheet_handler.get_data()): return logging.info("Alle Zeilen mit Timestamp gefüllt. Nichts zu tun.") + + start_sheet_row = start_data_index + header_rows + 1 + total_sheet_rows = len(all_data) + end_sheet_row = total_sheet_rows # Default bis Ende + + if limit is not None and limit >= 0: + end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows) + if limit == 0: return logging.info("Limit 0.") + + if start_sheet_row > end_sheet_row: logging.warning("Start nach Ende (Limit)."); return + + logging.info(f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} für Website Scraping (Batch).") + + # Worker-Funktion für Scraping (Kann global bleiben oder private statische Methode) + # Bleibt global, da sie keine self benötigt. + def scrape_raw_text_task(task_info): + row_num = task_info['row_num']; url = task_info['url']; raw_text = "k.A."; error = None + try: raw_text = get_website_raw(url) # Annahme: get_website_raw ist global mit Retry + except Exception as e: error = f"Scraping Fehler Zeile {row_num}: {e}"; logging.error(error) # Logge Fehler im Worker + return {"row_num": row_num, "raw_text": raw_text, "error": error} + + + tasks_for_processing_batch = [] + all_sheet_updates = [] + processed_count = 0 # Zählt Zeilen, für die Task erstellt wird + skipped_url_count = 0 + + processing_batch_size = Config.PROCESSING_BATCH_SIZE + max_scraping_workers = Config.MAX_SCRAPING_WORKERS + + for i in range(start_sheet_row, end_sheet_row + 1): + row_index_in_list = i - 1 + row = all_data[row_index_in_list] + + # URL Prüfung (immer nötig, auch wenn AT fehlt) + website_url = row[website_col_idx] if len(row) > website_col_idx else "" + if not website_url or website_url.strip().lower() == "k.A.": + skipped_url_count += 1 + continue + + # Kein AT Timestamp -> Task erstellen + tasks_for_processing_batch.append({"row_num": i, "url": website_url}) + processed_count += 1 + + # Verarbeitungs-Batch ausführen + if len(tasks_for_processing_batch) >= processing_batch_size or i == end_sheet_row: + if tasks_for_processing_batch: + batch_start_row = tasks_for_processing_batch[0]['row_num'] + batch_end_row = tasks_for_processing_batch[-1]['row_num'] + batch_task_count = len(tasks_for_processing_batch) + logging.info(f"\n--- Starte Scraping-Batch ({batch_task_count} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") + + scraping_results = {} # {'row_num': raw_text} + batch_error_count = 0 + logging.info(f" Scrape {batch_task_count} Websites parallel (max {max_scraping_workers} worker)...") + with concurrent.futures.ThreadPoolExecutor(max_workers=max_scraping_workers) as executor: + future_to_task = {executor.submit(scrape_raw_text_task, task): task for task in tasks_for_processing_batch} + for future in concurrent.futures.as_completed(future_to_task): + task = future_to_task[future] + try: + result = future.result() + scraping_results[result['row_num']] = result['raw_text'] + if result['error']: batch_error_count += 1 + except Exception as exc: + row_num = task['row_num'] + err_msg = f"Generischer Fehler Scraping Task Zeile {row_num}: {exc}" + logging.error(err_msg) + scraping_results[row_num] = "k.A. (Fehler)" + batch_error_count += 1 + + logging.info(f" Scraping für Batch beendet. {len(scraping_results)} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).") + + # Sheet Updates vorbereiten (AR und AT) + if scraping_results: + current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + batch_sheet_updates = [] + for row_num, raw_text_res in scraping_results.items(): + batch_sheet_updates.extend([ + {'range': f'{rohtext_col_letter}{row_num}', 'values': [[raw_text_res]]}, + {'range': f'{ts_col_letter}{row_num}', 'values': [[current_timestamp]]} # Setze AT Timestamp + ]) + all_sheet_updates.extend(batch_sheet_updates) + + # Sheet Updates senden für diesen Batch + if all_sheet_updates: + logging.info(f" Sende Sheet-Update für {len(all_sheet_updates)} Zellen für Batch {batch_start_row}-{batch_end_row}...") + success = self.sheet_handler.batch_update_cells(all_sheet_updates) + if success: logging.info(f" Sheet-Update erfolgreich.") + else: logging.error(f" FEHLER beim Sheet-Update.") + all_sheet_updates = [] # Zurücksetzen nach Senden + + # Pause nach jedem Batch + logging.debug(" Warte nach Batch...") + time.sleep(Config.RETRY_DELAY) + + # Finaler Sheet Update Batch senden (falls Reste übrig) + if all_sheet_updates: + logging.info(f"Sende finalen Sheet-Update ({len(all_sheet_updates)} Zellen)...") + self.sheet_handler.batch_update_cells(all_sheet_updates) + + logging.info(f"Website-Scraping Batch abgeschlossen. {processed_count} Tasks erstellt, {skipped_url_count} Zeilen ohne URL übersprungen.") + + + # process_summarization_batch Methode + def process_summarization_batch(self, limit=None): + """ + Batch-Prozess NUR für Website-Zusammenfassung (AS). + Findet Startzeile ab erster Zelle mit leerem AS, wo AR gefüllt ist. + """ + logging.info(f"Starte Website-Zusammenfassung Batch. Limit: {limit if limit is not None else 'Unbegrenzt'}") + if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.") + all_data = self.sheet_handler.get_all_data_with_headers() + header_rows = 5 + if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten gefunden.") + + rohtext_col_idx = COLUMN_MAP.get("Website Rohtext") + summary_col_idx = COLUMN_MAP.get("Website Zusammenfassung") + version_col_idx = COLUMN_MAP.get("Version") + if None in [rohtext_col_idx, summary_col_idx, version_col_idx]: return logging.critical(f"FEHLER: Benötigte Indizes fehlen.") + summary_col_letter = self.sheet_handler._get_col_letter(summary_col_idx + 1) + version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1) + + # Finde die Startzeile: Erste Zelle mit leerem AS UND gefülltem AR + # Dies erfordert ein manuelles Scannen, da get_start_row_index nur eine Spalte prüft + start_sheet_row = header_rows + 1 # Starte nach Headern + logging.info(f"Suche Startzeile für Zusammenfassungs-Batch (leeres AS, gefülltes AR)...") + found_start_row = None + for i in range(header_rows, len(all_data)): + row = all_data[i] + row_num_in_sheet = i + 1 + # Sicherstellen, dass Zeile lang genug ist + if len(row) <= max(rohtext_col_idx, summary_col_idx): continue + + ar_value = str(row[rohtext_col_idx]).strip() + as_value = str(row[summary_col_idx]).strip() + + # Kriterium: AS ist leer UND AR ist gefüllt (nicht k.A. Varianten) + ar_is_filled = bool(ar_value) and ar_value.lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"] + as_is_empty = not bool(as_value) + + if ar_is_filled and as_is_empty: + found_start_row = row_num_in_sheet + logging.info(f"Startzeile für Zusammenfassungs-Batch gefunden: {found_start_row} (Index {i}).") + break + + if found_start_row is None: + logging.info("Keine Zeilen gefunden, die eine Zusammenfassung benötigen (leeres AS, gefülltes AR).") + return # Nichts zu tun + + start_sheet_row = found_start_row + total_sheet_rows = len(all_data) + end_sheet_row = total_sheet_rows # Default bis Ende + + if limit is not None and limit >= 0: + end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows) + if limit == 0: return logging.info("Limit 0.") + + if start_sheet_row > end_sheet_row: logging.warning("Start nach Ende (Limit)."); return + + + logging.info(f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} für Website Zusammenfassung (Batch).") + + tasks_for_openai_batch = [] + all_sheet_updates = [] + processed_count = 0 # Zählt Zeilen, für die Task erstellt wird + + openai_batch_size = Config.OPENAI_BATCH_SIZE_LIMIT + + for i in range(start_sheet_row, end_sheet_row + 1): + row_index_in_list = i - 1 + row = all_data[row_index_in_list] + + # Erneute Prüfung (nur zur Sicherheit): Ist AS noch leer und AR gefüllt? (Daten könnten sich geändert haben) + if len(row) <= max(rohtext_col_idx, summary_col_idx): continue # Zeile zu kurz + + ar_value = str(row[rohtext_col_idx]).strip() + as_value = str(row[summary_col_idx]).strip() + ar_is_filled = bool(ar_value) and ar_value.lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"] + as_is_empty = not bool(as_value) + + if not (ar_is_filled and as_is_empty): + # Diese Zeile wurde von get_start_row_index gefunden, aber das Kriterium passt nicht mehr (z.B. manuell bearbeitet) + logging.debug(f"Zeile {i}: Kriterium (leeres AS, gefülltes AR) passt nicht mehr, übersprungen.") + continue + + + # Task hinzufügen + tasks_for_openai_batch.append({'row_num': i, 'raw_text': ar_value}) # Füge den Rohtext hinzu + processed_count += 1 + + # OpenAI Batch verarbeiten, wenn voll oder letzte Zeile + if tasks_for_openai_batch and (len(tasks_for_openai_batch) >= openai_batch_size or i == end_sheet_row): + debug_print(f" Verarbeite OpenAI Batch für {len(tasks_for_openai_batch)} Aufgaben (Start: {tasks_for_openai_batch[0]['row_num']})...") + # summarize_batch_openai ist global (oder private helper) + try: + summaries_result = summarize_batch_openai(tasks_for_openai_batch) + + # Sheet Updates für diesen OpenAI Batch vorbereiten + current_version = Config.VERSION + current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Timestamp für AS? Oder AT nutzen? + # AT ist für Scraping. AS könnte eigenen Timestamp bekommen, oder AO/AP nutzen. + # Belassen wir es bei AS + AP Update. + + for task in tasks_for_openai_batch: + row_num = task['row_num'] + summary = summaries_result.get(row_num, "k.A. (Fehler Batch Zuordnung)") + batch_sheet_updates = [ + {'range': f'{summary_col_letter}{row_num}', 'values': [[summary]]}, + # Version AP wird in _process_single_row gesetzt. Batch Modi setzen AP nicht. + # Das ist eine Inkonsistenz. AP sollte von jedem Batch Modus gesetzt werden. + # {'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]} # Setze AP hier + ] + all_sheet_updates.extend(batch_sheet_updates) + + # Sheet Updates senden für diesen OpenAI Batch + if all_sheet_updates: + logging.info(f" Sende Sheet-Update für {len(tasks_for_openai_batch)} Zusammenfassungen ({len(all_sheet_updates)} Zellen)...") + success = self.sheet_handler.batch_update_cells(all_sheet_updates) + if success: logging.info(f" Sheet-Update erfolgreich.") + else: logging.error(f" FEHLER beim Sheet-Update.") + all_sheet_updates = [] # Zurücksetzen nach Senden + + except Exception as e_batch: + logging.error(f"FEHLER bei Verarbeitung von OpenAI Batch {tasks_for_openai_batch[0]['row_num']}-{tasks_for_openai_batch[-1]['row_num']}: {e_batch}") + # Fehler markieren? Oder einfach weitermachen? Pass. + + tasks_for_openai_batch = [] # OpenAI Batch leeren + # Pause nach jedem OpenAI Batch + time.sleep(Config.RETRY_DELAY) + + + # Finaler Sheet Update Batch senden (falls Reste übrig) + if all_sheet_updates: + logging.info(f"Sende finalen Sheet-Update ({len(all_sheet_updates)} Zellen)...") + self.sheet_handler.batch_update_cells(all_sheet_updates) + + logging.info(f"Website-Zusammenfassung Batch abgeschlossen. {processed_count} Tasks erstellt.") + + + # process_branch_batch Methode + def process_branch_batch(self, limit=None): + """ + Batch-Prozess NUR für Brancheneinschätzung (W-Y, AO). + Findet Startzeile ab erster Zelle mit leerem AO. + """ + logging.info(f"Starte Branchen-Einschätzung Batch. Limit: {limit if limit is not None else 'Unbegrenzt'}") + if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.") + all_data = self.sheet_handler.get_all_data_with_headers() + header_rows = 5 + if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten gefunden.") + + timestamp_col_key = "Timestamp letzte Prüfung"; timestamp_col_index = COLUMN_MAP.get(timestamp_col_key) + branche_crm_idx = COLUMN_MAP.get("CRM Branche"); beschreibung_idx = COLUMN_MAP.get("CRM Beschreibung") + branche_wiki_idx = COLUMN_MAP.get("Wiki Branche"); kategorien_wiki_idx = COLUMN_MAP.get("Wiki Kategorien") + summary_web_idx = COLUMN_MAP.get("Website Zusammenfassung"); version_col_idx = COLUMN_MAP.get("Version") + branch_w_idx = COLUMN_MAP.get("Chat Vorschlag Branche"); branch_x_idx = COLUMN_MAP.get("Chat Konsistenz Branche") + branch_y_idx = COLUMN_MAP.get("Chat Begründung Abweichung Branche") + required_indices = [timestamp_col_index, branche_crm_idx, beschreibung_idx, branche_wiki_idx, kategorien_wiki_idx, summary_web_idx, version_col_idx, branch_w_idx, branch_x_idx, branch_y_idx] + if None in required_indices: return logging.critical(f"FEHLER: Benötigte Indizes fehlen.") + + ts_col_letter = self.sheet_handler._get_col_letter(timestamp_col_index + 1) + version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1) + branch_w_letter = self.sheet_handler._get_col_letter(branch_w_idx + 1) + branch_x_letter = self.sheet_handler._get_col_letter(branch_x_idx + 1) + branch_y_letter = self.sheet_handler._get_col_letter(branch_y_idx + 1) + + + # Finde die Startzeile + start_data_index = self.sheet_handler.get_start_row_index(check_column_key=timestamp_col_key, min_sheet_row=header_rows + 1) + if start_data_index == -1: return logging.error(f"FEHLER bei Startzeilensuche auf Spalte '{timestamp_col_key}'.") + if start_data_index >= len(self.sheet_handler.get_data()): return logging.info("Alle Zeilen mit Timestamp gefüllt. Nichts zu tun.") + + start_sheet_row = start_data_index + header_rows + 1 + total_sheet_rows = len(all_data) + end_sheet_row = total_sheet_rows # Default bis Ende + + if limit is not None and limit >= 0: + end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows) + if limit == 0: return logging.info("Limit 0.") + + if start_sheet_row > end_sheet_row: logging.warning("Start nach Ende (Limit)."); return + + + logging.info(f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} für Branchen-Einschätzung (Batch).") + + MAX_BRANCH_WORKERS = Config.MAX_BRANCH_WORKERS + OPENAI_CONCURRENCY_LIMIT = Config.OPENAI_CONCURRENCY_LIMIT + openai_semaphore_branch = threading.Semaphore(OPENAI_CONCURRENCY_LIMIT) # Annahme: threading ist importiert + + tasks_for_processing_batch = [] # Liste von Task-Daten für parallele Verarbeitung + processed_count = 0 # Zählt Zeilen, für die Task erstellt wird + + if not ALLOWED_TARGET_BRANCHES: load_target_schema(); + if not ALLOWED_TARGET_BRANCHES: return logging.critical("FEHLER: Ziel-Schema nicht geladen. Branch Batch nicht möglich.") + + + for i in range(start_sheet_row, end_sheet_row + 1): + row_index_in_list = i - 1 + row = all_data[row_index_in_list] + + # Erneute Prüfung (nur zur Sicherheit): Ist AO noch leer? + if len(row) > timestamp_col_index and str(row[timestamp_col_index]).strip(): + logging.debug(f"Zeile {i}: Timestamp {ts_col_letter} ist nicht mehr leer, übersprungen.") + continue + + # Task sammeln (Nutze self._get_cell_value) + task_data = { + "row_num": i, + "crm_branche": self._get_cell_value(row, "CRM Branche"), + "beschreibung": self._get_cell_value(row, "CRM Beschreibung"), + "wiki_branche": self._get_cell_value(row, "Wiki Branche"), + "wiki_kategorien": self._get_cell_value(row, "Wiki Kategorien"), + "website_summary": self._get_cell_value(row, "Website Zusammenfassung") + } + tasks_for_processing_batch.append(task_data) + processed_count += 1 + + # Batch verarbeiten, wenn voll oder letzte Zeile + if len(tasks_for_processing_batch) >= Config.PROCESSING_BRANCH_BATCH_SIZE or i == end_sheet_row: + if tasks_for_processing_batch: + batch_start_row = tasks_for_processing_batch[0]['row_num'] + batch_end_row = tasks_for_processing_batch[-1]['row_num'] + batch_task_count = len(tasks_for_processing_batch) + logging.info(f"\n--- Starte Branch-Evaluation Batch ({batch_task_count} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") + + results_list = []; batch_error_count = 0 + logging.info(f" Evaluiere {batch_task_count} Zeilen parallel (max {MAX_BRANCH_WORKERS} worker, {OPENAI_CONCURRENCY_LIMIT} OpenAI gleichzeitig)...") + # Worker Funktion für Branch Evaluation (muss hier oder global sein) + # Kann private Methode werden, die semaphore nutzt. + # Machen wir sie zu einer privaten Methode. + # Definiere _evaluate_branch_task_worker(self, task_data, semaphore) + + # *** BEGINN PARALLELE VERARBEITUNG *** + with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_BRANCH_WORKERS) as executor: + # Submit Aufgaben an den Executor + # Passing semaphore to each worker task + future_to_task = {executor.submit(self._evaluate_branch_task_worker, task, openai_semaphore_branch): task for task in tasks_for_processing_batch} + + # Warte auf Ergebnisse und sammle sie + for future in concurrent.futures.as_completed(future_to_task): + task = future_to_task[future] + try: + result_data = future.result() # Ergebnis enthält {'row_num': ..., 'result': ..., 'error': ...} + results_list.append(result_data) + if result_data['error']: batch_error_count += 1 + except Exception as exc: + # Dies fängt Fehler auf Executor-Ebene ab (sollte selten sein, da Worker Fehler loggt) + row_num = task['row_num'] + err_msg = f"Generischer Fehler Branch Task Zeile {row_num}: {exc}" + logging.error(err_msg) + results_list.append({"row_num": row_num, "result": {"branch": "FEHLER", "consistency": "error_task", "justification": err_msg[:500]}, "error": err_msg}) + batch_error_count += 1 + + + # *** ENDE PARALLELE VERARBEITUNG *** + logging.info(f" Branch-Evaluation für Batch beendet. {len(results_list)} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).") + + # Sheet Updates vorbereiten FÜR DIESEN BATCH + if results_list: + current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + current_version = Config.VERSION + batch_sheet_updates = [] + # Sortiere Ergebnisse nach Zeilennummer für geordnetes Schreiben (optional) + results_list.sort(key=lambda x: x['row_num']) + for res_data in results_list: + row_num = res_data['row_num']; result = res_data['result'] + logging.debug(f" Zeile {row_num}: Ergebnis -> Branch='{result.get('branch')}', Consistency='{result.get('consistency')}', Justification='{result.get('justification', '')[:50]}...'") + batch_sheet_updates.extend([ + {'range': f'{branch_w_letter}{row_num}', 'values': [[result.get("branch", "Fehler")]]}, + {'range': f'{branch_x_letter}{row_num}', 'values': [[result.get("consistency", "Fehler")]]}, + {'range': f'{branch_y_letter}{row_num}', 'values': [[result.get("justification", "Fehler")]]}, + {'range': f'{ts_col_letter}{row_num}', 'values': [[current_timestamp]]}, # AO Timestamp setzen + # Version AP sollte auch von Batch Modi gesetzt werden. + {'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]} # Setze AP + ]) + + # Sende Updates für DIESEN Batch SOFORT + if batch_sheet_updates: + logging.info(f" Sende Sheet-Update für {len(results_list)} Zeilen ({len(batch_sheet_updates)} Zellen) für Batch {batch_start_row}-{batch_end_row}...") + success = self.sheet_handler.batch_update_cells(batch_sheet_updates) + if success: logging.info(f" Sheet-Update erfolgreich.") + else: logging.error(f" FEHLER beim Sheet-Update.") + else: logging.debug(f" Keine Sheet-Updates für Batch {batch_start_row}-{batch_end_row} vorbereitet.") + + tasks_for_processing_batch = [] # Batch leeren + logging.debug(f"--- Verarbeitungs-Batch {batch_start_row}-{batch_end_row} abgeschlossen ---") + # Kurze Pause NACHDEM ein Batch komplett verarbeitet und geschrieben wurde + logging.debug(" Warte nach Batch...") + time.sleep(Config.RETRY_DELAY) + + + logging.info(f"Branchen-Einschätzung Batch abgeschlossen. {processed_count} Tasks erstellt.") + + # --- Private Worker Methode für Branch Batch Parallelisierung --- + # Diese Methode gehört in die Klasse DataProcessor und wird vom Branch Batch Executor aufgerufen + def _evaluate_branch_task_worker(self, task_data, semaphore): + """Worker-Funktion für die parallele Branchenevaluation.""" + row_num = task_data['row_num'] + # evaluate_branche_chatgpt ist global und macht den OpenAI Call mit Retry + # Semaphor steuert die Anzahl gleichzeitiger OpenAI Calls + result = {"branch": "k.A. (Task Fehler)", "consistency": "error_task", "justification": "Fehler im Worker"}; error = None + try: + with semaphore: # Acquire the semaphore + # logging.debug(f" Task {row_num}: Semaphore erhalten.") # Zu laut + result = evaluate_branche_chatgpt( + task_data['crm_branche'], task_data['beschreibung'], + task_data['wiki_branche'], task_data['wiki_kategorien'], + task_data['website_summary'] + ) + # logging.debug(f" Task {row_num}: evaluate_branche_chatgpt beendet.") # Zu laut + except Exception as e: + error = f"Fehler bei Branchenevaluation Zeile {row_num}: {e}"; logging.error(error) + # Update result dictionary with error info if needed + result['justification'] = (result.get('justification', '') + f" Fehler: {error}")[:500] + result['consistency'] = 'error_task' + + return {"row_num": row_num, "result": result, "error": error} + + + # --- Dienstprogramm Methoden (Werden von run_user_interface aufgerufen) --- + # Diese Methoden führen eine spezifische Aufgabe aus und arbeiten oft über das gesamte Sheet + # oder eine gefilterte Menge. + + # process_serp_website_lookup Methode (früher process_serp_website_lookup_for_empty) + def process_serp_website_lookup(self, limit=None): # <<< Methode in DataProcessor """ Sucht fehlende Websites (Spalte D ist leer oder "k.A.") via SERP API (Google Search) und trägt gefundene URLs in Spalte D ein. + + Args: + limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None. """ - logging.info("Starte Modus: SERP API Website Lookup für leere Zellen in Spalte D.") - # Daten neu laden - if not self.sheet_handler.load_data(): - logging.error("Fehler beim Laden der Daten für Website Lookup.") - return + logging.info(f"Starte Modus: SERP API Website Lookup für leere Zellen in Spalte D. Limit: {limit if limit is not None else 'Unbegrenzt'}") + if not self.sheet_handler.load_data(): return logging.error("Fehler beim Laden der Daten.") + data_rows = self.sheet_handler.get_data() + header_rows = 5 - data_rows = self.sheet_handler.get_data() # Datenzeilen ohne Header - header_rows = 5 # Annahme - total_rows_in_sheet = len(self.sheet_handler.get_all_data_with_headers()) # Gesamtzahl Zeilen - - rows_processed_count = 0 # Zählt Zeilen, wo ein Lookup versucht wurde + rows_processed_count = 0 updates = [] - # Definiere die Spaltenindizes innerhalb der Methode - try: - website_col_idx = COLUMN_MAP["CRM Website"] - name_col_idx = COLUMN_MAP["CRM Name"] + + try: # Annahme: COLUMN_MAP ist global + website_col_idx = COLUMN_MAP["CRM Website"]; name_col_idx = COLUMN_MAP["CRM Name"] website_col_letter = self.sheet_handler._get_col_letter(website_col_idx + 1) - except KeyError as e: - logging.critical(f"FEHLER: Benötigte Spalte '{e}' für Modus 'website_lookup' nicht in COLUMN_MAP.") - return - except Exception as e: - logging.critical(f"FEHLER beim Holen der Spaltenbuchstaben für 'website_lookup': {e}") - return + # Optional: Timestamp AY für SerpAPI Wiki Suche, um hier nicht Website Suche immer wieder zu machen, wenn Wiki Suche für die Zeile schon fehlschlug. + # Das wird aber komplex. Belassen wir es simpel. + except KeyError as e: logging.critical(f"FEHLER: Benötigte Spalte '{e}' fehlt."); return + except Exception as e: logging.critical(f"FEHLER beim Holen der Spaltenbuchstaben: {e}"); return - # Iteriere über die Datenzeilen (ab der ersten möglichen Zeile, standard 7) - # min_start_row = 7 # Ggf. als Parameter übergeben - # search_start_data_index = max(0, min_start_row - header_rows - 1) - - # Annahme: Wir wollen alle Zeilen prüfen, nicht nur ab einer bestimmten for i, row in enumerate(data_rows): - row_num_in_sheet = i + header_rows + 1 # 1-basierte Zeilennummer im Sheet - - # Sicherstellen, dass die Zeile lang genug ist, um auf die benötigten Spalten zuzugreifen - if len(row) <= max(website_col_idx, name_col_idx): - logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz für benötigte Spalten).") - continue + row_num_in_sheet = i + header_rows + 1 + if limit is not None and rows_processed_count >= limit: logging.info(f"Limit ({limit}) für Website Lookup erreicht."); break + max_needed_idx = max(website_col_idx, name_col_idx); if len(row) <= max_needed_idx: logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz)."); continue current_website = row[website_col_idx] if len(row) > website_col_idx else "" - # Prüfe, ob die Website-Spalte (D) leer, "k.A." oder nur Whitespace ist if not current_website or str(current_website).strip().lower() == "k.a.": company_name = row[name_col_idx] if len(row) > name_col_idx else "" - if not company_name or str(company_name).strip() == "": - logging.warning(f"Zeile {row_num_in_sheet}: Übersprungen (kein Firmenname in Spalte B für Lookup vorhanden).") - continue + if not company_name or str(company_name).strip() == "": logging.warning(f"Zeile {row_num_in_sheet}: Übersprungen (kein Firmenname)."); continue - logging.info(f"Zeile {row_num_in_sheet}: Suche Website für '{company_name}' in Spalte {self.sheet_handler._get_col_letter(name_col_idx+1)}...") - # Annahme: serp_website_lookup existiert und nutzt logging/retry - new_website = serp_website_lookup(company_name) - rows_processed_count += 1 # Zähle jede Zeile, für die ein Lookup versucht wurde + logging.info(f"Zeile {row_num_in_sheet}: Suche Website für '{company_name}'...") + new_website = serp_website_lookup(company_name) # Globale Funktion mit Retry + rows_processed_count += 1 if new_website != "k.A.": - # Füge Update für Spalte D hinzu updates.append({'range': f'{website_col_letter}{row_num_in_sheet}', 'values': [[new_website]]}) logging.info(f"Zeile {row_num_in_sheet}: Neue Website '{new_website}' gefunden und zum Update hinzugefügt.") - else: - # Optional: Markiere, dass kein Ergebnis gefunden wurde, falls nötig - # updates.append({'range': f'{website_col_letter}{row_num_in_sheet}', 'values': [['k.A. (kein SERP Ergebnis)']}) # Beispiel - logging.info(f"Zeile {row_num_in_sheet}: Keine Website via SERP gefunden.") + else: logging.info(f"Zeile {row_num_in_sheet}: Keine Website gefunden.") - # Kleine Pause nach jedem SERP-Aufruf - time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.3) + time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.3) # Pause nach jedem SERP-Aufruf - - # Sende gesammelte Updates in einem Batch if updates: logging.info(f"Sende Batch-Update für {len(updates)} Zellen ({rows_processed_count} Zeilen geprüft)...") - # Annahme: sheet_handler.batch_update_cells existiert und nutzt logging/retry success = self.sheet_handler.batch_update_cells(updates) - if success: - logging.info("Batch-Update für 'website_lookup' erfolgreich.") - # Der Fehlerfall wird von batch_update_cells geloggt - else: - logging.info("Keine fehlenden Websites gefunden oder keine Updates nötig.") - + if success: logging.info(f"Batch-Update erfolgreich.") + else: logging.error(f"FEHLER beim Batch-Update.") + else: logging.info("Keine fehlenden Websites gefunden oder keine Updates nötig.") logging.info(f"Modus 'website_lookup' abgeschlossen. {rows_processed_count} Zeilen geprüft.") + # process_find_wiki_serp Methode + def process_find_wiki_serp(self, limit=None, min_employees=500, min_umsatz=200): # <<< Methode in DataProcessor + """ + Sucht fehlende Wikipedia-URLs (Spalte M = k.A.) für Unternehmen mit + (Umsatz CRM > min_umsatz MIO € ODER Mitarbeiter CRM > min_employees) + über SerpAPI und trägt gefundene URLs in Spalte M ein. Setzt ReEval-Flag (A) + und löscht abhängige Wiki-Spalten (N-V, AN, AO, AP, AX). + Merkt sich in Spalte AY, wann die Suche durchgeführt wurde. - # --- Methode für experimentelle Website Details --- - # Diese Methode gehört in die Klasse - def process_website_details_for_marked_rows(self): + Args: + limit (int, optional): Maximale Anzahl zu prüfender Zeilen. Defaults to None. + 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. + """ + logging.info(f"Starte Modus 'find_wiki_serp': Suche fehlende Wiki-URLs für Firmen mit (Umsatz CRM > {min_umsatz} MIO € ODER Mitarbeiter CRM > {min_employees})...") + if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.") + all_data = self.sheet_handler.get_all_data_with_headers() + header_rows = 5; if not all_data or len(all_data) <= header_rows: logging.warning("Keine Daten gefunden."); return + data_rows = all_data[header_rows:] + col_indices = {} # Annahme: COLUMN_MAP ist global + required_keys = [ "ReEval Flag", "CRM Anzahl Mitarbeiter", "CRM Umsatz", "Wiki URL", "CRM Name", "CRM Website", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Chat Wiki Konsistenzprüfung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", "Begründung bei Abweichung", "Wikipedia Timestamp", "Timestamp letzte Prüfung", "Version", "Wiki Verif. Timestamp", "SerpAPI Wiki Search Timestamp" ] + all_keys_found = True; for key in required_keys: idx = COLUMN_MAP.get(key); col_indices[key] = idx; if idx is None: logging.critical(f"FEHLER: Schlüssel '{key}' fehlt! Modus abgebrochen."); all_keys_found = False + if not all_keys_found: return + col_letters = {key: self.sheet_handler._get_col_letter(idx + 1) for key, idx in col_indices.items()} + all_sheet_updates = []; processed_rows_count = 0; found_urls_count = 0; skipped_timestamp_ay_count = 0; skipped_size_count = 0; skipped_m_filled_count = 0 + now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + for idx, row in enumerate(data_rows): + row_num_in_sheet = idx + header_rows + 1 + if limit is not None and processed_rows_count >= limit: logging.info(f"Limit ({limit}) erreicht."); break + max_needed_idx = max(col_indices.values()); if len(row) <= max_needed_idx: logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz)."); continue + + ts_ay_val = row[col_indices["SerpAPI Wiki Search Timestamp"]]; if ts_ay_val and ts_ay_val.strip(): skipped_timestamp_ay_count += 1; continue + m_value = row[col_indices["Wiki URL"]]; if m_value and str(m_value).strip().lower() not in ["k.a.", "kein artikel gefunden"]: skipped_m_filled_count += 1; continue + + umsatz_val_str = row[col_indices["CRM Umsatz"]]; ma_val_str = row[col_indices["CRM Anzahl Mitarbeiter"]] + umsatz_val_mio = get_numeric_filter_value(umsatz_val_str, is_umsatz=True) # Globale Funktion + ma_val_num = get_numeric_filter_value(ma_val_str, is_umsatz=False) # Globale Funktion + + if not (umsatz_val_mio > min_umsatz or ma_val_num > min_employees): + logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Größe nicht ausreichend. Umsatz (Mio): {umsatz_val_mio:.2f}, MA: {ma_val_num}). Schwellen: Umsatz > {min_umsatz} Mio, MA > {min_employees}.") + skipped_size_count += 1; continue + + company_name = row[col_indices["CRM Name"]]; if not company_name or str(company_name).strip() == "": logging.warning(f"Zeile {row_num_in_sheet}: Übersprungen, kein Firmenname."); ay_col_letter = col_letters["SerpAPI Wiki Search Timestamp"]; all_sheet_updates.append({'range': f'{ay_col_letter}{row_num_in_sheet}', 'values': [[now_timestamp_str]]}); continue + + logging.info(f"Zeile {row_num_in_sheet}: Suche Wiki-URL für '{company_name}' (Umsatz (Mio): {umsatz_val_mio:.2f}, MA: {ma_val_num})...") + processed_rows_count += 1 + website_url = row[col_indices["CRM Website"]] if col_indices["CRM Website"] is not None and len(row) > col_indices["CRM Website"] else None + wiki_url_found = serp_wikipedia_lookup(company_name, website=website_url) # Globale Funktion mit Retry + + ay_col_letter = col_letters["SerpAPI Wiki Search Timestamp"]; all_sheet_updates.append({'range': f'{ay_col_letter}{row_num_in_sheet}', 'values': [[now_timestamp_str]]}) + + if wiki_url_found and wiki_url_found.strip() and wiki_url_found != "k.A.": + logging.info(f" -> URL gefunden: {wiki_url_found}. Bereite Update vor.") + found_urls_count += 1; m_l = col_letters["Wiki URL"]; a_l = col_letters["ReEval Flag"]; n_idx = col_indices["Wiki Absatz"]; v_idx = col_indices["Begründung bei Abweichung"]; n_l=self.sheet_handler._get_col_letter(n_idx+1); v_l=self.sheet_handler._get_col_letter(v_idx+1); an_l = col_letters["Wikipedia Timestamp"]; ao_l = col_indices["Timestamp letzte Prüfung"]; ap_l = col_letters["Version"]; ax_l = col_letters["Wiki Verif. Timestamp"] + # Korrektur AO_l war Index, muss Buchstabe sein + ao_idx = COLUMN_MAP.get("Timestamp letzte Prüfung"); ao_l=self.sheet_handler._get_col_letter(ao_idx+1); + + all_sheet_updates.extend([ + {'range': f'{m_l}{row_num_in_sheet}', 'values': [[wiki_url_found]]}, {'range': f'{a_l}{row_num_in_sheet}', 'values': [['x']]}, + {'range': f'{n_l}{row_num_in_sheet}:{v_l}{row_num_in_sheet}', 'values': [[''] * (v_idx - n_idx + 1)]}, + {'range': f'{an_l}{row_num_in_sheet}', 'values': [['']]}, {'range': f'{ao_l}{row_num_in_sheet}', 'values': [['']]}, + {'range': f'{ap_l}{row_num_in_sheet}', 'values': [['']]}, {'range': f'{ax_l}{row_num_in_sheet}', 'values': [['']]} + ]) + else: logging.info(f" -> Keine Wiki-URL via SerpAPI gefunden.") + time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.3) + + if all_sheet_updates: + logging.info(f"Sende Batch-Update für {len(all_sheet_updates)} Zellen ({processed_rows_count} Zeilen geprüft)...") + success = self.sheet_handler.batch_update_cells(all_sheet_updates) + if success: logging.info(f"Sheet-Update erfolgreich.") + else: logging.error(f"FEHLER beim Batch-Update.") + else: logging.info("Keine Updates nötig.") + logging.info(f"Modus 'find_wiki_serp' abgeschlossen. {processed_rows_count} Tasks erstellt, {found_urls_count} URLs gefunden, {skipped_timestamp_ay_count} AY gesetzt, {skipped_size_count} Größe, {skipped_m_filled_count} M gefüllt.") + + # process_wiki_updates_from_chatgpt Methode + def process_wiki_updates_from_chatgpt(self, row_limit=None): # <<< Methode in DataProcessor + """ + Identifiziert Zeilen (S nicht OK/Updated/Copied/Invalid), prüft ob U eine *valide* und *andere* Wiki-URL ist. + - Wenn ja: Kopiert U->M, markiert S='X (URL Copied)', U='URL übernommen', löscht TS/Version, setzt ReEval-Flag A. + - Wenn nein (U keine URL, U==M, oder U ungültig): LÖSCHT den Inhalt von U und markiert S als 'X (Invalid Suggestion)'. + Verarbeitet maximal row_limit Zeilen. + """ + logging.info(f"Starte Modus: Wiki-Updates (URL-Validierung & Löschen ungültiger Vorschläge). Limit: {limit if limit is not None else 'Unbegrenzt'}") + if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.") + all_data = self.sheet_handler.get_all_data_with_headers() + header_rows = 5; if not all_data or len(all_data) <= header_rows: logging.warning("Keine Daten gefunden."); return + data_rows = all_data[header_rows:] + required_keys = [ "Chat Wiki Konsistenzprüfung", "Chat Vorschlag Wiki Artikel", "Wiki URL", "Wikipedia Timestamp", "Wiki Verif. Timestamp", "Timestamp letzte Prüfung", "Version", "ReEval Flag", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Begründung bei Abweichung" ] + col_indices = {} # Annahme: COLUMN_MAP ist global + all_keys_found = True; for key in required_keys: idx = COLUMN_MAP.get(key); col_indices[key] = idx; if idx is None: logging.critical(f"FEHLER: Schlüssel '{key}' fehlt! Modus abgebrochen."); all_keys_found = False + if not all_keys_found: return + + all_sheet_updates = []; processed_rows_count = 0; updated_url_count = 0; cleared_suggestion_count = 0 + + for idx, row in enumerate(data_rows): + row_num_in_sheet = idx + header_rows + 1 + if limit is not None and processed_rows_count >= limit: logging.info(f"Limit ({limit}) erreicht."); break + max_needed_idx = max(col_indices.values()); if len(row) <= max_needed_idx: logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz)."); continue + + # Nutze private Helfermethode _get_cell_value + konsistenz_s = self._get_cell_value(row, "Chat Wiki Konsistenzprüfung").strip() + vorschlag_u = self._get_cell_value(row, "Chat Vorschlag Wiki Artikel").strip() + url_m = self._get_cell_value(row, "Wiki URL").strip() + + konsistenz_s_upper = konsistenz_s.upper() + is_candidate_for_check = bool(konsistenz_s_upper) and konsistenz_s_upper not in ["OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)", "?"] + if is_candidate_for_check or (konsistenz_s_upper == "?" and not vorschlag_u): + logging.debug(f"Zeile {row_num_in_sheet}: Kandidat für Wiki-Update-Prüfung (Status S = '{konsistenz_s}'). Vorschlag U = '{vorschlag_u}'") + processed_rows_count += 1 + + is_update_candidate = False; new_url = "" + condition2_u_is_wiki_url = vorschlag_u.lower().startswith(("http://", "https://")) and "wikipedia.org/wiki/" in vorschlag_u.lower() + + if condition2_u_is_wiki_url: + new_url = vorschlag_u + condition3_u_differs_m = simple_normalize_url(new_url) != simple_normalize_url(url_m) # Globale Funktion + + if condition3_u_differs_m: + logging.debug(f" -> Prüfe Validität der neuen URL: {new_url}...") + try: condition4_u_is_valid = is_valid_wikipedia_article_url(new_url); # Globale Funktion mit Retry + except Exception as e_valid: logging.error(f" -> Fehler bei Validierung der URL '{new_url}': {e_valid}. Behandle als ungültig."); condition4_u_is_valid = False + + if condition4_u_is_valid: is_update_candidate = True; logging.debug(f" -> URL '{new_url}' ist ein valider Artikel.") + else: logging.debug(f" -> URL '{new_url}' ist KEIN valider Artikel laut API Check.") + else: logging.debug(f" -> Vorschlag U ist identisch mit URL M.") + else: logging.debug(f" -> Vorschlag U ist keine Wikipedia URL ('{vorschlag_u}').") + + if is_update_candidate: + logging.info(f"Zeile {row_num_in_sheet}: Update-Kandidat VALIDIERUNG ERFOLGREICH. Setze ReEval-Flag 'x' und bereite Updates vor für URL: {new_url}") + updated_url_count += 1 + m_l=self.sheet_handler._get_col_letter(col_indices["Wiki URL"]+1); s_l=self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"]+1); u_l=self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"]+1) + a_l=self.sheet_handler._get_col_letter(col_indices["ReEval Flag"]+1) + n_idx = col_indices["Wiki Absatz"]; v_idx = col_indices["Begründung bei Abweichung"]; n_l=self.sheet_handler._get_col_letter(n_idx+1); v_l=self.sheet_handler._get_col_letter(v_idx+1) + an_l=self.sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"]+1); ax_l=self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"]+1); ao_l=self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Prüfung"]+1); ap_l=self.sheet_handler._get_col_letter(col_indices["Version"]+1) + + all_sheet_updates.extend([ + {'range': f'{m_l}{row_num_in_sheet}', 'values': [[new_url]]}, {'range': f'{s_l}{row_num_in_sheet}', 'values': [["X (URL Copied)"]]}, {'range': f'{u_l}{row_num_in_sheet}', 'values': [["URL übernommen"]]}, + {'range': f'{n_l}{row_num_in_sheet}:{v_l}{row_num_in_sheet}', 'values': [[''] * (v_idx - n_idx + 1)]}, + {'range': f'{an_l}{row_num_in_sheet}', 'values': [['']]}, {'range': f'{ax_l}{row_num_in_sheet}', 'values': [['']]}, + {'range': f'{ao_l}{row_num_in_sheet}', 'values': [['']]}, {'range': f'{ap_l}{row_num_in_sheet}', 'values': [['']]}, + {'range': f'{a_l}{row_num_in_sheet}', 'values': [["x"]]}, + ]) + else: + logging.info(f"Zeile {row_num_in_sheet}: Vorschlag U ('{vorschlag_u}') ist ungültig/identisch. Lösche U und setze Status S.") + cleared_suggestion_count += 1 + s_l=self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"]+1); u_l=self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"]+1) + all_sheet_updates.extend([ + {'range': f'{s_l}{row_num_in_sheet}', 'values': [["X (Invalid Suggestion)"]]}, # Neuer Status in S + {'range': f'{u_l}{row_num_in_sheet}', 'values': [[""]]} # Vorschlag U löschen + ]) + # else: Status war OK, X(Updated), X(Copied), X(Invalid Suggestion) oder leer -> Kein Kandidat + + + if all_sheet_updates: + logging.info(f"Sende Batch-Update für {processed_rows_count} geprüfte Zeilen ({len(all_sheet_updates)} Zellen)...") + success = self.sheet_handler.batch_update_cells(all_sheet_updates) + if success: logging.info(f"Sheet-Update für Wiki-Updates erfolgreich.") + else: logging.error("FEHLER beim Sheet-Update für Wiki-Updates.") + else: logging.info("Keine Zeilen gefunden, die eine Wiki-URL-Korrektur oder Vorschlagsbereinigung benötigen.") + + logging.info(f"Wiki-Updates abgeschlossen. {processed_rows_count} Zeilen geprüft. {updated_url_count} URLs kopiert & für ReEval markiert, {cleared_suggestion_count} ungültige Vorschläge gelöscht/markiert.") + + + # --- Private Helfer für Timestamp/Status Checks --- + # Diese werden von _process_single_row aufgerufen. + # Stellen Sie sicher, dass diese Methoden IN der Klasse DataProcessor sind. + + def _get_cell_value(self, row_data, key): + """Lokale Hilfsfunktion zum sicheren Zugriff auf Zellwerte innerhalb von Methoden, die row_data als Parameter erhalten.""" + idx = COLUMN_MAP.get(key) # Annahme: COLUMN_MAP ist global + if idx is not None and len(row_data) > idx: + return row_data[idx] if row_data[idx] is not None else '' + return "" + + def _is_step_processing_needed(self, row_data, step_key, force_reeval, related_inputs_updated=False): + """ + Prüft, ob ein spezifischer Verarbeitungsschritt für diese Zeile ausgeführt werden soll, + basierend auf Timestamp, force_reeval und ob Eingangsdaten aktualisiert wurden. + """ + if force_reeval: return True + if step_key is None: return related_inputs_updated # Ohne Timestamp, nur wenn Inputs neu + + timestamp_col_index = COLUMN_MAP.get(step_key) + if timestamp_col_index is None: logging.error(f" -> Step Check Fehler: Timestamp Schlüssel '{step_key}' nicht in COLUMN_MAP gefunden."); return False + + ts_value = row_data[timestamp_col_index] if len(row_data) > timestamp_col_index else "" + ts_is_set = bool(str(ts_value).strip()) + + needs_processing = not ts_is_set or related_inputs_updated + return needs_processing + + # _is_wiki_search_extract_needed Helfer (spezifisch für AN & S='X (URL Copied)') + def _is_wiki_search_extract_needed(self, row_data, force_reeval): # related_inputs_updated hier nicht relevant + """Prüft, ob Wikipedia Search/Extraction nötig ist (AN Timestamp oder S='X (URL Copied)' oder force_reeval).""" + # Wiki Search/Extraction ist nötig, wenn: force_reeval ODER AN fehlt ODER S='X (URL Copied)' + # Nutze private Helfermethode _get_cell_value + wiki_ts_an_missing = not self._get_cell_value(row_data, "Wikipedia Timestamp").strip() + status_s_indicates_reparse = self._get_cell_value(row_data, "Chat Wiki Konsistenzprüfung").strip().upper() == "X (URL COPIED)" + + return force_reeval or wiki_ts_an_missing or status_s_indicates_reparse + + # _is_wiki_verification_needed Helfer (spezifisch für AX) + def _is_wiki_verification_needed(self, row_data, force_reeval, wiki_data_updated_in_this_run): # Abhängig von wiki_data_updated_in_this_run + """Prüft, ob Wikipedia Verifizierung nötig ist (AX Timestamp oder Wiki Daten aktualisiert).""" + # Wiki Verifizierung (S-U, AX) ist nötig, wenn: force_reeval ODER AX fehlt ODER Wiki Daten gerade aktualisiert wurden + return self._is_step_processing_needed(row_data, "Wiki Verif. Timestamp", force_reeval, wiki_data_updated_in_this_run) + + # _is_branch_evaluation_needed Helfer (spezifisch für AO) + def _is_branch_evaluation_needed(self, row_data, force_reeval, inputs_updated_in_this_run): # Abhängig von wiki_data_updated_in_this_run ODER website_data_updated_in_this_run + """Prüft, ob Branch Evaluation nötig ist (AO Timestamp oder Inputs (Wiki/Web) aktualisiert).""" + # Branch Evaluation ist nötig, wenn: force_reeval ODER AO fehlt ODER Inputs (Wiki/Web) wurden aktualisiert + return self._is_step_processing_needed(row_data, "Timestamp letzte Prüfung", force_reeval, inputs_updated_in_this_run) + + # Fügen Sie hier weitere _is_xxx_needed Methoden für andere Schritte hinzu (FSM, MA, Umsatz Schätzung) + # Diese prüfen jeweils ihren spezifischen Trigger (eigenen Timestamp ODER Inputs). + # Z.B. FSM hat keinen eigenen TS, wird getriggert wenn Branch Eval inputs (Wiki/Web) aktualisiert ODER Branch Eval selbst neu gemacht wurde. + # Implementierung könnte so aussehen: + # def _is_fsm_evaluation_needed(self, row_data, force_reeval, inputs_updated_in_this_run): + # # FSM hat keinen eigenen Timestamp, AO wird für Branch Eval verwendet + # # Wir triggern FSM, wenn Branch Eval triggern würde ODER Inputs aktualisiert wurden + # branch_eval_trigger = self._is_step_processing_needed(row_data, "Timestamp letzte Prüfung", force_reeval, inputs_updated_in_this_run) + # return branch_eval_trigger # FSM wird getriggert, wenn der Branch Step getriggert wird. (Vereinfachung) + + + # --- Methode für den Re-Eval Modus --- + # Diese Methode gehört in die Klasse DataProcessor + def process_reevaluation_rows(self, row_limit=None, clear_flag=True, + process_wiki_steps=True, + process_chatgpt_steps=True, + process_website_steps=True): + """ + Verarbeitet nur Zeilen, die in Spalte A mit 'x' markiert sind. + Ruft _process_single_row für jede dieser Zeilen auf mit force_reeval=True. + Verarbeitet maximal row_limit Zeilen. + Löscht optional das 'x'-Flag nach erfolgreicher Verarbeitung. + Erlaubt die Auswahl spezifischer Verarbeitungsschritte. + + Args: + row_limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None. + clear_flag (bool, optional): Flag 'x' nach erfolgreicher Verarbeitung löschen. Defaults to True. + process_wiki_steps (bool, optional): Soll die Gruppe Wiki-Schritte ausgeführt werden?. Defaults to True. + process_chatgpt_steps (bool, optional): Soll die Gruppe ChatGPT-Schritte ausgeführt werden?. Defaults to True. + process_website_steps (bool, optional): Soll die Gruppe Website-Schritte ausgeführt werden?. Defaults to True. + """ + logging.info(f"Starte Re-Evaluierungsmodus (Spalte A = 'x'). Max. Zeilen: {row_limit if row_limit is not None else 'Unbegrenzt'}") + selected_steps_log = [] + if process_wiki_steps: selected_steps_log.append("Wiki") + if process_chatgpt_steps: selected_steps_log.append("ChatGPT") + if process_website_steps: selected_steps_log.append("Website") + logging.info(f"Ausgewählte Schritte für Re-Eval: {', '.join(selected_steps_log) if selected_steps_log else 'Keine ausgewählt!'} (force_reeval=True)") + + if not self.sheet_handler.load_data(): return logging.error("Fehler beim Laden der Daten für Re-Evaluation.") + all_data = self.sheet_handler.get_all_data_with_headers() + header_rows = 5 + if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten für Re-Evaluation gefunden.") + + reeval_col_idx = COLUMN_MAP.get("ReEval Flag") # Annahme: COLUMN_MAP ist global + if reeval_col_idx is None: return logging.critical("FEHLER: 'ReEval Flag' Spaltenindex nicht in COLUMN_MAP gefunden.") + + rows_to_process = [] + for idx_in_list in range(header_rows, len(all_data)): + row_data = all_data[idx_in_list] + row_num_in_sheet = idx_in_list + 1 + # Sicherstellen, dass die Zeile lang genug ist für Spalte A + if len(row_data) > reeval_col_idx and str(row_data[reeval_col_idx]).strip().lower() == "x": + rows_to_process.append({'row_num': row_num_in_sheet, 'data': row_data}) + + found_count = len(rows_to_process) + logging.info(f"{found_count} Zeilen mit ReEval-Flag 'x' gefunden.") + if found_count == 0: return logging.info("Keine Zeilen zur Re-Evaluation markiert.") + + + processed_count = 0 + updates_clear_flag = [] + rows_actually_processed = [] + + for task in rows_to_process: + if limit is not None and processed_count >= limit: + logging.info(f"Zeilenlimit ({limit}) für Re-Evaluation erreicht. Breche weitere Verarbeitung ab.") + break + + row_num = task['row_num'] + row_data = task['data'] + try: + # Rufe _process_single_row mit force_reeval=True und den ausgewählten Flags auf + self._process_single_row( + row_num_in_sheet = row_num, + row_data = row_data, + process_wiki = process_wiki_steps, # <<< ÜBERGIBT DIE STEUERUNG + process_chatgpt = process_chatgpt_steps, # <<< ÜBERGIBT DIE STEUERUNG + process_website = process_website_steps, # <<< ÜBERGIBT DIE STEUERUNG + force_reeval = True # <<< BLEIBT HIER TRUE FÜR RE-EVAL MODUS + ) + processed_count += 1 + rows_actually_processed.append(row_num) + + if clear_flag: + flag_col_letter = self.sheet_handler._get_col_letter(reeval_col_idx + 1) + if flag_col_letter: updates_clear_flag.append({'range': f'{flag_col_letter}{row_num}', 'values': [['']]}) + else: logging.error(f"Fehler: Konnte Spaltenbuchstaben für 'ReEval Flag' ({reeval_col_idx+1}) nicht ermitteln.") + + except Exception as e_proc: logging.exception(f"FEHLER bei Re-Evaluation von Zeile {row_num}: {e_proc}") + + if clear_flag and updates_clear_flag: + logging.info(f"Lösche ReEval-Flags für {len(updates_clear_flag)} erfolgreich verarbeitete Zeilen ({rows_actually_processed})...") + success = self.sheet_handler.batch_update_cells(updates_clear_flag) + if success: logging.info("ReEval-Flags erfolgreich gelöscht.") + else: logging.error("FEHLER beim Löschen der ReEval-Flags.") + logging.info(f"Re-Evaluierung abgeschlossen. {processed_count} Zeilen verarbeitet (Limit war: {limit}, Gefunden: {found_count}).") + + + # --- Methode für sequenzielle Verarbeitung (full_run) --- + # Diese Methode gehört in die Klasse DataProcessor + def process_sequential(self, start_sheet_row, num_to_process, + process_wiki=True, process_chatgpt=True, process_website=True): + """ + Verarbeitet eine feste Anzahl von Zeilen beginnend bei einer bestimmten Sheet-Zeile + sequenziell, eine nach der anderen, unter Verwendung von _process_single_row. + _process_single_row prüft dabei die Timestamps/Status (force_reeval=False). + + Args: + start_sheet_row (int): Die 1-basierte Startzeilennummer im Sheet. + num_to_process (int): Die maximale Anzahl der zu verarbeitenden Zeilen. + process_wiki (bool, optional): Soll die Gruppe Wiki-Schritte ausgeführt werden?. Defaults to True. + process_chatgpt (bool, optional): Soll die Gruppe ChatGPT-Schritte ausgeführt werden?. Defaults to True. + process_website (bool, optional): Soll die Gruppe Website-Schritte ausgeführt werden?. Defaults to True. + """ + header_rows = 5 # Annahme + + logging.info(f"Starte sequenzielle Verarbeitung von {num_to_process} Zeilen ab Sheet-Zeile {start_sheet_row}...") + groups_to_attempt_log = [] + if process_website: groups_to_attempt_log.append("Website") + if process_wiki: groups_to_attempt_log.append("Wiki") + if process_chatgpt: groups_to_attempt_log.append("ChatGPT") + logging.info(f"Ausgewählte Schritte: {', '.join(groups_to_attempt_log) if groups_to_attempt_log else 'Keine ausgewählt!'} (Standard-Timestamp/Status-Logik)") + + + if not self.sheet_handler.load_data(): logging.error("Fehler beim Laden der Daten für sequenzielle Verarbeitung."); return + all_data = self.sheet_handler.get_all_data_with_headers() + total_sheet_rows = len(all_data) + + if start_sheet_row > total_sheet_rows or start_sheet_row <= header_rows: + logging.warning(f"Start-Sheet-Zeile {start_sheet_row} liegt außerhalb des gültigen Datenbereichs ({header_rows+1} bis {total_sheet_rows}). Keine Verarbeitung.") + return + + end_sheet_row_inclusive = min(start_sheet_row + num_to_process - 1, total_sheet_rows) + + logging.info(f"Sequenzielle Verarbeitung geplant für Sheet-Zeilen {start_sheet_row} bis {end_sheet_row_inclusive}.") + if start_sheet_row > end_sheet_row_inclusive: logging.warning("Start nach Ende (berechnet nach Limit). Keine Verarbeitung."); return + + + processed_count = 0 + for i in range(start_sheet_row, end_sheet_row_inclusive + 1): + row_num_in_sheet = i + row_data = all_data[i - 1] + + try: + self._process_single_row( + row_num_in_sheet = row_num_in_sheet, + row_data = row_data, + process_wiki = process_wiki, # <<< ÜBERGIBT DIE STEUERUNG + process_chatgpt = process_chatgpt, # <<< ÜBERGIBT DIE STEUERUNG + process_website = process_website, # <<< ÜBERGIBT DIE STEUERUNG + force_reeval = False # <<< WICHTIG: Standard-Timestamp/Status-Logik + ) + processed_count += 1 + + except Exception as e_proc: logging.exception(f"FEHLER bei sequenzieller Verarbeitung von Zeile {row_num_in_sheet}: {e_proc}") + + logging.info(f"Sequenzielle Verarbeitung abgeschlossen. {processed_count} Zeilen bearbeitet im Bereich [{start_sheet_row}, {end_sheet_row_inclusive}].") + + + # --- Methode zum Prozessieren von Zeilen nach Kriterien (NEU) --- + # Diese Methode gehört in die Klasse DataProcessor + def process_rows_matching_criteria(self, criteria_func, limit=None, + process_wiki=True, process_chatgpt=True, process_website=True, + force_step_reeval=False): + """ + Sucht Zeilen im Sheet, die ein gegebenes Kriterium erfüllen (definiert durch criteria_func). + Verarbeitet eine begrenzte Anzahl dieser passenden Zeilen unter Verwendung von _process_single_row. + + Args: + criteria_func (callable): Eine Funktion, die eine Zeile (list) nimmt und True zurückgibt, wenn das Kriterium erfüllt ist. + limit (int, optional): Maximale Anzahl passender Zeilen, die verarbeitet werden sollen. Defaults to None (alle passenden). + process_wiki (bool, optional): Soll die Gruppe Wiki-Schritte ausgeführt werden?. Defaults to True. + process_chatgpt (bool, optional): Soll die Gruppe ChatGPT-Schritte ausgeführt werden?. Defaults to True. + process_website (bool, optional): Soll die Gruppe Website-Schritte ausgeführt werden?. Defaults to True. + force_step_reeval (bool, optional): Bestimmt, ob _process_single_row mit force_reeval=True aufgerufen wird (ignoriert Timestamps für ausgewählte Schritte). Defaults to False. + """ + logging.info(f"Starte Verarbeitung von Zeilen nach Kriterien. Limit: {limit if limit is not None else 'Unbegrenzt'}") + logging.info(f"Verwendetes Kriterium: {criteria_func.__name__}") + groups_to_attempt_log = [] + if process_website: groups_to_attempt_log.append("Website") + if process_wiki: groups_to_attempt_log.append("Wiki") + if process_chatgpt: groups_to_attempt_log.append("ChatGPT") + logging.info(f"Ausgewählte Schritte: {', '.join(groups_to_attempt_log) if groups_to_attempt_log else 'Keine ausgewählt!'}") + logging.info(f"force_reeval für Schritte: {force_step_reeval}") + + + if not self.sheet_handler.load_data(): logging.error("Fehler beim Laden der Daten für kriterienbasierte Verarbeitung."); return + all_data = self.sheet_handler.get_all_data_with_headers(); header_rows = 5 + if not all_data or len(all_data) <= header_rows: logging.warning("Keine Daten für kriterienbasierte Verarbeitung gefunden."); return + + rows_to_process = [] + logging.info("Suche nach Zeilen, die dem Kriterium entsprechen...") + for idx_in_list in range(header_rows, len(all_data)): + row_data = all_data[idx_in_list] + row_num_in_sheet = idx_in_list + 1 + + try: + if criteria_func(row_data): # Nutze die globale Kriterien-Funktion + rows_to_process.append({'row_num': row_num_in_sheet, 'data': row_data}) + except Exception as e_crit: logging.error(f"FEHLER beim Prüfen des Kriteriums für Zeile {row_num_in_sheet}: {e_crit}"); + + found_count = len(rows_to_process) + logging.info(f"{found_count} Zeilen entsprechen dem Kriterium '{criteria_func.__name__}'.") + if found_count == 0: logging.info("Keine Zeilen gefunden, die dem Kriterium entsprechen."); return + + processed_count = 0 + for task in rows_to_process: + if limit is not None and processed_count >= limit: + logging.info(f"Limit ({limit}) für kriterienbasierte Verarbeitung erreicht. Breche weitere Verarbeitung ab.") + break + + row_num = task['row_num']; row_data = task['data'] + try: + self._process_single_row( + row_num_in_sheet = row_num, + row_data = row_data, + process_wiki = process_wiki, + process_chatgpt = process_chatgpt, + process_website = process_website, + force_reeval = force_step_reeval + ) + processed_count += 1 + + except Exception as e_proc: logging.exception(f"FEHLER bei Verarbeitung einer Kriterium-Zeile ({row_num}): {e_proc}") + + logging.info(f"Kriterienbasierte Verarbeitung abgeschlossen. {processed_count} von {found_count} gefundenen Zeilen bearbeitet (Limit war: {limit}).") + + + # --- Batch-Verarbeitung Methoden (Werden von run_batch_dispatcher aufgerufen) --- + # Diese Methoden führen eine spezifische Aufgabe für einen Batch aus, basierend auf einem Timestamp. + # Sie rufen NICHT _process_single_row auf. + + def process_verification_batch(self, limit=None): + """ + Batch-Prozess NUR für Wikipedia-Verifizierung (Spalten S-U, AX). + Findet Startzeile ab erster Zelle mit leerem AX. + """ + logging.info(f"Starte Wikipedia-Verifizierungs-Batch. Limit: {limit if limit is not None else 'Unbegrenzt'}") + if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.") + all_data = self.sheet_handler.get_all_data_with_headers(); header_rows = 5 + if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten gefunden.") + + timestamp_col_key = "Wiki Verif. Timestamp"; timestamp_col_index = COLUMN_MAP.get(timestamp_col_key); if timestamp_col_index is None: return logging.critical(f"FEHLER: Schlüssel '{timestamp_col_key}' fehlt.") + ts_col_letter = self.sheet_handler._get_col_letter(timestamp_col_index + 1) + + start_data_index = self.sheet_handler.get_start_row_index(check_column_key=timestamp_col_key, min_sheet_row=header_rows + 1); if start_data_index == -1: return logging.error(f"FEHLER bei Startzeilensuche auf Spalte '{timestamp_col_key}'."); if start_data_index >= len(self.sheet_handler.get_data()): logging.info("Alle Zeilen mit Timestamp gefüllt. Nichts zu tun."); return + + start_sheet_row = start_data_index + header_rows + 1; total_sheet_rows = len(all_data); end_sheet_row = total_sheet_rows + if limit is not None and limit >= 0: end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows); if limit == 0: logging.info("Limit 0."); return + if start_sheet_row > end_sheet_row: logging.warning("Start nach Ende (Limit)."); return + + logging.info(f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} für Wiki Verifizierung (Batch).") + + batch_size = Config.BATCH_SIZE; current_batch = []; current_row_numbers = []; processed_count = 0 + + for i in range(start_sheet_row, end_sheet_row + 1): + row_index_in_list = i - 1; row = all_data[row_index_in_list] + + company_name = self._get_cell_value(row, "CRM Name"); crm_desc = self._get_cell_value(row, "CRM Beschreibung") + wiki_url = self._get_cell_value(row, "Wiki URL"); wiki_paragraph = self._get_cell_value(row, "Wiki Absatz") + wiki_categories = self._get_cell_value(row, "Wiki Kategorien") + + if wiki_url != 'k.A.' or wiki_paragraph != 'k.A.' or wiki_categories != 'k.A.': + entry_text = ( f"Eintrag {i}:\n" f" Firmenname: {company_name}\n" f" CRM-Beschreibung: {crm_desc[:200]}...\n" f" Wikipedia-URL: {wiki_url}\n" f" Wiki-Absatz: {wiki_paragraph[:200]}...\n" f" Wiki-Kategorien: {wiki_categories[:200]}...\n" f"----\n" ) + current_batch.append(entry_text); current_row_numbers.append(i); processed_count += 1 + + if len(current_batch) >= batch_size or i == end_sheet_row: + if current_batch: + try: _process_batch(self.sheet_handler.sheet, current_batch, current_row_numbers); # Globale Helferfunktion + wiki_ts_updates = []; current_wiki_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S"); + for row_num in current_row_numbers: wiki_ts_updates.append({'range': f'{ts_col_letter}{row_num}', 'values': [[current_wiki_timestamp]]}) + if wiki_ts_updates: success_ts = self.sheet_handler.batch_update_cells(wiki_ts_updates); if success_ts: logging.debug(f"Wiki Verif. Timestamp {ts_col_letter} für Batch {current_row_numbers[0]}-{current_row_numbers[-1]} gesetzt."); else: logging.error(f"FEHLER beim Setzen des Wiki Verif. Timestamps {ts_col_letter} für Batch."); + except Exception as e_batch: logging.error(f"FEHLER bei Verarbeitung von Batch {current_row_numbers[0]}-{current_row_numbers[-1]} in _process_batch: {e_batch}"); pass + time.sleep(Config.RETRY_DELAY) + current_batch = []; current_row_numbers = [] + logging.info(f"Wikipedia-Verifizierungs-Batch abgeschlossen. {processed_count} Zeilen in Batches verarbeitet.") + + # process_website_batch Methode + def process_website_batch(self, limit=None): + """ + Batch-Prozess NUR für Website-Scraping (Rohtext AR, Timestamp AT). + Findet Startzeile ab erster Zelle mit leerem AT. + """ + logging.info(f"Starte Website-Scraping Batch. Limit: {limit if limit is not None else 'Unbegrenzt'}") + if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.") + all_data = self.sheet_handler.get_all_data_with_headers(); header_rows = 5 + if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten gefunden.") + + rohtext_col_key = "Website Rohtext"; rohtext_col_index = COLUMN_MAP.get(rohtext_col_key) + website_col_idx = COLUMN_MAP.get("CRM Website"); version_col_idx = COLUMN_MAP.get("Version") + timestamp_col_key = "Website Scrape Timestamp"; timestamp_col_index = COLUMN_MAP.get(timestamp_col_key) + if None in [rohtext_col_index, website_col_idx, version_col_idx, timestamp_col_index]: return logging.critical(f"FEHLER: Benötigte Indizes fehlen."); + rohtext_col_letter = self.sheet_handler._get_col_letter(rohtext_col_index + 1); version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1); ts_col_letter = self.sheet_handler._get_col_letter(timestamp_col_index + 1) + + start_data_index = self.sheet_handler.get_start_row_index(check_column_key=timestamp_col_key, min_sheet_row=header_rows + 1); if start_data_index == -1: return logging.error(f"FEHLER bei Startzeilensuche."); if start_data_index >= len(self.sheet_handler.get_data()): logging.info("Alle Zeilen mit Timestamp gefüllt. Nichts zu tun."); return + + start_sheet_row = start_data_index + header_rows + 1; total_sheet_rows = len(all_data); end_sheet_row = total_sheet_rows + if limit is not None and limit >= 0: end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows); if limit == 0: logging.info("Limit 0."); return + if start_sheet_row > end_sheet_row: logging.warning("Start nach Ende (Limit)."); return + logging.info(f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} für Website Scraping (Batch).") + + # Worker-Funktion für Scraping (Globale Helferfunktion) + def scrape_raw_text_task(task_info): # Needs access to get_website_raw (global) + row_num = task_info['row_num']; url = task_info['url']; raw_text = "k.A."; error = None + try: raw_text = get_website_raw(url); # Annahme: get_website_raw ist global mit Retry + except Exception as e: error = f"Scraping Fehler Zeile {row_num}: {e}"; logging.error(error); + return {"row_num": row_num, "raw_text": raw_text, "error": error} + + tasks_for_processing_batch = []; all_sheet_updates = []; processed_count = 0; skipped_url_count = 0 + processing_batch_size = Config.PROCESSING_BATCH_SIZE; max_scraping_workers = Config.MAX_SCRAPING_WORKERS; + + for i in range(start_sheet_row, end_sheet_row + 1): + row_index_in_list = i - 1; row = all_data[row_index_in_list] + website_url = row[website_col_idx] if len(row) > website_col_idx else ""; if not website_url or website_url.strip().lower() == "k.A.": skipped_url_count += 1; continue + tasks_for_processing_batch.append({"row_num": i, "url": website_url}); processed_count += 1 + + if len(tasks_for_processing_batch) >= processing_batch_size or i == end_sheet_row: + if tasks_for_processing_batch: + batch_start_row = tasks_for_processing_batch[0]['row_num']; batch_end_row = tasks_for_processing_batch[-1]['row_num']; batch_task_count = len(tasks_for_processing_batch) + logging.info(f"\n--- Starte Scraping-Batch ({batch_task_count} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") + scraping_results = {}; batch_error_count = 0; logging.info(f" Scrape {batch_task_count} Websites parallel (max {max_scraping_workers} worker)...") + with concurrent.futures.ThreadPoolExecutor(max_workers=max_scraping_workers) as executor: + future_to_task = {executor.submit(scrape_raw_text_task, task): task for task in tasks_for_processing_batch} + for future in concurrent.futures.as_completed(future_to_task): + task = future_to_task[future]; try: result = future.result(); scraping_results[result['row_num']] = result['raw_text']; if result['error']: batch_error_count += 1; + except Exception as exc: row_num = task['row_num']; err_msg = f"Generischer Fehler Scraping Task Zeile {row_num}: {exc}"; logging.error(err_msg); scraping_results[row_num] = "k.A. (Fehler)"; batch_error_count += 1; + logging.info(f" Scraping für Batch beendet. {len(scraping_results)} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).") + if scraping_results: + current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S"); batch_sheet_updates = []; + for row_num, raw_text_res in scraping_results.items(): + batch_sheet_updates.extend([ {'range': f'{rohtext_col_letter}{row_num}', 'values': [[raw_text_res]]}, {'range': f'{ts_col_letter}{row_num}', 'values': [[current_timestamp]]} ]) + all_sheet_updates.extend(batch_sheet_updates); + if all_sheet_updates: logging.info(f" Sende Sheet-Update für {len(all_sheet_updates)} Zellen für Batch {batch_start_row}-{batch_end_row}..."); success = self.sheet_handler.batch_update_cells(all_sheet_updates); if success: logging.info(f" Sheet-Update erfolgreich."); else: logging.error(f" FEHLER beim Sheet-Update."); all_sheet_updates = []; + logging.debug(" Warte nach Batch..."); time.sleep(Config.RETRY_DELAY); + tasks_for_processing_batch = []; + + if all_sheet_updates: logging.info(f"Sende finalen Sheet-Update ({len(all_sheet_updates)} Zellen)..."); self.sheet_handler.batch_update_cells(all_sheet_updates); + logging.info(f"Website-Scraping Batch abgeschlossen. {processed_count} Tasks erstellt, {skipped_url_count} Zeilen ohne URL übersprungen.") + + # process_summarization_batch Methode + # Kopieren Sie die Logik aus Ihrer globalen process_website_summarization_batch Funktion hierher und passen Sie sie an self an. + # Sie braucht Zugriff auf summarize_batch_openai (global oder private helper). + def process_summarization_batch(self, limit=None): + """ + Batch-Prozess NUR für Website-Zusammenfassung (AS). + Findet Startzeile ab erster Zelle mit leerem AS, wo AR gefüllt ist. + """ + logging.info(f"Starte Website-Zusammenfassung Batch. Limit: {limit if limit is not None else 'Unbegrenzt'}") + if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.") + all_data = self.sheet_handler.get_all_data_with_headers(); header_rows = 5 + if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten gefunden.") + + rohtext_col_idx = COLUMN_MAP.get("Website Rohtext"); summary_col_idx = COLUMN_MAP.get("Website Zusammenfassung"); version_col_idx = COLUMN_MAP.get("Version"); + if None in [rohtext_col_idx, summary_col_idx, version_col_idx]: return logging.critical(f"FEHLER: Benötigte Indizes fehlen."); + summary_col_letter = self.sheet_handler._get_col_letter(summary_col_idx + 1); version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1); + + start_sheet_row = header_rows + 1; logging.info(f"Suche Startzeile für Zusammenfassungs-Batch (leeres AS, gefülltes AR)..."); found_start_row = None + for i in range(header_rows, len(all_data)): + row = all_data[i]; row_num_in_sheet = i + 1; + if len(row) <= max(rohtext_col_idx, summary_col_idx): continue; + ar_value = str(row[rohtext_col_idx]).strip(); as_value = str(row[summary_col_idx]).strip(); + ar_is_filled = bool(ar_value) and ar_value.lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]; as_is_empty = not bool(as_value); + if ar_is_filled and as_is_empty: found_start_row = row_num_in_sheet; logging.info(f"Startzeile gefunden: {found_start_row}."); break; + if found_start_row is None: logging.info("Keine Zeilen gefunden, die Zusammenfassung benötigen."); return; + + start_sheet_row = found_start_row; total_sheet_rows = len(all_data); end_sheet_row = total_sheet_rows; + if limit is not None and limit >= 0: end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows); if limit == 0: logging.info("Limit 0."); return; + if start_sheet_row > end_sheet_row: logging.warning("Start nach Ende (Limit)."); return; + logging.info(f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} für Website Zusammenfassung (Batch).") + + tasks_for_openai_batch = []; all_sheet_updates = []; processed_count = 0; openai_batch_size = Config.OPENAI_BATCH_SIZE_LIMIT; + + for i in range(start_sheet_row, end_sheet_row + 1): + row_index_in_list = i - 1; row = all_data[row_index_in_list]; + if len(row) <= max(rohtext_col_idx, summary_col_idx): continue; + ar_value = str(row[rohtext_col_idx]).strip(); as_value = str(row[summary_col_idx]).strip(); + ar_is_filled = bool(ar_value) and ar_value.lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]; as_is_empty = not bool(as_value); + if not (ar_is_filled and as_is_empty): logging.debug(f"Zeile {i}: Kriterium passt nicht mehr, übersprungen."); continue; + + tasks_for_openai_batch.append({'row_num': i, 'raw_text': ar_value}); processed_count += 1; + + if tasks_for_openai_batch and (len(tasks_for_openai_batch) >= openai_batch_size or i == end_sheet_row): + debug_print(f" Verarbeite OpenAI Batch für {len(tasks_for_openai_batch)} Aufgaben (Start: {tasks_for_openai_batch[0]['row_num']})...") + try: summaries_result = summarize_batch_openai(tasks_for_openai_batch); # Globale Funktion mit Retry + current_version = Config.VERSION; + for task in tasks_for_openai_batch: + row_num = task['row_num']; summary = summaries_result.get(row_num, "k.A. (Fehler Batch Zuordnung)"); + batch_sheet_updates = [ {'range': f'{summary_col_letter}{row_num}', 'values': [[summary]]}, # {'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]} # AP setzen + ]; all_sheet_updates.extend(batch_sheet_updates); + if all_sheet_updates: logging.info(f" Sende Sheet-Update für {len(tasks_for_openai_batch)} Zusammenfassungen ({len(all_sheet_updates)} Zellen)..."); success = self.sheet_handler.batch_update_cells(all_sheet_updates); if success: logging.info(f" Sheet-Update erfolgreich."); else: logging.error(f" FEHLER beim Sheet-Update."); all_sheet_updates = []; + except Exception as e_batch: logging.error(f"FEHLER bei Verarbeitung von OpenAI Batch {tasks_for_openai_batch[0]['row_num']}-{tasks_for_openai_batch[-1]['row_num']}: {e_batch}"); pass; + tasks_for_openai_batch = []; time.sleep(Config.RETRY_DELAY); + + if all_sheet_updates: logging.info(f"Sende finalen Sheet-Update ({len(all_sheet_updates)} Zellen)..."); self.sheet_handler.batch_update_cells(all_sheet_updates); + logging.info(f"Website-Zusammenfassung Batch abgeschlossen. {processed_count} Tasks erstellt.") + + # process_branch_batch Methode + # Kopieren Sie die Logik aus Ihrer globalen process_branch_batch Funktion hierher und passen Sie sie an self an. + # Sie braucht Zugriff auf evaluate_branche_chatgpt (global) und openai_semaphore_branch (global?). + # Das Semaphor sollte eher eine Instanzvariable sein oder an den Worker übergeben werden. + # Machen wir das Semaphor global und übergeben es. + def process_branch_batch(self, limit=None): + """ + Batch-Prozess NUR für Branchen-Einschätzung (W-Y, AO). + Findet Startzeile ab erster Zelle mit leerem AO. + """ + logging.info(f"Starte Branchen-Einschätzung Batch. Limit: {limit if limit is not None else 'Unbegrenzt'}") + if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.") + all_data = self.sheet_handler.get_all_data_with_headers(); header_rows = 5 + if not all_data or len(all_data) <= header_rows: return logging.warning("Keine Daten gefunden.") + + timestamp_col_key = "Timestamp letzte Prüfung"; timestamp_col_index = COLUMN_MAP.get(timestamp_col_key); if timestamp_col_index is None: return logging.critical(f"FEHLER: Schlüssel '{timestamp_col_key}' fehlt.") + branche_crm_idx = COLUMN_MAP.get("CRM Branche"); beschreibung_idx = COLUMN_MAP.get("CRM Beschreibung"); branche_wiki_idx = COLUMN_MAP.get("Wiki Branche"); kategorien_wiki_idx = COLUMN_MAP.get("Wiki Kategorien"); summary_web_idx = COLUMN_MAP.get("Website Zusammenfassung"); version_col_idx = COLUMN_MAP.get("Version"); + branch_w_idx = COLUMN_MAP.get("Chat Vorschlag Branche"); branch_x_idx = COLUMN_MAP.get("Chat Konsistenz Branche"); branch_y_idx = COLUMN_MAP.get("Chat Begründung Abweichung Branche"); + required_indices = [timestamp_col_index, branche_crm_idx, beschreibung_idx, branche_wiki_idx, kategorien_wiki_idx, summary_web_idx, version_col_idx, branch_w_idx, branch_x_idx, branch_y_idx]; + if None in required_indices: return logging.critical(f"FEHLER: Benötigte Indizes fehlen."); + + ts_col_letter = self.sheet_handler._get_col_letter(timestamp_col_index + 1); version_col_letter = self.sheet_handler._get_col_letter(version_col_idx + 1); + branch_w_letter = self.sheet_handler._get_col_letter(branch_w_idx + 1); branch_x_letter = self.sheet_handler._get_col_letter(branch_x_idx + 1); branch_y_letter = self.sheet_handler._get_col_letter(branch_y_idx + 1); + + start_data_index = self.sheet_handler.get_start_row_index(check_column_key=timestamp_col_key, min_sheet_row=header_rows + 1); if start_data_index == -1: return logging.error(f"FEHLER bei Startzeilensuche."); if start_data_index >= len(self.sheet_handler.get_data()): logging.info("Alle Zeilen mit Timestamp gefüllt. Nichts zu tun."); return; + + start_sheet_row = start_data_index + header_rows + 1; total_sheet_rows = len(all_data); end_sheet_row = total_sheet_rows; + if limit is not None and limit >= 0: end_sheet_row = min(start_sheet_row + limit - 1, total_sheet_rows); if limit == 0: logging.info("Limit 0."); return; + if start_sheet_row > end_sheet_row: logging.warning("Start nach Ende (Limit)."); return; + logging.info(f"Verarbeite Sheet-Zeilen {start_sheet_row} bis {end_sheet_row} für Branchen-Einschätzung (Batch).") + + MAX_BRANCH_WORKERS = Config.MAX_BRANCH_WORKERS; OPENAI_CONCURRENCY_LIMIT = Config.OPENAI_CONCURRENCY_LIMIT; + # Semaphor als globale Variable oder Instanz Variable der Klasse? + # Machen wir es global für Einfachheit in diesem Übergang. + # openai_semaphore_branch = threading.Semaphore(OPENAI_CONCURRENCY_LIMIT) # Annahme: threading ist importiert und Semaphor global + + tasks_for_processing_batch = []; processed_count = 0; + + if not ALLOWED_TARGET_BRANCHES: load_target_schema(); + if not ALLOWED_TARGET_BRANCHES: return logging.critical("FEHLER: Ziel-Schema nicht geladen. Branch Batch nicht möglich.") + + for i in range(start_sheet_row, end_sheet_row + 1): + row_index_in_list = i - 1; row = all_data[row_index_in_list]; + if len(row) <= timestamp_col_index or str(row[timestamp_col_index]).strip(): logging.debug(f"Zeile {i}: Timestamp {ts_col_letter} nicht leer, übersprungen."); continue; + task_data = { "row_num": i, "crm_branche": self._get_cell_value(row, "CRM Branche"), "beschreibung": self._get_cell_value(row, "CRM Beschreibung"), "wiki_branche": self._get_cell_value(row, "Wiki Branche"), "wiki_kategorien": self._get_cell_value(row, "Wiki Kategorien"), "website_summary": self._get_cell_value(row, "Website Zusammenfassung") }; + tasks_for_processing_batch.append(task_data); processed_count += 1; + + if len(tasks_for_processing_batch) >= Config.PROCESSING_BRANCH_BATCH_SIZE or i == end_sheet_row: + if tasks_for_processing_batch: + batch_start_row = tasks_for_processing_batch[0]['row_num']; batch_end_row = tasks_for_processing_batch[-1]['row_num']; batch_task_count = len(tasks_for_processing_batch); + logging.info(f"\n--- Starte Branch-Evaluation Batch ({batch_task_count} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") + results_list = []; batch_error_count = 0; logging.info(f" Evaluiere {batch_task_count} Zeilen parallel (max {MAX_BRANCH_WORKERS} worker, {OPENAI_CONCURRENCY_LIMIT} OpenAI gleichzeitig)...") + # Worker Funktion für Branch Evaluation (muss hier oder global sein) + # Machen wir sie global wie _process_batch, da sie Semaphor nutzt. + # Definiere _evaluate_branch_task_worker(task_data, semaphore) + + with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_BRANCH_WORKERS) as executor: + # Submit Aufgaben an den Executor + # Annahme: openai_semaphore_branch ist global initialisiert + future_to_task = {executor.submit(_evaluate_branch_task_worker, task, openai_semaphore_branch): task for task in tasks_for_processing_batch} # Annahme: _evaluate_branch_task_worker ist global + + for future in concurrent.futures.as_completed(future_to_task): + task = future_to_task[future]; try: result_data = future.result(); results_list.append(result_data); if result_data['error']: batch_error_count += 1; + except Exception as exc: row_num = task['row_num']; err_msg = f"Generischer Fehler Branch Task Zeile {row_num}: {exc}"; logging.error(err_msg); results_list.append({"row_num": row_num, "result": {"branch": "FEHLER", "consistency": "error_task", "justification": err_msg[:500]}, "error": err_msg}); batch_error_count += 1; + logging.info(f" Branch-Evaluation für Batch beendet. {len(results_list)} Ergebnisse erhalten ({batch_error_count} Fehler in diesem Batch).") + if results_list: + current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S"); current_version = Config.VERSION; batch_sheet_updates = []; results_list.sort(key=lambda x: x['row_num']); + for res_data in results_list: + row_num = res_data['row_num']; result = res_data['result']; logging.debug(f" Zeile {row_num}: Ergebnis -> Branch='{result.get('branch')}', Consistency='{result.get('consistency')}', Justification='{result.get('justification', '')[:50]}...'"); + batch_sheet_updates.extend([ + {'range': f'{branch_w_letter}{row_num}', 'values': [[result.get("branch", "Fehler")]]}, {'range': f'{branch_x_letter}{row_num}', 'values': [[result.get("consistency", "Fehler")]]}, {'range': f'{branch_y_letter}{row_num}', 'values': [[result.get("justification", "Fehler")]]}, {'range': f'{ts_col_letter}{row_num}', 'values': [[current_timestamp]]}, {'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]} + ]); + if batch_sheet_updates: logging.info(f" Sende Sheet-Update für {len(results_list)} Zeilen ({len(batch_sheet_updates)} Zellen)..."); success = self.sheet_handler.batch_update_cells(batch_sheet_updates); if success: logging.info(f" Sheet-Update erfolgreich."); else: logging.error(f" FEHLER beim Sheet-Update."); all_sheet_updates = []; + else: logging.debug(f" Keine Sheet-Updates vorbereitet.") + tasks_for_processing_batch = []; logging.debug(f"--- Verarbeitungs-Batch {batch_start_row}-{batch_end_row} abgeschlossen ---"); logging.debug(" Warte nach Batch..."); time.sleep(Config.RETRY_DELAY); + if all_sheet_updates: logging.info(f"Sende finalen Sheet-Update ({len(all_sheet_updates)} Zellen)..."); self.sheet_handler.batch_update_cells(all_sheet_updates); + logging.info(f"Branchen-Einschätzung Batch abgeschlossen. {processed_count} Tasks erstellt.") + + + # --- Dienstprogramm Methoden (Werden von run_user_interface aufgerufen) --- + # Diese Methoden führen eine spezifische Aufgabe aus und arbeiten oft über das gesamte Sheet + # oder eine gefilterte Menge. + + # process_serp_website_lookup Methode (früher process_serp_website_lookup_for_empty) + def process_serp_website_lookup(self, limit=None): # <<< Methode in DataProcessor + """ + Sucht fehlende Websites (Spalte D ist leer oder "k.A.") via SERP API + (Google Search) und trägt gefundene URLs in Spalte D ein. + + Args: + limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None. + """ + logging.info(f"Starte Modus: SERP API Website Lookup für leere Zellen in Spalte D. Limit: {limit if limit is not None else 'Unbegrenzt'}") + if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.") + data_rows = self.sheet_handler.get_data(); header_rows = 5; + rows_processed_count = 0; updates = []; + try: website_col_idx = COLUMN_MAP["CRM Website"]; name_col_idx = COLUMN_MAP["CRM Name"]; website_col_letter = self.sheet_handler._get_col_letter(website_col_idx + 1); + except KeyError as e: logging.critical(f"FEHLER: Benötigte Spalte '{e}' fehlt."); return; + except Exception as e: logging.critical(f"FEHLER beim Holen der Spaltenbuchstaben: {e}"); return; + + for i, row in enumerate(data_rows): + row_num_in_sheet = i + header_rows + 1; + if limit is not None and rows_processed_count >= limit: logging.info(f"Limit ({limit}) erreicht."); break; + max_needed_idx = max(website_col_idx, name_col_idx); if len(row) <= max_needed_idx: logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz)."); continue; + current_website = row[website_col_idx] if len(row) > website_col_idx else ""; + if not current_website or str(current_website).strip().lower() == "k.a.": + company_name = row[name_col_idx] if len(row) > name_col_idx else ""; if not company_name or str(company_name).strip() == "": logging.warning(f"Zeile {row_num_in_sheet}: Übersprungen (kein Firmenname)."); continue; + logging.info(f"Zeile {row_num_in_sheet}: Suche Website für '{company_name}'..."); + new_website = serp_website_lookup(company_name); # Globale Funktion mit Retry + rows_processed_count += 1; + if new_website != "k.A.": updates.append({'range': f'{website_col_letter}{row_num_in_sheet}', 'values': [[new_website]]}); logging.info(f"Zeile {row_num_in_sheet}: Neue Website '{new_website}' gefunden."); + else: logging.info(f"Zeile {row_num_in_sheet}: Keine Website gefunden."); + time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.3); + + if updates: logging.info(f"Sende Batch-Update für {len(updates)} Zellen ({rows_processed_count} Zeilen geprüft)..."); success = self.sheet_handler.batch_update_cells(updates); if success: logging.info(f"Batch-Update erfolgreich."); else: logging.error(f"FEHLER beim Batch-Update."); + else: logging.info("Keine fehlenden Websites gefunden oder keine Updates nötig."); + logging.info(f"Modus 'website_lookup' abgeschlossen. {rows_processed_count} Zeilen geprüft.") + + # process_find_wiki_serp Methode + def process_find_wiki_serp(self, limit=None, min_employees=500, min_umsatz=200): # <<< Methode in DataProcessor + """ + Sucht fehlende Wikipedia-URLs (Spalte M = k.A.) für Unternehmen mit + (Umsatz CRM > min_umsatz MIO € ODER Mitarbeiter CRM > min_employees) + über SerpAPI und trägt gefundene URLs in Spalte M ein. Setzt ReEval-Flag (A) + und löscht abhängige Wiki-Spalten (N-V, AN, AO, AP, AX). + Merkt sich in Spalte AY, wann die Suche durchgeführt wurde. + + Args: + limit (int, optional): Maximale Anzahl zu prüfender Zeilen. Defaults to None. + 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. + """ + logging.info(f"Starte Modus 'find_wiki_serp': Suche fehlende Wiki-URLs für Firmen mit (Umsatz CRM > {min_umsatz} MIO € ODER Mitarbeiter CRM > {min_employees})...") + if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.") + all_data = self.sheet_handler.get_all_data_with_headers(); header_rows = 5; if not all_data or len(all_data) <= header_rows: logging.warning("Keine Daten gefunden."); return + data_rows = all_data[header_rows:]; + col_indices = {}; required_keys = [ "ReEval Flag", "CRM Anzahl Mitarbeiter", "CRM Umsatz", "Wiki URL", "CRM Name", "CRM Website", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Chat Wiki Konsistenzprüfung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", "Begründung bei Abweichung", "Wikipedia Timestamp", "Timestamp letzte Prüfung", "Version", "Wiki Verif. Timestamp", "SerpAPI Wiki Search Timestamp" ]; + all_keys_found = True; for key in required_keys: idx = COLUMN_MAP.get(key); col_indices[key] = idx; if idx is None: logging.critical(f"FEHLER: Schlüssel '{key}' fehlt! Modus abgebrochen."); all_keys_found = False; + if not all_keys_found: return; + col_letters = {key: self.sheet_handler._get_col_letter(idx + 1) for key, idx in col_indices.items()}; + all_sheet_updates = []; processed_rows_count = 0; found_urls_count = 0; skipped_timestamp_ay_count = 0; skipped_size_count = 0; skipped_m_filled_count = 0; + now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S"); + + for idx, row in enumerate(data_rows): + row_num_in_sheet = idx + header_rows + 1; + if limit is not None and processed_rows_count >= limit: logging.info(f"Limit ({limit}) erreicht."); break; + max_needed_idx = max(col_indices.values()); if len(row) <= max_needed_idx: logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz)."); continue; + ts_ay_val = row[col_indices["SerpAPI Wiki Search Timestamp"]]; if ts_ay_val and ts_ay_val.strip(): skipped_timestamp_ay_count += 1; continue; + m_value = row[col_indices["Wiki URL"]]; if m_value and str(m_value).strip().lower() not in ["k.a.", "kein artikel gefunden"]: skipped_m_filled_count += 1; continue; + + umsatz_val_str = row[col_indices["CRM Umsatz"]]; ma_val_str = row[col_indices["CRM Anzahl Mitarbeiter"]]; + umsatz_val_mio = get_numeric_filter_value(umsatz_val_str, is_umsatz=True); # Globale Funktion + ma_val_num = get_numeric_filter_value(ma_val_str, is_umsatz=False); # Globale Funktion + + if not (umsatz_val_mio > min_umsatz or ma_val_num > min_employees): + logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Größe nicht ausreichend. Umsatz (Mio): {umsatz_val_mio:.2f}, MA: {ma_val_num}). Schwellen: Umsatz > {min_umsatz} Mio, MA > {min_employees}."); + skipped_size_count += 1; continue; + + company_name = row[col_indices["CRM Name"]]; if not company_name or str(company_name).strip() == "": logging.warning(f"Zeile {row_num_in_sheet}: Übersprungen, kein Firmenname."); ay_col_letter = col_letters["SerpAPI Wiki Search Timestamp"]; all_sheet_updates.append({'range': f'{ay_col_letter}{row_num_in_sheet}', 'values': [[now_timestamp_str]]}); continue; + + logging.info(f"Zeile {row_num_in_sheet}: Suche Wiki-URL für '{company_name}' (Umsatz (Mio): {umsatz_val_mio:.2f}, MA: {ma_val_num})..."); + processed_rows_count += 1; + website_url = row[col_indices["CRM Website"]] if col_indices["CRM Website"] is not None and len(row) > col_indices["CRM Website"] else None; + wiki_url_found = serp_wikipedia_lookup(company_name, website=website_url); # Globale Funktion mit Retry + + ay_col_letter = col_letters["SerpAPI Wiki Search Timestamp"]; all_sheet_updates.append({'range': f'{ay_col_letter}{row_num_in_sheet}', 'values': [[now_timestamp_str]]}); + + if wiki_url_found and wiki_url_found.strip() and wiki_url_found != "k.A.": + logging.info(f" -> URL gefunden: {wiki_url_found}. Bereite Update vor."); + found_urls_count += 1; m_l = col_letters["Wiki URL"]; a_l = col_letters["ReEval Flag"]; n_idx = col_indices["Wiki Absatz"]; v_idx = col_indices["Begründung bei Abweichung"]; n_l=self.sheet_handler._get_col_letter(n_idx+1); v_l=self.sheet_handler._get_col_letter(v_idx+1); an_l = col_indices["Wikipedia Timestamp"]; ao_l = col_indices["Timestamp letzte Prüfung"]; ap_l = col_letters["Version"]; ax_l = col_letters["Wiki Verif. Timestamp"]; + ao_idx = COLUMN_MAP.get("Timestamp letzte Prüfung"); ao_l=self.sheet_handler._get_col_letter(ao_idx+1); # Korrektur AO_l war Index, muss Buchstabe sein + + all_sheet_updates.extend([ + {'range': f'{m_l}{row_num_in_sheet}', 'values': [[wiki_url_found]]}, {'range': f'{a_l}{row_num_in_sheet}', 'values': [['x']]}, + {'range': f'{n_l}{row_num_in_sheet}:{v_l}{row_num_in_sheet}', 'values': [[''] * (v_idx - n_idx + 1)]}, + {'range': f'{an_l}{row_num_in_sheet}', 'values': [['']]}, {'range': f'{ao_l}{row_num_in_sheet}', 'values': [['']]}, + {'range': f'{ap_l}{row_num_in_sheet}', 'values': [['']]}, {'range': f'{ax_l}{row_num_in_sheet}', 'values': [['']]} + ]); + else: logging.info(f" -> Keine Wiki-URL via SerpAPI gefunden."); + time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.3); + + if all_sheet_updates: logging.info(f"Sende Batch-Update für {len(all_sheet_updates)} Zellen ({processed_rows_count} Zeilen geprüft)..."); success = self.sheet_handler.batch_update_cells(all_sheet_updates); if success: logging.info(f"Sheet-Update erfolgreich."); else: logging.error(f"FEHLER beim Batch-Update."); + else: logging.info("Keine Updates nötig."); + logging.info(f"Modus 'find_wiki_serp' abgeschlossen. {processed_rows_count} Tasks erstellt, {found_urls_count} URLs gefunden, {skipped_timestamp_ay_count} AY gesetzt, {skipped_size_count} Größe, {skipped_m_filled_count} M gefüllt.") + + # process_wiki_updates_from_chatgpt Methode + def process_wiki_updates_from_chatgpt(self, row_limit=None): # <<< Methode in DataProcessor + """ + Identifiziert Zeilen (S nicht OK/Updated/Copied/Invalid), prüft ob U eine *valide* und *andere* Wiki-URL ist. + - Wenn ja: Kopiert U->M, markiert S='X (URL Copied)', U='URL übernommen', löscht TS/Version, setzt ReEval-Flag A. + - Wenn nein (U keine URL, U==M, oder U ungültig): LÖSCHT den Inhalt von U und markiert S als 'X (Invalid Suggestion)'. + Verarbeitet maximal row_limit Zeilen. + """ + logging.info(f"Starte Modus: Wiki-Updates (URL-Validierung & Löschen ungültiger Vorschläge). Limit: {limit if limit is not None else 'Unbegrenzt'}") + if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten.") + all_data = self.sheet_handler.get_all_data_with_headers(); header_rows = 5 + if not all_data or len(all_data) <= header_rows: logging.warning("Keine Daten gefunden."); return + data_rows = all_data[header_rows:] + required_keys = [ "Chat Wiki Konsistenzprüfung", "Chat Vorschlag Wiki Artikel", "Wiki URL", "Wikipedia Timestamp", "Wiki Verif. Timestamp", "Timestamp letzte Prüfung", "Version", "ReEval Flag", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Begründung bei Abweichung" ]; + col_indices = {}; all_keys_found = True; for key in required_keys: idx = COLUMN_MAP.get(key); col_indices[key] = idx; if idx is None: logging.critical(f"FEHLER: Schlüssel '{key}' fehlt! Modus abgebrochen."); all_keys_found = False; + if not all_keys_found: return; + all_sheet_updates = []; processed_rows_count = 0; updated_url_count = 0; cleared_suggestion_count = 0; + + for idx, row in enumerate(data_rows): + row_num_in_sheet = idx + header_rows + 1; + if limit is not None and processed_rows_count >= limit: logging.info(f"Limit ({limit}) erreicht."); break; + max_needed_idx = max(col_indices.values()); if len(row) <= max_needed_idx: logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz)."); continue; + + konsistenz_s = self._get_cell_value(row, "Chat Wiki Konsistenzprüfung").strip(); + vorschlag_u = self._get_cell_value(row, "Chat Vorschlag Wiki Artikel").strip(); + url_m = self._get_cell_value(row, "Wiki URL").strip(); + + konsistenz_s_upper = konsistenz_s.upper(); + is_candidate_for_check = bool(konsistenz_s_upper) and konsistenz_s_upper not in ["OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)", "?"]; + if is_candidate_for_check or (konsistenz_s_upper == "?" and not vorschlag_u): + logging.debug(f"Zeile {row_num_in_sheet}: Kandidat für Wiki-Update-Prüfung (Status S = '{konsistenz_s}'). Vorschlag U = '{vorschlag_u}'"); + processed_rows_count += 1; is_update_candidate = False; new_url = ""; + condition2_u_is_wiki_url = vorschlag_u.lower().startswith(("http://", "https://")) and "wikipedia.org/wiki/" in vorschlag_u.lower(); + if condition2_u_is_wiki_url: + new_url = vorschlag_u; + condition3_u_differs_m = simple_normalize_url(new_url) != simple_normalize_url(url_m); # Global Function + if condition3_u_differs_m: + logging.debug(f" -> Prüfe Validität der neuen URL: {new_url}..."); + try: condition4_u_is_valid = is_valid_wikipedia_article_url(new_url); # Global Function with Retry + except Exception as e_valid: logging.error(f" -> Fehler bei Validierung der URL '{new_url}': {e_valid}. Behandle als ungültig."); condition4_u_is_valid = False; + if condition4_u_is_valid: is_update_candidate = True; logging.debug(f" -> URL '{new_url}' ist ein valider Artikel."); + else: logging.debug(f" -> URL '{new_url}' ist KEIN valider Artikel laut API Check."); + else: logging.debug(f" -> Vorschlag U ist identisch mit URL M."); + else: logging.debug(f" -> Vorschlag U ist keine Wikipedia URL ('{vorschlag_u}')."); + + if is_update_candidate: + logging.info(f"Zeile {row_num_in_sheet}: Update-Kandidat VALIDIERUNG ERFOLGREICH. Setze ReEval-Flag 'x' und bereite Updates vor für URL: {new_url}"); + updated_url_count += 1; + m_l=self.sheet_handler._get_col_letter(col_indices["Wiki URL"]+1); s_l=self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"]+1); u_l=self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"]+1); + a_l=self.sheet_handler._get_col_letter(col_indices["ReEval Flag"]+1); + n_idx = col_indices["Wiki Absatz"]; v_idx = col_indices["Begründung bei Abweichung"]; n_l=self.sheet_handler._get_col_letter(n_idx+1); v_l=self.sheet_handler._get_col_letter(v_idx+1); + an_l=self.sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"]+1); ax_l=self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"]+1); ao_l=self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Prüfung"]+1); ap_l=self.sheet_handler._get_col_letter(col_indices["Version"]+1); + + all_sheet_updates.extend([ + {'range': f'{m_l}{row_num_in_sheet}', 'values': [[new_url]]}, {'range': f'{s_l}{row_num_in_sheet}', 'values': [["X (URL Copied)"]]}, {'range': f'{u_l}{row_num_in_sheet}', 'values': [["URL übernommen"]]}, + {'range': f'{n_l}{row_num_in_sheet}:{v_l}{row_num_in_sheet}', 'values': [[''] * (v_idx - n_idx + 1)]}, + {'range': f'{an_l}{row_num_in_sheet}', 'values': [['']]}, {'range': f'{ax_l}{row_num_in_sheet}', 'values': [['']]}, + {'range': f'{ao_l}{row_num_in_sheet}', 'values': [['']]}, {'range': f'{ap_l}{row_num_in_sheet}', 'values': [['']]}, + {'range': f'{a_l}{row_num_in_sheet}', 'values': [["x"]]}, + ]); + else: # <<< Continue from here + logging.info(f"Zeile {row_num_in_sheet}: Vorschlag U ('{vorschlag_u}') ist ungültig/identisch. Lösche U und setze Status S."); + cleared_suggestion_count += 1; + s_l=self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"]+1); u_l=self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"]+1); + all_sheet_updates.extend([ + {'range': f'{s_l}{row_num_in_sheet}', 'values': [["X (Invalid Suggestion)"]]}, + {'range': f'{u_l}{row_num_in_sheet}', 'values': [[""]]} + ]); + + if all_sheet_updates: + logging.info(f"Sende Batch-Update für {processed_rows_count} geprüfte Zeilen ({len(all_sheet_updates)} Zellen)..."); + success = self.sheet_handler.batch_update_cells(all_sheet_updates); + if success: logging.info(f"Sheet-Update für Wiki-Updates erfolgreich."); + else: logging.error("FEHLER beim Sheet-Update für Wiki-Updates."); + else: logging.info("Keine Zeilen gefunden, die Wiki-Updates benötigen."); + + logging.info(f"Wiki-Updates abgeschlossen. {processed_rows_count} Zeilen geprüft. {updated_url_count} URLs kopiert & für ReEval markiert, {cleared_suggestion_count} ungültige Vorschläge gelöscht/markiert."); + + + # process_website_details Methode (früher process_website_details_for_marked_rows) + def process_website_details(self, limit=None): # <<< Methode in DataProcessor """ EXPERIMENTELL: Extrahiert Website-Details für Zeilen, die mit 'x' in Spalte A markiert sind. Schreibt die Details in eine definierte Spalte (Website Details oder AR als Fallback). Löscht NICHT das 'x'-Flag. + + Args: + limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None. """ - logging.info("Starte Modus (EXPERIMENTELL): Website Detail Extraction für Zeilen mit 'x' in Spalte A.") - - # Daten neu laden - if not self.sheet_handler.load_data(): - logging.error("Fehler beim Laden der Daten für Website Details Extraction.") - return - - data_rows = self.sheet_handler.get_data() # Datenzeilen ohne Header - header_rows = 5 # Annahme - total_rows_in_sheet = len(self.sheet_handler.get_all_data_with_headers()) # Gesamtzahl Zeilen - - rows_processed_count = 0 # Zählt Zeilen, wo eine Extraktion versucht wurde - updates = [] - - # Definiere die Spaltenindizes - try: - reeval_col_idx = COLUMN_MAP["ReEval Flag"] - website_col_idx = COLUMN_MAP["CRM Website"] + logging.info(f"Starte Modus (EXPERIMENTELL): Website Detail Extraction für Zeilen mit 'x' in Spalte A. Limit: {limit if limit is not None else 'Unbegrenzt'}"); + if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten."); + data_rows = self.sheet_handler.get_data(); header_rows = 5; + rows_processed_count = 0; updates = []; + try: reeval_col_idx = COLUMN_MAP["ReEval Flag"]; website_col_idx = COLUMN_MAP["CRM Website"]; # Versuche zuerst die dedizierte Spalte 'Website Details' - details_col_idx = COLUMN_MAP.get("Website Details", None) + details_col_idx = COLUMN_MAP.get("Website Details", None); if details_col_idx is None: # Fallback auf 'Website Rohtext' (AR) wenn 'Website Details' nicht in COLUMN_MAP - details_col_idx = COLUMN_MAP.get("Website Rohtext") - if details_col_idx is None: - logging.critical("FEHLER: Weder 'Website Details' noch 'Website Rohtext' Spaltenindex in COLUMN_MAP gefunden.") - return - logging.warning("Keine Spalte 'Website Details' in COLUMN_MAP, nutze 'Website Rohtext' (AR) als Fallback.") + details_col_idx = COLUMN_MAP.get("Website Rohtext"); + if details_col_idx is None: logging.critical("FEHLER: Weder 'Website Details' noch 'Website Rohtext' Spaltenindex fehlt."); return; + logging.warning("Keine Spalte 'Website Details' in COLUMN_MAP, nutze 'Website Rohtext' (AR) als Fallback."); + details_col_letter = self.sheet_handler._get_col_letter(details_col_idx + 1); + except KeyError as e: logging.critical(f"FEHLER: Benötigte Spalte '{e}' fehlt."); return; + except Exception as e: logging.critical(f"FEHLER beim Holen der Spaltenbuchstaben: {e}"); return; - details_col_letter = self.sheet_handler._get_col_letter(details_col_idx + 1) - - except KeyError as e: - logging.critical(f"FEHLER: Benötigte Spalte '{e}' für Modus 'website_details' nicht in COLUMN_MAP.") - return - except Exception as e: - logging.critical(f"FEHLER beim Holen der Spaltenbuchstaben für 'website_details': {e}") - return - - - # Iteriere über die Datenzeilen (ab der ersten möglichen Zeile, standard 7) for i, row in enumerate(data_rows): - row_num_in_sheet = i + header_rows + 1 # 1-basierte Zeilennummer im Sheet + row_num_in_sheet = i + header_rows + 1; + if limit is not None and rows_processed_count >= limit: logging.info(f"Limit ({limit}) erreicht."); break; + if len(row) <= reeval_col_idx or str(row[reeval_col_idx]).strip().lower() != "x": continue; # Prüfen, ob Zeile mit 'x' markiert ist - # Prüfen, ob die Zeile mit 'x' in Spalte A markiert ist - # Stelle sicher, dass die Zeile lang genug ist für Spalte A - if len(row) <= reeval_col_idx or str(row[reeval_col_idx]).strip().lower() != "x": - # Logging kann hier sehr laut sein, nur bei Bedarf aktivieren - # logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Kein 'x' in Spalte A).") - continue + website_url = row[website_col_idx] if len(row) > website_col_idx else ""; + if not website_url or str(website_url).strip().lower() == "k.a.": logging.warning(f"Zeile {row_num_in_sheet}: Keine gültige Website URL, überspringe."); continue; - # Prüfen, ob eine gültige Website-URL vorhanden ist - website_url = "" - if len(row) > website_col_idx: website_url = row[website_col_idx] + logging.info(f"Zeile {row_num_in_sheet}: Extrahiere Website Details von {website_url}..."); + rows_processed_count += 1; + try: details = scrape_website_details(website_url); # Annahme: scrape_website_details ist global + except NameError: logging.critical("FEHLER: Funktion 'scrape_website_details' nicht definiert!"); details = "FEHLER: Funktion nicht definiert"; + except Exception as e_detail: logging.exception(f"Fehler bei scrape_website_details für {website_url}: {e_detail}"); details = f"FEHLER: {e_detail}"; - if not website_url or str(website_url).strip().lower() == "k.a.": - logging.warning(f"Zeile {row_num_in_sheet}: Übersprungen (keine gültige Website in Spalte {self.sheet_handler._get_col_letter(website_col_idx+1)} vorhanden).") - continue - - logging.info(f"Zeile {row_num_in_sheet}: Extrahiere Website Details von {website_url}...") - rows_processed_count += 1 # Zähle jede Zeile, für die Extraktion versucht wird - - try: - # Annahme: Funktion scrape_website_details existiert - # Diese Funktion MUSS außerhalb der Klasse definiert sein, - # es sei denn, sie wird auch als Methode des DataProcessors gesehen. - # Angenommen, sie ist eine unabhängige Helper-Funktion. - details = scrape_website_details(website_url) - except NameError: - logging.critical("FEHLER: Funktion 'scrape_website_details' ist nicht definiert! Kann Details nicht extrahieren.") - details = "FEHLER: Funktion 'scrape_website_details' nicht definiert" - # Fehler hier abfangen, damit der Prozess nicht abstürzt, aber trotzdem loggen - except Exception as e_detail: - logging.exception(f"Fehler bei scrape_website_details für {website_url}: {e_detail}") - details = f"FEHLER: {e_detail}" - - - # Füge Update für die Details-Spalte hinzu - # Stelle sicher, dass der Wert in einen String konvertiert wird, falls scrape_website_details z.B. ein Dict zurückgibt - updates.append({'range': f'{details_col_letter}{row_num_in_sheet}', 'values': [[str(details)]]}) - logging.info(f"Zeile {row_num_in_sheet}: Details extrahiert und zum Update für Spalte {details_col_letter} hinzugefügt.") - - - # Kleine Pause nach jeder Extraktion - time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.2) - - - # Sende gesammelte Updates in einem Batch - if updates: - logging.info(f"Sende Batch-Update für {len(updates)} Zellen ({rows_processed_count} Zeilen geprüft)...") - # Annahme: sheet_handler.batch_update_cells existiert und nutzt logging/retry - success = self.sheet_handler.batch_update_cells(updates) - if success: - logging.info("Batch-Update für 'website_details' erfolgreich.") - # Der Fehlerfall wird von batch_update_cells geloggt - else: - logging.info("Keine mit 'x' markierten Zeilen gefunden oder keine Updates nötig.") + updates.append({'range': f'{details_col_letter}{row_num_in_sheet}', 'values': [[str(details)]]}); + time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.2); + if updates: logging.info(f"Sende Batch-Update für {len(updates)} Zellen ({rows_processed_count} Zeilen geprüft)..."); success = self.sheet_handler.batch_update_cells(updates); if success: logging.info(f"Batch-Update erfolgreich."); else: logging.error(f"FEHLER beim Batch-Update."); + else: logging.info("Keine 'x' Zeilen gefunden für Detail-Extraktion."); logging.info(f"Modus 'website_details' abgeschlossen. {rows_processed_count} Zeilen geprüft.") - # --- Methode zur Datenvorbereitung für ML --- - # Diese Methode gehört in die Klasse + # process_contact_research Methode + def process_contact_research(self, limit=None): # <<< Methode in DataProcessor + """Sucht LinkedIn Kontakte und trägt sie in 'Contacts' Sheet ein.""" + logging.info(f"Starte Contact Research (LinkedIn). Limit: {limit if limit is not None else 'Unbegrenzt'}"); + # DataProcessor benötigt sheet_handler.sheet.spreadsheet Zugriff + if not self.sheet_handler or not hasattr(self.sheet_handler, 'sheet') or not hasattr(self.sheet_handler.sheet, 'spreadsheet'): + logging.critical("FEHLER: Sheet Handler oder Spreadsheet nicht verfügbar für Contact Research."); + return; + + main_sheet = self.sheet_handler.sheet; + if not self.sheet_handler.load_data(): return logging.error("FEHLER beim Laden der Daten."); + all_data = self.sheet_handler.get_all_data_with_headers(); header_rows = 5; + + # Finde Startzeile basierend auf Timestamp in Spalte AM (Index 38) + timestamp_col_index = COLUMN_MAP.get("Contact Search Timestamp"); + if timestamp_col_index is None: logging.critical("FEHLER: 'Contact Search Timestamp' Spaltenindex fehlt."); return; + + start_sheet_row = -1; + # Starte Suche nach leerem Timestamp nach Headern (Zeile 6, Index 5) + for i in range(header_rows, len(all_data)): # Iterate 0-based + row_index_in_list = i; row = all_data[row_index_in_list]; row_num_in_sheet = i + 1; # 1-based + if len(row) <= timestamp_col_index or not row[timestamp_col_index].strip(): + start_sheet_row = row_num_in_sheet; break; + if start_sheet_row == -1: logging.info("Keine Zeile ohne Contact Search Timestamp gefunden."); return; + logging.info(f"Contact Research startet ab Zeile {start_sheet_row}."); + + # Kontakte-Blatt öffnen oder erstellen + try: contacts_sheet = self.sheet_handler.sheet.spreadsheet.worksheet("Contacts"); logging.info("Blatt 'Contacts' gefunden."); + except gspread.exceptions.WorksheetNotFound: + logging.info("Blatt 'Contacts' nicht gefunden, erstelle neu..."); + contacts_sheet = self.sheet_handler.sheet.spreadsheet.add_worksheet(title="Contacts", rows="1000", cols="12"); + header = ["Firmenname", "CRM Kurzform", "Website", "Geschlecht", "Vorname", "Nachname", "Position", "Suchbegriffskategorie", "E-Mail-Adresse", "LinkedIn-Link", "Timestamp"]; + try: contacts_sheet.update(values=[header], range_name="A1:K1"); logging.info("Neues Blatt 'Contacts' erstellt und Header eingetragen."); + except Exception as e: logging.error(f"FEHLER beim Schreiben des Headers ins 'Contacts' Blatt: {e}"); # Kann hier weitergehen? + + # Positionen, nach denen gesucht wird + positions_to_search = ["Serviceleiter", "Leiter Kundendienst", "IT-Leiter", "Leiter IT", "Geschäftsführer", "Vorstand", "Disponent", "Einsatzleiter"]; # Annahme + + processed_count = 0; + # Gehe Zeilen im Hauptblatt durch (ab Startzeile) + for i in range(start_sheet_row, len(all_data) + 1): + row_index_in_list = i - 1; row = all_data[row_index_in_list]; row_num_in_sheet = i; # 1-based + if limit is not None and processed_count >= limit: logging.info(f"Limit ({limit}) erreicht."); break; + + # Benötigte Spaltenindizes für Lesezugriff (CRM Name, Kurzform, Website) + name_idx = COLUMN_MAP.get("CRM Name"); kurzform_idx = COLUMN_MAP.get("CRM Kurzform"); website_idx = COLUMN_MAP.get("CRM Website"); + if None in [name_idx, kurzform_idx, website_idx]: logging.error("FEHLER: Benötigte CRM-Spalten für Contact Research fehlen."); break; + + # Sicherstellen, dass Zeile lang genug ist + max_crm_idx = max(name_idx, kurzform_idx, website_idx); + if len(row) <= max_crm_idx: logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz)."); continue; + + + company_name = row[name_idx]; crm_kurzform = row[kurzform_idx]; website = row[website_idx]; + if not all([company_name, crm_kurzform, website]) or any(str(v).strip().lower() == "k.a." for v in [company_name, crm_kurzform, website]): + logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (fehlende CRM Daten: Name, Kurzform oder Website)."); + # Optional: Setze AM Timestamp zu "k.A. (Missing Data)"? Oder leer lassen? + # Lassen wir leer, website_lookup/find_wiki_serp könnten Daten ergänzen. + continue; + + logging.info(f"Zeile {row_num_in_sheet}: Suche Kontakte für '{crm_kurzform}'..."); + processed_count += 1; # Zähle als verarbeitet, wenn die Suche für diese Firma gestartet wird + all_found_contacts = []; contact_counts = {pos: 0 for pos in ["Serviceleiter", "IT-Leiter", "Geschäftsführer", "Disponent"]}; + + for position in positions_to_search: + # search_linkedin_contacts ist global mit Retry + found_contacts = search_linkedin_contacts(company_name, website, position, crm_kurzform, num_results=5); # Suche max. 5 Kontakte pro Position + + # Zählung für das Hauptblatt (vereinfachte Kategorien) + if "serviceleiter" in position.lower() or "kundendienst" in position.lower() or "einsatzleiter" in position.lower(): contact_counts["Serviceleiter"] += len(found_contacts); + elif "it-leiter" in position.lower() or "leiter it" in position.lower(): contact_counts["IT-Leiter"] += len(found_contacts); + elif "geschäftsführer" in position.lower() or "vorstand" in position.lower(): contact_counts["Geschäftsführer"] += len(found_contacts); + elif "disponent" in position.lower(): contact_counts["Disponent"] += len(found_contacts); + + # Füge gefundene Kontakte zur Liste hinzu (mit Suchkategorie) + for contact in found_contacts: contact["Suchbegriffskategorie"] = position; all_found_contacts.append(contact); + + time.sleep(1.5); # Kleine Pause zwischen SerpAPI-Aufrufen + + # Verarbeite gefundene Kontakte und schreibe ins Contacts-Sheet + rows_to_append = []; timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S"); + unique_contacts = {c.get('LinkedInURL'): c for c in all_found_contacts if c.get('LinkedInURL')}.values(); # Deduplizieren & nur mit URL + + for contact in unique_contacts: + firstname = contact.get("Vorname", ""); lastname = contact.get("Nachname", ""); + gender_value = get_gender(firstname); # Global Function with Retry + email = get_email_address(firstname, lastname, website); # Global Function + contact_row = [ contact.get("Firmenname", ""), contact.get("CRM Kurzform", ""), contact.get("Website", ""), gender_value, firstname, lastname, contact.get("Position", ""), contact.get("Suchbegriffskategorie", ""), email, contact.get("LinkedInURL", ""), timestamp ]; + rows_to_append.append(contact_row); + + if rows_to_append: + try: contacts_sheet.append_rows(rows_to_append, value_input_option='USER_ENTERED'); logging.info(f"Zeile {row_num_in_sheet}: {len(rows_to_append)} neue Kontakte zum 'Contacts'-Blatt hinzugefügt."); + except Exception as e: logging.error(f"Zeile {row_num_in_sheet}: Fehler beim Hinzufügen von Kontakten zum Sheet: {e}"); pass; # Fehler loggen, aber weitermachen + + # Aktualisiere Trefferzahlen und Timestamp im Hauptblatt (Batch Update) + # Benötigte Spaltenindizes für Schreibzugriff (AI-AM) + ai_idx = COLUMN_MAP.get("Linked Serviceleiter gefunden"); aj_idx = COLUMN_MAP.get("Linked It-Leiter gefunden"); ak_idx = COLUMN_MAP.get("Linked Management gefunden"); al_idx = COLUMN_MAP.get("Linked Disponent gefunden"); am_idx = COLUMN_MAP.get("Contact Search Timestamp"); + if None in [ai_idx, aj_idx, ak_idx, al_idx, am_idx]: logging.error("FEHLER: Benötigte Linked/Contact TS Spalten fehlen."); continue; # Kann nicht updaten + + main_sheet_updates = []; + main_sheet_updates.append({'range': f'AI{row_num_in_sheet}', 'values': [[str(contact_counts.get("Serviceleiter", ""))]]}); + main_sheet_updates.append({'range': f'AJ{row_num_in_sheet}', 'values': [[str(contact_counts.get("IT-Leiter", ""))]]}); + main_sheet_updates.append({'range': f'AK{row_num_in_sheet}', 'values': [[str(contact_counts.get("Geschäftsführer", ""))]]}); + main_sheet_updates.append({'range': f'AL{row_num_in_sheet}', 'values': [[str(contact_counts.get("Disponent", ""))]]}); + main_sheet_updates.append({'range': f'AM{row_num_in_sheet}', 'values': [[timestamp]]}); + + if main_sheet_updates: + success = self.sheet_handler.batch_update_cells(main_sheet_updates); # Nutze self.sheet_handler + if success: logging.debug(f"Zeile {row_num_in_sheet}: Kontaktzahlen/AM im Hauptblatt aktualisiert."); + else: logging.error(f"Zeile {row_num_in_sheet}: FEHLER beim Batch-Update Kontaktzahlen/AM."); + + # Pause nach Verarbeitung einer Firma + time.sleep(Config.RETRY_DELAY); + + + logging.info(f"Contact Research abgeschlossen. {processed_count} Firmen geprüft.") + # --- Methoden zur Datenvorbereitung und Modelltraining für ML --- + # Diese Methoden gehören in die Klasse DataProcessor + + # prepare_data_for_modeling Methode def prepare_data_for_modeling(self): """ Lädt Daten aus dem Google Sheet über den sheet_handler, @@ -4684,19 +4305,11 @@ class DataProcessor: - Erstellt die Zielvariable (Techniker-Bucket). - Bereitet Features auf (One-Hot Encoding für Branche). - Behält NaNs in numerischen Features für spätere Imputation. - - Args: - # Kein sheet_handler Parameter mehr nötig, da es eine Methode ist und self.sheet_handler nutzt - - Returns: - pandas.DataFrame: Vorbereiteter DataFrame für Training/Test-Split, - oder None bei Fehlern. """ logging.info("Starte Datenvorbereitung für Modellierung...") # Nutze den self.sheet_handler der Klasse if not self.sheet_handler or not self.sheet_handler.sheet_values: logging.error("Fehler: Sheet Handler nicht initialisiert oder keine Daten geladen für prepare_data_for_modeling.") - # Versuche die Daten einmalig innerhalb dieser Methode zu laden, falls sie fehlen if not self.sheet_handler.load_data(): logging.critical("Konnte Daten auch nach erneutem Versuch nicht laden. Abbruch der Datenvorbereitung.") return None @@ -4709,7 +4322,6 @@ class DataProcessor: return None try: - # Die erste Zeile sollte die Spaltennamen enthalten headers = all_data[0] # Stelle sicher, dass die Header-Zeile auch die erwartete Mindestlänge hat, # um die Spaltenindizes aus COLUMN_MAP zu finden @@ -4776,57 +4388,47 @@ class DataProcessor: logging.info(f"Benötigte Spalten für Modellierung ausgewählt und umbenannt: {list(df_subset.columns)}") # --- Features konsolidieren (Umsatz, Mitarbeiter) --- - # Annahme: extract_numeric_value existiert und kann pd.Series verarbeiten (oder wird per apply genutzt) - # (Ihre Implementierung nutzt apply, was korrekt ist) + # Annahme: extract_numeric_value existiert (global) + # Wir brauchen hier eine Funktion, die NaN zurückgibt für ungültige Werte, nicht "k.A." + # Passen Sie extract_numeric_value an oder erstellen Sie eine neue. + # Die get_valid_numeric Funktion aus Ihrer alten prepare_data_for_modeling Version macht genau das. + def get_valid_numeric(value_str): """Hilfsfunktion zur sicheren Konvertierung mit Fehlerbehandlung.""" if value_str is None or pd.isna(value_str) or str(value_str).strip() == '': return np.nan - # Annahme: extract_numeric_value existiert und gibt string 'k.A.' oder Zahl-String zurück - # Wir brauchen aber einen numerischen Wert oder np.nan + raw_value_str = str(value_str) try: - # Versuche direkt die logik aus extract_numeric_value hier zu verwenden - raw_value_str = str(value_str) + # Kopieren Sie hier die Logik von extract_numeric_value, die NaN zurückgibt + # anstatt "k.A." bei Fehlern oder 0/negativen Werten. processed_value = clean_text(raw_value_str) # Annahme: clean_text existiert if processed_value == "k.A.": return np.nan - # Anpassung hier: Entferne auch Apostroph (tausendertrenner) processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|über|unter|mehr als|weniger als|bis zu)\s+', '', processed_value) processed_value = re.sub(r'[€$£¥]', '', processed_value).strip() - # Split bei Bindestrich (Umsatzspanne), nur ersten Teil nehmen processed_value = re.split(r'\s*(-|–|bis)\s*', processed_value, 1)[0].strip() - processed_value = processed_value.replace('.', '').replace("'", "") # Entferne Punkte UND Apostrophe als Tausendertrenner - processed_value = processed_value.replace(',', '.') # Ersetze Komma durch Punkt für Dezimaltrennung + processed_value_no_thousands = processed_value.replace('.', '').replace("'", "") + processed_value_final = processed_value_no_thousands.replace(',', '.') - match = re.search(r'([\d.]+)', processed_value) - if not match: return np.nan # Keine numerischen Zeichen gefunden + match = re.search(r'([\d.]+)', processed_value_final) + if not match: return np.nan num_str = match.group(1) - # Zusätzliche Prüfung: String darf nicht nur ein Punkt sein if not num_str or num_str == '.': return np.nan - num = float(num_str) # Konvertiere zum float + num = float(num_str) - # --- Einheiten-Multiplikatoren (Mrd, Mio, Tsd) --- + original_lower = raw_value_str.lower() multiplier = 1.0 - original_lower = raw_value_str.lower() # Nutze den Originalstring für Einheiten - if "mrd" in original_lower or "milliarden" in original_lower or "billion" in original_lower: multiplier = 1000000000.0 - elif "mio" in original_lower or "millionen" in original_lower or "mill." in original_lower: multiplier = 1000000.0 - elif "tsd" in original_lower or "tausend" in original_lower: multiplier = 1000.0 + if re.search(r'\bmrd\s*\b|\bmilliarden\s*\b|\bbillion\s*\b', original_lower): multiplier = 1000000000.0 + elif re.search(r'\bmio\s*\b|\bmillionen\s*\b|\bmill\.\s*\b', original_lower): multiplier = 1000000.0 + elif re.search(r'\btsd\s*\b|\btausend\s*\b', original_lower): multiplier = 1000.0 num = num * multiplier - # Optional: Runden auf ganze Zahlen für Mitarbeiter, Umsatz in Mio. - # Die extract_numeric_value Funktion hat das gemacht. Hier brauchen wir rohe Zahlen für Imputation. - # Also einfach den num zurückgeben - return num if num > 0 else np.nan # Nur positive Werte sind gültig + return num if num > 0 else np.nan # Nur positive Werte zählen - except (ValueError, TypeError) as e: - # Logge auf DEBUG, da dies oft vorkommt - # logging.debug(f"Konntze Wert '{str(value_str)[:50]}...' nicht als gültige Zahl parsen: {e}") - return np.nan - except Exception as e: - logging.warning(f"Unerwarteter Fehler in get_valid_numeric für Wert '{str(value_str)[:50]}...': {e}") - return np.nan + except (ValueError, TypeError) as e: logging.debug(f"Konntze Wert '{str(value_str)[:50]}...' nicht als gültige Zahl parsen: {e}"); return np.nan + except Exception as e: logging.warning(f"Unerwarteter Fehler in get_valid_numeric für Wert '{str(value_str)[:50]}...': {e}"); return np.nan cols_to_process = { @@ -4846,567 +4448,643 @@ class DataProcessor: wiki_series, crm_series ) - # Info-Log über Ergebnis logging.info(f" -> {df_subset[final_col].notna().sum()} gültige '{final_col}' Werte erstellt (von {len(df_subset)} Zeilen).") # --- Zielvariable vorbereiten (Technikerzahl) --- techniker_col = "techniker" # Interne Spaltenname nach Umbenennung logging.info(f"Verarbeite Zielvariable '{techniker_col}'...") - - # Konvertiere zu Numerisch (Fehler -> NaN) - # Sicherstellen, dass die Spalte existiert - if techniker_col not in df_subset.columns: - logging.critical(f"FEHLER: Zielvariable '{techniker_col}' (CRM Anzahl Techniker) nicht im DataFrame gefunden nach Umbenennung.") - return None - + if techniker_col not in df_subset.columns: logging.critical(f"FEHLER: Zielvariable '{techniker_col}' fehlt."); return None df_subset['Anzahl_Servicetechniker_Numeric'] = pd.to_numeric(df_subset[techniker_col], errors='coerce') - # Filtere Zeilen: Behalte nur die mit gültiger, positiver Technikerzahl + # Filtere Zeilen: Behalte nur die mit gültiger, positiver Technikerzahl (> 0) initial_rows = len(df_subset) df_filtered = df_subset[ df_subset['Anzahl_Servicetechniker_Numeric'].notna() & (df_subset['Anzahl_Servicetechniker_Numeric'] > 0) - ].copy() # WICHTIG: .copy() um SettingWithCopyWarning zu vermeiden - filtered_rows = len(df_filtered) - removed_rows = initial_rows - filtered_rows - # Info, wenn Zeilen entfernt wurden - if removed_rows > 0: - logging.info(f"{removed_rows} Zeilen entfernt aufgrund fehlender/ungültiger Technikerzahl (Wert <= 0 oder nicht numerisch).") + ].copy() + filtered_rows = len(df_filtered); removed_rows = initial_rows - filtered_rows; + if removed_rows > 0: logging.info(f"{removed_rows} Zeilen entfernt (fehlende/ungültige Technikerzahl).") logging.info(f"Verbleibende Zeilen für Modellierung (mit gültiger Technikerzahl > 0): {filtered_rows}") - - if filtered_rows == 0: - logging.error("FEHLER: Keine Zeilen mit gültiger Technikerzahl (>0) übrig für Modellierung!") - return None + if filtered_rows == 0: logging.error("FEHLER: Keine Zeilen mit gültiger Technikerzahl (>0) übrig!"); return None # --- Techniker-Buckets erstellen --- - # Die Bins und Labels müssen die gefilterten Daten widerspiegeln (die jetzt alle > 0 sind) - # Wenn die Buckets 0 beinhalten, muss die Bin-Definition angepasst werden. - # Aktuelle Definition: [-1, 0, 19, 49, 99, 249, 499, float('inf')] - # labels = ['Bucket_1_(0)', 'Bucket_2_(<20)', 'Bucket_3_(<50)', 'Bucket_4_(<100)', 'Bucket_5_(<250)', 'Bucket_6_(<500)', 'Bucket_7_(>499)'] - # Da wir auf > 0 filtern, wird Bucket_1_(0) nie erreicht. - # Bins und Labels anpassen, wenn 0 ignoriert wird? - # Nein, die Labels repräsentieren Bereiche, auch wenn ein Bereich im Trainingsset nicht vorkommt. - # Wichtig ist, dass die Bins Sinn ergeben. -1 bis 0 fängt 0, 0 bis 19 fängt 1-19 etc. - # Wenn wir auf >0 filtern, wird alles < 19 in den 2. Bucket fallen, alles >=1 und <20. - # Die Bin-Definition [-1, 0, 19, 49, ...] bedeutet eigentlich: - # (-1, 0] -> <= 0 - # (0, 19] -> >0 und <= 19 - # (19, 49] -> >19 und <= 49 - # ... - # Passt zur Filterung > 0. + # Bins und Labels wie definiert ([-1, 0, 19, 49, 99, 249, 499, float('inf')]) bins = [-1, 0, 19, 49, 99, 249, 499, float('inf')] labels = ['Bucket_1_(0)', 'Bucket_2_(<20)', 'Bucket_3_(<50)', 'Bucket_4_(<100)', 'Bucket_5_(<250)', 'Bucket_6_(<500)', 'Bucket_7_(>499)'] - # Ensure labels match expected categories if buckets are used differently - # For DecisionTree classification, the target should be discrete labels. - # Let's assume the labels are the desired outcome categories. - df_filtered['Techniker_Bucket'] = pd.cut( - df_filtered['Anzahl_Servicetechniker_Numeric'], - bins=bins, - labels=labels, - right=True, # Das Intervall ist (linker, rechter]. also (0, 19] - include_lowest=True # Wenn bins mit -1 starten, inkludiere den niedrigsten Wert (nicht relevant bei >0 Filterung) - ) + df_filtered['Techniker_Bucket'] = pd.cut( df_filtered['Anzahl_Servicetechniker_Numeric'], bins=bins, labels=labels, right=True, include_lowest=True ) logging.info("Techniker-Buckets erstellt.") - # Verteilung als Info-Log logging.info(f"Verteilung der Techniker-Buckets im Trainingsdatensatz:\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True).round(3)}") - # Prüfe, ob NaNs in Buckets erstellt wurden (sollte bei >0 Filterung nicht passieren) - if df_filtered['Techniker_Bucket'].isna().any(): - logging.warning("WARNUNG: NaNs in Techniker-Buckets erstellt. Überprüfen Sie die bins/labels und die Filterung.") - # Optional: Zeilen mit NaN im Bucket entfernen - df_filtered.dropna(subset=['Techniker_Bucket'], inplace=True) - logging.info(f"Nach Entfernung von NaN Buckets: {len(df_filtered)} Zeilen verbleiben.") - if len(df_filtered) == 0: - logging.error("FEHLER: Keine Zeilen übrig nach Entfernung von NaN Buckets.") - return None + if df_filtered['Techniker_Bucket'].isna().any(): logging.warning("WARNUNG: NaNs in Techniker-Buckets erstellt. Entferne diese Zeilen."); df_filtered.dropna(subset=['Techniker_Bucket'], inplace=True); logging.info(f"Nach Entfernung von NaN Buckets: {len(df_filtered)} Zeilen verbleiben."); if len(df_filtered) == 0: logging.error("FEHLER: Keine Zeilen übrig nach Entfernung von NaN Buckets."); return None; # --- Kategoriale Features vorbereiten (Branche) --- branche_col = "branche" # Interne Spaltenname logging.info(f"Verarbeite kategoriales Feature '{branche_col}' für One-Hot Encoding...") - - # Stelle sicher, dass die Spalte String ist und fülle evtl. NaNs mit 'Unbekannt' - if branche_col not in df_filtered.columns: - logging.warning(f"Spalte '{branche_col}' nicht im DataFrame, One-Hot Encoding wird übersprungen.") - # Erstelle eine leere Spalte oder überspringe die One-Hot Encoding - # Lassen Sie es hier abstürzen, da Branche ein wichtiges Feature ist. - logging.critical(f"FEHLER: Spalte '{branche_col}' nicht im DataFrame für One-Hot Encoding gefunden.") - return None - - df_filtered[branche_col] = df_filtered[branche_col].astype(str).fillna('Unbekannt').str.strip() # .str.strip() hinzugefügt - - # One-Hot Encoding - # dummy_na=False, da wir NaNs gefüllt haben. - # prefix='Branche' ist gut. + if branche_col not in df_filtered.columns: logging.critical(f"FEHLER: Spalte '{branche_col}' fehlt für One-Hot Encoding."); return None; + df_filtered[branche_col] = df_filtered[branche_col].astype(str).fillna('Unbekannt').str.strip() df_encoded = pd.get_dummies(df_filtered, columns=[branche_col], prefix='Branche', dummy_na=False) logging.info(f"One-Hot Encoding für '{branche_col}' durchgeführt. Neue Spaltenanzahl: {len(df_encoded.columns)}") # --- Finale Auswahl der Features für das Modell --- - # Merke dir die Feature-Spalten, die tatsächlich für das Training verwendet werden sollen - feature_columns = [col for col in df_encoded.columns if col.startswith('Branche_')] # Alle One-Hot Branch-Spalten - feature_columns.extend(['Finaler_Umsatz', 'Finaler_Mitarbeiter']) # Hinzufügen der numerischen Features - - # Prüfe, ob die Final-Spalten existieren (sollten sie, wurden oben erstellt) - if not all(col in df_encoded.columns for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter']): - logging.critical("FEHLER: Konsolidierte numerische Spalten 'Finaler_Umsatz' oder 'Finaler_Mitarbeiter' fehlen im DataFrame.") - return None - - - target_column = 'Techniker_Bucket' # Zielvariable - - # Erstelle den finalen DataFrame mit den Features, dem Target und Identifikationsspalten - # Behalte Originaldaten (Name, tatsächliche Technikerzahl) für spätere Analyse / Zuordnung - original_data_cols = ['name', 'Anzahl_Servicetechniker_Numeric'] # 'name' nach Umbenennung - - # Stelle sicher, dass die original_data_cols auch existieren - if not all(col in df_encoded.columns for col in original_data_cols): - logging.critical(f"FEHLER: Originaldaten-Spalten {original_data_cols} fehlen im DataFrame.") - return None - + feature_columns = [col for col in df_encoded.columns if col.startswith('Branche_')] + feature_columns.extend(['Finaler_Umsatz', 'Finaler_Mitarbeiter']) + if not all(col in df_encoded.columns for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter']): logging.critical("FEHLER: Konsolidierte numerische Spalten fehlen."); return None; + target_column = 'Techniker_Bucket'; original_data_cols = ['name', 'Anzahl_Servicetechniker_Numeric']; + if not all(col in df_encoded.columns for col in original_data_cols): logging.critical(f"FEHLER: Originaldaten-Spalten {original_data_cols} fehlen."); return None; df_model_ready = df_encoded[original_data_cols + feature_columns + [target_column]].copy() - - # Optional: Konvertiere numerische Spalten explizit zu Float64 for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter', 'Anzahl_Servicetechniker_Numeric']: - if col in df_model_ready.columns: # Sicherheitscheck - df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce') # errors='coerce' wandelt Fehler in NaN - - # Reset Index für saubere Verarbeitung im nächsten Schritt (z.B. Train/Test-Split) + 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) - logging.info("Datenvorbereitung für Modellierung abgeschlossen.") - logging.info(f"Finaler DataFrame für Modellierung hat {len(df_model_ready)} Zeilen und {len(df_model_ready.columns)} Spalten.") - # Logge die Anzahl der Feature-Spalten, nicht die Liste - logging.info(f"Anzahl Feature-Spalten: {len(feature_columns)}") - logging.info(f"Ziel-Spalte: {target_column}") - - # WICHTIG: Info über fehlende Werte in den finalen numerischen Features vor Imputation - nan_counts = df_model_ready[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].isna().sum() - logging.info(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}") - # Logge auch, wie viele Zeilen *mindestens* einen NaN haben - rows_with_nan = df_model_ready[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].isna().any(axis=1).sum() - logging.info(f"Anzahl Zeilen mit mindestens einem fehlenden numerischen Feature: {rows_with_nan}") - + logging.info("Datenvorbereitung für Modellierung abgeschlossen."); logging.info(f"Finaler DataFrame hat {len(df_model_ready)} Zeilen und {len(df_model_ready.columns)} Spalten."); + logging.info(f"Anzahl Feature-Spalten: {len(feature_columns)}"); logging.info(f"Ziel-Spalte: {target_column}"); + nan_counts = df_model_ready[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].isna().sum(); logging.info(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}"); + rows_with_nan = df_model_ready[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].isna().any(axis=1).sum(); logging.info(f"Anzahl Zeilen mit mindestens einem fehlenden numerischen Feature: {rows_with_nan}"); return df_model_ready - # --- Methode für sequenzielle Verarbeitung (full_run) --- - # Diese Methode gehört in die Klasse - def process_rows_sequentially(self, start_data_index, num_to_process, - process_wiki=True, process_chatgpt=True, process_website=True): + # train_technician_model Methode + def train_technician_model(self, model_out, imputer_out, patterns_out): + """ + Trainiert Decision Tree Modell zur Schätzung der Servicetechnikerzahl. + """ + logging.info("Starte Modus: train_technician_model"); + prepared_df = self.prepare_data_for_modeling(); # Nutze self + + if prepared_df is not None and not prepared_df.empty: + logging.info("Aufteilen der Daten für das Modelltraining..."); + try: + X = prepared_df.drop(columns=['Techniker_Bucket', 'name', 'Anzahl_Servicetechniker_Numeric']); # Spaltennamen nach Umbenennung + y = prepared_df['Techniker_Bucket']; + X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y); + logging.info(f"Train/Test Split: {len(X_train)} Train, {len(X_test)} Test samples."); + except KeyError as e: logging.error(f"FEHLER beim Train/Test Split: Spalte nicht gefunden - {e}."); return; + except Exception as e: logging.error(f"FEHLER beim Train/Test Split: {e}"); return; + + logging.info("Imputation fehlender numerischer Werte (Median)..."); + numeric_features = ['Finaler_Umsatz', 'Finaler_Mitarbeiter']; + try: + imputer = SimpleImputer(strategy='median'); + features_to_impute = [nf for nf in numeric_features if nf in X_train.columns]; + if features_to_impute: + X_train[features_to_impute] = imputer.fit_transform(X_train[features_to_impute]); + X_test[features_to_impute] = imputer.transform(X_test[features_to_impute]); # Wichtig: transform, nicht fit_transform! + imputer_filename = imputer_out; + with open(imputer_filename, 'wb') as f_imp: pickle.dump(imputer, f_imp); + logging.info(f"Imputer erfolgreich trainiert und gespeichert: '{imputer_filename}'."); + else: logging.warning("Keine numerischen Features gefunden, die imputiert werden müssen."); + except Exception as e: logging.error(f"FEHLER bei der Imputation: {e}"); return; + + logging.info("Starte Decision Tree Training mit GridSearchCV..."); + param_grid = { 'criterion': ['gini', 'entropy'], 'max_depth': [6, 8, 10, 12, 15], 'min_samples_split': [20, 40, 60], 'min_samples_leaf': [10, 20, 30], 'ccp_alpha': [0.0, 0.001, 0.005] }; + dtree = DecisionTreeClassifier(random_state=42, class_weight='balanced'); + grid_search = GridSearchCV(estimator=dtree, param_grid=param_grid, cv=5, scoring='f1_weighted', n_jobs=-1, verbose=1); + if X_train.isna().sum().sum() > 0: logging.error(f"FEHLER: NaNs nach Imputation in X_train gefunden. {X_train.columns[X_train.isna().any()].tolist()}. Training abgebrochen."); return; + try: + grid_search.fit(X_train, y_train); + best_estimator = grid_search.best_estimator_; logging.info(f"GridSearchCV abgeschlossen."); logging.info(f"Beste Parameter: {grid_search.best_params_}"); logging.info(f"Bester F1-Score (gewichtet, CV): {grid_search.best_score_:.4f}"); + model_filename = model_out; with open(model_filename, 'wb') as f_mod: pickle.dump(best_estimator, f_mod); logging.info(f"Bestes Modell gespeichert: '{model_filename}'."); + except Exception as e_train: logging.exception(f"FEHLER während des Trainings: {e_train}"); return; + + logging.info("Evaluiere Modell auf dem Test-Set..."); + try: + X_test_processed = X_test.reindex(columns=X_train.columns, fill_value=0); # Sicherstellen, dass X_test gleiche Spalten hat + y_pred = best_estimator.predict(X_test_processed); + test_accuracy = accuracy_score(y_test, y_pred); class_labels = [str(cls) for cls in best_estimator.classes_]; + report = classification_report(y_test, y_pred, zero_division=0, labels=best_estimator.classes_, target_names=class_labels); + conf_matrix = confusion_matrix(y_test, y_pred, labels=best_estimator.classes_); conf_matrix_df = pd.DataFrame(conf_matrix, index=class_labels, columns=class_labels); + logging.info(f"\n--- Evaluation Test-Set ---\nGenauigkeit: {test_accuracy:.4f}\nClassification Report:\n{report}\nConfusion Matrix:\n{conf_matrix_df}"); + print(f"\nModell Genauigkeit (Test): {test_accuracy:.4f}"); + except Exception as e_eval: logging.exception(f"FEHLER bei der Evaluation des Test-Sets: {e_eval}"); + + logging.info("Extrahiere Baumregeln..."); + try: feature_names = list(X_train.columns); + rules_text = export_text(best_estimator, feature_names=feature_names, show_weights=True, spacing=3); + patterns_filename = patterns_out; with open(patterns_filename, 'w', encoding='utf-8') as f_rules: f_rules.write(rules_text); logging.info(f"Regeln als Text gespeichert: '{patterns_filename}'."); + except Exception as e_export: logging.error(f"Fehler beim Exportieren der Regeln: {e_export}"); + + else: logging.warning("Datenvorbereitung für Modelltraining fehlgeschlagen oder ergab keine Daten."); + + + # train_technician_model_rag_light Methode (NEU - Platzhalter) + # Diese Methode würde die Schätzung mit dem trainierten Modell und Regeln durchführen. + # Sie gehört hierher, wird aber erst später implementiert. + # def train_technician_model_rag_light(self, ...): + # pass # Implementierung später + # --- Batch Dispatcher Methode (Werden von run_user_interface aufgerufen) --- + # Diese Methode wählt den passenden Batch-Prozess aus und ruft die entsprechende Batch-Methode auf. + # Sie findet die Startzeile für die Batch-Methoden. + def run_batch_dispatcher(self, mode, limit=None): """ - Verarbeitet eine feste Anzahl von Zeilen beginnend bei einem bestimmten Datenindex - sequenziell, eine nach der anderen, unter Verwendung von _process_single_row. - Prüft KEINE Timestamps oder ReEval-Flags intern, _process_single_row tut dies. + Wählt den passenden Batch-Prozess basierend auf dem Modus und ruft die entsprechende Methode auf. + Ermittelt die Startzeile dynamisch. Args: - start_data_index (int): Der 0-basierte Index in der Datenliste (ohne Header). - num_to_process (int): Die maximale Anzahl der zu verarbeitenden Zeilen. - process_wiki (bool, optional): Soll Wiki-Verarbeitung durchgeführt werden?. Defaults to True. - process_chatgpt (bool, optional): Sollen ChatGPT-Evaluationen durchgeführt werden?. Defaults to True. - process_website (bool, optional): Soll Website-Verarbeitung durchgeführt werden?. Defaults to True. + mode (str): Der Name des Batch-Modus (z.B. 'wiki_batch', 'website_scrape_batch'). + limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None. """ - header_rows = 5 # Annahme + logging.info(f"Starte DataProcessor Batch Dispatcher im Modus '{mode}' mit limit={limit if limit is not None else 'Unbegrenzt'}.") + header_rows = 5 # Annahme, könnte auch dynamisch vom handler kommen - logging.info(f"Starte sequenzielle Verarbeitung von {num_to_process} Zeilen ab Daten-Index {start_data_index}...") - - # Lade Daten einmalig vor der Verarbeitung - if not self.sheet_handler.load_data(): - logging.error("Fehler beim Laden der Daten für sequenzielle Verarbeitung.") - return - - all_data = self.sheet_handler.get_all_data_with_headers() - total_data_rows = len(all_data) - header_rows - - if start_data_index >= total_data_rows: - logging.warning(f"Start-Datenindex {start_data_index} liegt außerhalb der verfügbaren Daten ({total_data_rows} Datenzeilen). Keine Verarbeitung.") - return - - # Berechne den tatsächlichen End-Datenindex (exklusiv) - end_data_index = min(start_data_index + num_to_process, total_data_rows) - - logging.info(f"Sequenzielle Verarbeitung: Daten-Index Bereich [{start_data_index}, {end_data_index})") - # Übersetze in Sheet-Zeilennummern für Logging - start_sheet_row = start_data_index + header_rows + 1 - end_sheet_row_inclusive = end_data_index + header_rows # Das Ende ist exklusiv, also ist die letzte Zeile am Index end_data_index-1 - - logging.info(f"Entsprechende Sheet-Zeilen (1-basiert): {start_sheet_row} bis {end_sheet_row_inclusive}") + # Startspalte für jeden Batch-Modus (basierend auf Timestamp/Status für Neuverarbeitung) + start_col_key = None + if mode == "wiki_batch": start_col_key = "Wiki Verif. Timestamp" # AX + elif mode == "website_scrape_batch": start_col_key = "Website Scrape Timestamp" # AT + elif mode == "summarize_batch": # Summarize Batch braucht leeres AS und gefülltes AR + # Die Startzeilensuche für Summarize Batch ist komplexer und in process_summarization_batch implementiert. + # Dieser Dispatcher kann sie hier nicht generisch finden. process_summarization_batch muss das selbst tun. + pass # Keine generische Startspalte hier + elif mode == "branch_batch": start_col_key = "Timestamp letzte Prüfung" # AO + elif mode == "combined": + # Combined mode ruft die einzelnen Batch-Methoden nacheinander auf + logging.info("Combined mode: Calling batches sequentially.") + self.run_batch_dispatcher(mode="wiki_batch", limit=limit) # Prüft AX + self.run_batch_dispatcher(mode="website_scrape_batch", limit=limit) # Prüft AT + self.run_batch_dispatcher(mode="summarize_batch", limit=limit) # Sucht Startzeile intern + self.run_batch_dispatcher(mode="branch_batch", limit=limit) # Prüft AO + logging.info("Combined mode completed.") + return # Wichtig: Nach Combined beenden - processed_count = 0 - # Iteriere über die Datenzeilen im angegebenen Bereich - for i in range(start_data_index, end_data_index): - row_num_in_sheet = i + header_rows + 1 # 1-basierte Zeilennummer - row_data = all_data[i + header_rows] # Tatsächliche Zeilendaten aus der Gesamtliste + # Logik für einzelne Batch-Modi (wiki, website, summarize, branch) + # Für Summarize Batch (mode == 'summarize_batch'), ruft die Methode intern die Startzeilensuche auf. + # Für die anderen (wiki, website, branch), nutzen wir get_start_row_index hier. + if mode in ["wiki_batch", "website_scrape_batch", "branch_batch"]: + if start_col_key is None: + logging.critical(f"FEHLER: Keine Startspalte für Batch-Modus '{mode}' definiert.") + return + logging.info(f"Dispatcher: Ermittle Startzeile basierend auf Spalte '{start_col_key}'...") + start_data_index = self.sheet_handler.get_start_row_index(check_column_key=start_col_key, min_sheet_row=header_rows + 1) + + if start_data_index == -1: return logging.error(f"FEHLER: Startspalte '{start_col_key}' prüfen!") + # get_start_row_index gibt den Index in den Daten (ohne Header) zurück. + # Wenn alle Zeilen gefüllt sind, gibt es die Anzahl der Datenzeilen zurück. + # Wenn dieser Index >= Anzahl der Datenzeilen ist, gibt es nichts zu tun. + if start_data_index >= len(self.sheet_handler.get_data()): + logging.info(f"Alle Zeilen in Spalte '{start_col_key}' sind gefüllt. Nichts zu tun für Modus '{mode}'.") + return + + # Diese Startzeile (0-basiert in Daten) wird nicht direkt an die Batch-Methoden übergeben, + # da diese die Startzeile (1-basiert im Sheet) benötigen, um über die GESAMTE Liste zu iterieren. + # Wir berechnen hier nur zur Info die Start-Sheet-Zeile. + start_sheet_row_info = start_data_index + header_rows + 1 + logging.info(f"Erste Zeile mit leerem Timestamp in Spalte '{start_col_key}' ist Sheet-Zeile {start_sheet_row_info}.") + # Die Batch-Methoden (process_verification_batch etc.) müssen ihre eigene Startzeilensuche durchführen, + # oder wir übergeben die Daten ab dieser Zeile. + # Aktuell machen die Batch-Methoden ihre eigene get_start_row_index Suche. + # Das ist redundant, aber funktioniert. Behalten wir das vorerst bei. + # Im Refactoring kann man die Batch-Methoden so ändern, dass sie ab einem übergebenen Index iterieren. + + + # Aufruf der spezifischen Batch-Methoden + try: + # Diese Methoden müssen in der DataProcessor Klasse implementiert sein + if mode == "wiki_batch": self.process_verification_batch(limit=limit) + elif mode == "website_scrape_batch": self.process_website_batch(limit=limit) + elif mode == "summarize_batch": self.process_summarization_batch(limit=limit) # Sucht Startzeile intern + elif mode == "branch_batch": self.process_branch_batch(limit=limit) + # Combined wird oben separat behandelt + + except Exception as e: + logging.exception(f"FEHLER in DataProcessor Batch Dispatcher im Modus '{mode}': {e}") + + logging.info(f"DataProcessor Batch Dispatcher für Modus '{mode}' abgeschlossen.") + + +# --- Neue Funktion: Benutzerinterface & Modus Dispatcher --- +# Diese Funktion ist die neue Steuerzentrale, die das Menü anzeigt und die Aufrufe delegiert. +# Sie ersetzt den grossen if/elif Block in der alten main Funktion. +# Annahme: DataProcessor Klasse ist definiert und instanziert +# Annahme: Globale Kriterien-Funktionen (criteria_xxx) sind definiert +# Annahme: Globale Dienstprogramm-Funktionen (alignment_demo) sind definiert +# Annahme: logging ist konfiguriert + +def run_user_interface(data_processor, cli_mode=None, cli_limit=None, cli_start_row=None, cli_steps=None, cli_min_umsatz=None, cli_min_employees=None): + """ + Implementiert das interaktive Menü zur Modusauswahl oder verarbeitet CLI-Argumente. + Ruft die entsprechenden Methoden der DataProcessor-Instanz auf. + """ + mode_info = None + row_limit = cli_limit + start_row = cli_start_row # Optional für sequenzielle Verarbeitung + # CLI Steps für Re-Eval werden hier verarbeitet + steps_list = [step.strip().lower() for step in (cli_steps.split(',') if cli_steps else [])] + + # Definition der Hauptmodi (Numerisch -> Name -> Beschreibung) + # Definieren Sie dieses Dictionary hier oder global + MAIN_MODES = { + 1: {"name": "sequential", "description": "Sequenzielle Zeilenverarbeitung", "requires_limit": True, "requires_start_row": True, "is_single_row_processing_mode": True}, + 2: {"name": "reeval", "description": "Re-evaluate markierte Zeilen (Spalte A='x')", "requires_limit": True, "is_single_row_processing_mode": True}, + 3: {"name": "criteria", "description": "Prozessiere Zeilen, die Kriterien erfüllen", "requires_limit": True, "is_single_row_processing_mode": True}, + 4: {"name": "batch", "description": "Batch-Verarbeitung (Schritt-optimiert)", "requires_limit": True, "is_single_row_processing_mode": False}, + 5: {"name": "dienstprogramme", "description": "Einzelne Dienstprogramme / Suchen", "requires_limit": False, "is_single_row_processing_mode": False}, + } + + # --- Modus Auswahl (CLI hat Priorität über Interaktiv) --- + if cli_mode: + # Finde Modus Info basierend auf CLI Name + found_mode = [info for num, info in MAIN_MODES.items() if info["name"] == cli_mode] + if found_mode: + mode_info = found_mode[0] + logging.info(f"Hauptmodus (aus Kommandozeile): {mode_info['name']}") + else: + logging.error(f"Ungültiger Hauptmodus '{cli_mode}' via Kommandozeile. Gültige Modi: {', '.join([info['name'] for info in MAIN_MODES.values()])}") + return # Skript beenden bei ungültigem CLI Modus + else: + # Interaktiver Modus - Stufe 1 Menü + print("\n--- Hauptmodus wählen ---") + for num, info in MAIN_MODES.items(): + print(f" {num}: {info['description']}") + + while mode_info is None: try: - # Rufe die Methode zur Verarbeitung einer einzelnen Zeile auf - # _process_single_row wird intern die Timestamps prüfen (außer force_reeval) - self._process_single_row(row_num_in_sheet, row_data, - process_wiki=process_wiki, - process_chatgpt=process_chatgpt, - process_website=process_website, - force_reeval=False) # Im full_run Modus normalerweise KEIN Re-Eval erzwingen - - processed_count += 1 - - except Exception as e_proc: - # Logge den spezifischen Fehler für diese Zeile, fahre aber fort - logging.exception(f"FEHLER bei sequenzieller Verarbeitung von Zeile {row_num_in_sheet}: {e_proc}") - # Optional: Hier könnte man ein Flag in der Zeile setzen, um den Fehler zu markieren - - logging.info(f"Sequenzielle Verarbeitung abgeschlossen. {processed_count} Zeilen verarbeitet im Bereich [{start_data_index}, {end_data_index}).") + choice = input("Geben Sie die Zahl des Hauptmodus ein: ").strip() + mode_num = int(choice) + if mode_num in MAIN_MODES: + mode_info = MAIN_MODES[mode_num] + logging.info(f"Hauptmodus (interaktiv gewählt): {mode_info['name']}") + else: + print("Ungültige Zahl. Bitte versuchen Sie es erneut.") + except ValueError: print("Ungültige Eingabe. Bitte geben Sie eine Zahl ein.") + except Exception as e: logging.error(f"Fehler bei Hauptmodus-Eingabe: {e}"); return # Skript beenden -# ==================== MAIN FUNCTION ==================== -# ==================== MAIN FUNCTION ==================== -# Diese Funktion ist der Haupteinstiegspunkt des Skripts. + # --- Abfrage weiterer Parameter basierend auf Hauptmodus --- + flags_for_steps = None # Wird für Zeilenverarbeitungsmodi gesetzt + selected_batch_mode = None # Wird für Batch-Modus gesetzt + selected_dienstprogramm = None # Wird für Dienstprogramme gesetzt + criteria_func = None # Wird für Kriterien-Modus gesetzt + force_step_reeval = False # Bestimmt force_reeval in _process_single_row für Criteria Mode + + + # --- Ebene 2/3: Spezifische Aktion / Schritte wählen / Bereich & Kriterien --- + + # Fall: Zeilenverarbeitung (Sequentiell, Re-Eval, Kriterien) + if mode_info["is_single_row_processing_mode"]: + # --- Schrittauswahl für Zeilenverarbeitung --- + # Wenn Schritte nicht über CLI (--steps) gesetzt wurden, frage interaktiv. + if not cli_steps: + logging.info("\n--- Schritte für Zeilenverarbeitung auswählen ---") + print("\nWelche Verarbeitungsschritte sollen für die Zeilen ausgeführt werden?") + STEP_OPTIONS = { + 1: {"name": "initial_search", "description": "Initial-Suchen (SerpAPI Website/Wiki, LinkedIn Kontakte)", "steps": ['initial_search']}, # Schrittnamen müssen mit denen in _process_single_row übereinstimmen (zukünftig) + 2: {"name": "core_extraction", "description": "Kern-Extraktion (Wiki Daten, Web Scraping, Web Summary)", "steps": ['website', 'wiki']}, # Namen der Gruppen-Flags + 3: {"name": "ki_enrichment", "description": "KI/Logik Anreicherung (Wiki Verify, Branch, FSM, MA Schätzung, Umsatz Schätzung, Konsistenz)", "steps": ['wiki_verify', 'chatgpt']}, # Namen der Gruppen-Flags + 4: {"name": "all_enrichment", "description": "Alle oben genannten Anreicherungs-Schritte (1+2+3)", "steps": ['initial_search', 'website', 'wiki', 'wiki_verify', 'chatgpt']}, # Alle relevanten Gruppen-Flags + # Optional detailliertere Schritte hier einfügen ('wiki_extract', 'wiki_verify', 'branch_eval', 'fsm_eval', 'ma_est', 'umsatz_est', 'ma_cons', 'umsatz_cons', 'website_scrape', 'website_summary', 'website_lookup', 'linkedin_contacts') + } + selected_step_option = None + while selected_step_option is None: + try: + for num, info in STEP_OPTIONS.items(): print(f" {num}: {info['description']}") + step_choice = input("Geben Sie die Zahl der Schrittgruppe ein: ").strip(); step_num = int(step_choice); + if step_num in STEP_OPTIONS: selected_step_option = STEP_OPTIONS[step_num]; steps_list = selected_step_option["steps"]; logging.info(f"Schrittgruppe gewählt: {selected_step_option['name']}"); + else: print("Ungültige Zahl für Schritte."); + except ValueError: print("Ungültige Eingabe."); + except Exception as e: logging.error(f"Fehler bei Schritt-Eingabe: {e}"); return; + + # Mappen der Schritt-String-Liste auf die boolschen Flags für _process_single_row + # Diese Flags müssen mit den Parameternamen von _process_single_row übereinstimmen (process_wiki, process_chatgpt, process_website) + # Im Refactoring werden dies detailliertere Flags in einem Dictionary sein. + # Vorerst: Mappe auf die 3 groben Flags + process_wiki_flag = 'wiki' in steps_list or 'wiki_verify' in steps_list or 'wiki_extraction' in steps_list # Wenn irgendein Wiki-Schritt gewählt + process_chatgpt_flag = 'chatgpt' in steps_list or 'branch_eval' in steps_list or 'fsm_eval' in steps_list or 'ma_est' in steps_list or 'umsatz_est' in steps_list or 'ma_cons' in steps_list or 'umsatz_cons' in steps_list # Wenn irgendein ChatGPT/KI Schritt gewählt + process_website_flag = 'web' in steps_list or 'website_scrape' in steps_list or 'website_summary' in steps_list # Wenn irgendein Website-Schritt gewählt + # Setzen der flags_for_steps für den Aufruf von _process_single_row (mit den 3 groben Flags) + flags_for_steps = { + 'process_wiki': process_wiki_flag, + 'process_chatgpt': process_chatgpt_flag, + 'process_website': process_website_flag + } + # Logge die gemappten Flags + logging.info(f"Gemappte _process_single_row Flags: wiki={process_wiki_flag}, chatgpt={process_chatgpt_flag}, website={process_website_flag}") + + + # --- Kriterien Auswahl für 'criteria' Modus --- + if mode_info["name"] == "criteria": + logging.info("\n--- Kriterium für Zeilenauswahl wählen ---") + print("\nWelches Kriterium soll für die Zeilenauswahl angewendet werden?") + CRITERIA_OPTIONS = { + 1: {"name": "m_filled_an_empty", "description": "Wiki URL (M) gefüllt UND Wiki Timestamp (AN) leer", "func": criteria_m_filled_an_empty}, # Globale Funktion + 2: {"name": "ao_empty", "description": "Timestamp letzte Prüfung (AO) leer", "func": criteria_ao_empty}, # Globale Funktion + 3: {"name": "ar_empty", "description": "Website Rohtext (AR) leer", "func": criteria_ar_empty}, # Globale Funktion + 4: {"name": "ax_empty", "description": "Wiki Verif. Timestamp (AX) leer", "func": criteria_ax_empty}, # Globale Funktion + # Fügen Sie hier weitere Kriterien hinzu + 5: {"name": "size_meets_threshold", "description": f"Umsatz CRM > {cli_min_umsatz} MIO € ODER Mitarbeiter CRM > {cli_min_employees}", "func": lambda row_data: criteria_size_meets_threshold(row_data, cli_min_employees, cli_min_umsatz)}, # Kriterium mit Parametern + } + selected_criteria_option = None + while selected_criteria_option is None: + try: + for num, info in CRITERIA_OPTIONS.items(): print(f" {num}: {info['description']}") + criteria_choice = input("Geben Sie die Zahl des Kriteriums ein: ").strip(); criteria_num = int(criteria_choice); + if criteria_num in CRITERIA_OPTIONS: selected_criteria_option = CRITERIA_OPTIONS[criteria_num]; criteria_func = selected_criteria_option["func"]; logging.info(f"Kriterium gewählt: {selected_criteria_option['name']}"); + else: print("Ungültige Zahl für Kriterium."); + except ValueError: print("Ungültige Eingabe."); + except Exception as e: logging.error(f"Fehler bei Kriterien-Eingabe: {e}"); return; + + # Für Criteria Modus: Abfragen, ob force_reeval für Schritte angewendet werden soll + reeval_criteria_input = input("Force re-evaluate (Timestamp/Status ignorieren) für diese Schritte bei passenden Zeilen? (j/N): ").strip().lower() + force_step_reeval = (reeval_criteria_input == 'j') + logging.info(f"Force re-evaluate für Kriterien-Modus Schritte: {force_step_reeval}") + + + # Fall: Batch-Verarbeitung (Ebene 1 = 4) + elif mode_info["name"] == "batch": + # Hier das Menü für die Auswahl des Batch-Modus anzeigen + logging.info("\n--- Batch-Modus auswählen ---") + print("\nWelchen Batch-Modus möchten Sie ausführen?") + BATCH_MODES = { + 1: {"name": "wiki_batch", "description": "Wikipedia-Verifizierung (AX)"}, + 2: {"name": "website_scrape_batch", "description": "Website-Scraping Rohtext (AT)"}, + 3: {"name": "summarize_batch", "description": "Website-Zusammenfassung (AS)"}, + 4: {"name": "branch_batch", "description": "Branchen-Einstufung (AO)"}, + # 5: {"name": "combined", "description": "Alle Batch-Modi nacheinander"}, # Optional + } + selected_batch_mode_info = None + while selected_batch_mode_info is None: + try: + for num, info in BATCH_MODES.items(): print(f" {num}: {info['description']}"); + batch_choice = input("Geben Sie die Zahl des Batch-Modus ein: ").strip(); batch_num = int(batch_choice); + if batch_num in BATCH_MODES: selected_batch_mode_info = BATCH_MODES[batch_num]; selected_batch_mode = selected_batch_mode_info["name"]; logging.info(f"Batch-Modus gewählt: {selected_batch_mode}"); + else: print("Ungültige Zahl für Batch-Modus."); + except ValueError: print("Ungültige Eingabe."); + except Exception as e: logging.error(f"Fehler bei Batch-Modus-Eingabe: {e}"); return; + + + # Fall: Dienstprogramme (Ebene 1 = 5) + elif mode_info["name"] == "dienstprogramme": + # Hier das Menü für die Auswahl des Dienstprogramms anzeigen + logging.info("\n--- Dienstprogramm auswählen ---") + print("\nWelches Dienstprogramm möchten Sie ausführen?") + DIENSTPROGRAMM_MODES = { + 1: {"name": "find_wiki_serp", "description": "Finde fehlende Wiki-URLs via SerpAPI"}, + 2: {"name": "website_lookup", "description": "Finde fehlende Website-URLs via SerpAPI"}, + 3: {"name": "contacts", "description": "Suche LinkedIn Kontakte via SerpAPI"}, + 4: {"name": "update_wiki_suggestions", "description": "Übernehme Wiki-Vorschläge aus U nach M"}, + 5: {"name": "train_technician_model", "description": "Trainiere ML Technikermodell"}, + 6: {"name": "alignment", "description": "Schreibe Header (A1:AY5)"}, + # 7: {"name": "website_details", "description": "EXPERIMENTELL: Extrahiere Website-Details für 'x' Zeilen"}, # Ggf. ausblenden + 8: {"name": "wiki_reextract", "description": "Wiki Re-Extraction (M gefüllt, AN leer) - Übergang"}, # Übergangsmodus + } + selected_dienstprogramm_info = None + while selected_dienstprogramm_info is None: + try: + for num, info in DIENSTPROGRAMM_MODES.items(): print(f" {num}: {info['description']}"); + dp_choice = input("Geben Sie die Zahl des Dienstprogramms ein: ").strip(); dp_num = int(dp_choice); + if dp_num in DIENSTPROGRAMM_MODES: selected_dienstprogramm_info = DIENSTPROGRAMM_MODES[dp_num]; selected_dienstprogramm = selected_dienstprogramm_info["name"]; logging.info(f"Dienstprogramm gewählt: {selected_dienstprogramm}"); + else: print("Ungültige Zahl für Dienstprogramm."); + except ValueError: print("Ungültige Eingabe."); + except Exception as e: logging.error(f"Fehler bei Dienstprogramm-Eingabe: {e}"); return; + + + # --- Abfrage Limit & Startzeile (Falls noch nicht gesetzt und benötigt) --- + # Limit ist bereits als CLI arg oder interaktiv abgefragt, wenn mode_info["requires_limit"] True ist. + # Startzeile wird nur für Sequenziell benötigt. + + if mode_info["name"] == "sequential" and start_row is None: + try: + start_row_input = input(f"Startzeile (1-basiert) für sequenzielle Verarbeitung? (Enter=automatisch ermitteln): ").strip(); + if start_row_input: + start_row_val = int(start_row_input); + if start_row_val >= 1: start_row = start_row_val; + else: logging.warning("Ungültige Startzeile ignoriert (<=0)."); start_row = None; + else: logging.info("Startzeile wird automatisch ermittelt."); + logging.info(f"Startzeile für sequenzielle Verarbeitung: {start_row if start_row is not None else 'Automatisch'}"); + except ValueError: logging.warning("Ungültige Startzeilen-Eingabe ignoriert."); start_row = None; + except Exception as e: logging.error(f"Fehler bei Startzeilen-Eingabe: {e}"); start_row = None; + + + # --- Modus Ausführung basierend auf Auswahl --- + try: + # Fall: Zeilenverarbeitung (Sequenziell, Re-Eval, Kriterien) + if mode_info["is_single_row_processing_mode"]: + if flags_for_steps is None or not any(flags_for_steps.values()): + logging.warning("Keine Verarbeitungsschritte für Zeilenverarbeitung ausgewählt oder Fehler bei Auswahl. Nichts zu tun."); return; + + if mode_info["name"] == "sequential": + # Sequenzielle Verarbeitung ruft process_sequential Methode auf + # start_row ist die 1-basierte Sheet-Zeile, wird so an process_sequential übergeben. + # process_sequential kümmert sich um die Konvertierung zu Daten-Index und Iteration. + # Wenn start_row None ist, wird es in process_sequential automatisch ermittelt. + data_processor.process_sequential( + start_sheet_row = start_row, # Kann None sein + num_to_process = row_limit, # Kann None sein + process_wiki = flags_for_steps.get('process_wiki', False), # <<< ÜBERGIBT DIE STEUERUNG + process_chatgpt = flags_for_steps.get('process_chatgpt', False), # <<< ÜBERGIBT DIE STEUERUNG + process_website = flags_for_steps.get('process_website', False) # <<< ÜBERGIBT DIE STEUERUNG + ); + + + elif mode_info["name"] == "reeval": + # Re-Evaluate ruft process_reevaluation_rows Methode auf + # row_limit und flags_for_steps sind bereits gesetzt + data_processor.process_reevaluation_rows( + row_limit = row_limit, + clear_flag = True, # Standardmäßig Flag 'x' löschen + process_wiki_steps = flags_for_steps.get('process_wiki', False), # <<< ÜBERGIBT DIE STEUERUNG + process_chatgpt_steps = flags_for_steps.get('process_chatgpt', False), # <<< ÜBERGIBT DIE STEUERUNG + process_website_steps = flags_for_steps.get('process_website', False) # <<< ÜBERGIBT DIE STEUERUNG + ); + + elif mode_info["name"] == "criteria": + # Kriterienbasierte Verarbeitung ruft process_rows_matching_criteria Methode auf + # limit, flags_for_steps, criteria_func, force_step_reeval sind bereits gesetzt + if criteria_func is None: logging.error("FEHLER: Kriterien-Funktion nicht gesetzt. Abbruch."); return; + data_processor.process_rows_matching_criteria( + criteria_func = criteria_func, + limit = row_limit, + process_wiki = flags_for_steps.get('process_wiki', False), + process_chatgpt = flags_for_steps.get('process_chatgpt', False), + process_website = flags_for_steps.get('process_website', False), + force_step_reeval = force_step_reeval + ); + + + # Fall: Batch-Verarbeitung (Ebene 1 = 4) + elif mode_info["name"] == "batch": + if selected_batch_mode is None: logging.warning("Kein Batch-Modus ausgewählt oder Fehler bei Auswahl. Nichts zu tun."); return; + # Batch Dispatcher Methode aufrufen + data_processor.run_batch_dispatcher( + mode = selected_batch_mode, # Gewählten Batch-Modus Namen übergeben + limit = row_limit # Limit übergeben + ); + + # Fall: Dienstprogramme (Ebene 1 = 5) + elif mode_info["name"] == "dienstprogramme": + if selected_dienstprogramm is None: logging.warning("Kein Dienstprogramm ausgewählt oder Fehler bei Auswahl. Nichts zu tun."); return; + + # Aufruf der spezifischen Dienstprogramm-Methode + if selected_dienstprogramm == "find_wiki_serp": + # Parameter werden über CLI args (args.min_umsatz, args.min_employees) oder Defaults genommen + data_processor.process_find_wiki_serp(row_limit=row_limit, min_employees=cli_min_employees, min_umsatz=cli_min_umsatz); # <<< CLI args hier übergeben + elif selected_dienstprogramm == "website_lookup": + data_processor.process_serp_website_lookup(limit=row_limit); + elif selected_dienstprogramm == "contacts": + data_processor.process_contact_research(limit=row_limit); # limit parameter hinzufügen + elif selected_dienstprogramm == "update_wiki_suggestions": + data_processor.process_wiki_updates_from_chatgpt(row_limit=row_limit); + elif selected_dienstprogramm == "train_technician_model": + # Parameter werden über CLI args genommen + data_processor.train_technician_model(model_out=args.model_out, imputer_out=args.imputer_out, patterns_out=args.patterns_out); # <<< CLI args hier übergeben + elif selected_dienstprogramm == "alignment": + # alignment_demo ist global und braucht sheet_handler.sheet + alignment_demo(data_processor.sheet_handler.sheet); + elif selected_dienstprogramm == "website_details": + data_processor.process_website_details(limit=row_limit); # limit parameter hinzufügen + elif selected_dienstprogramm == "wiki_reextract": + # Dies ist der Übergangsmodus, der die temporäre globale Funktion nutzt + # Annahme: process_wiki_reextract_missing_an ist global + process_wiki_reextract_missing_an(data_processor.sheet_handler, data_processor, limit=row_limit); + + except KeyboardInterrupt: logging.warning("Skript durch Benutzer unterbrochen (KeyboardInterrupt)."); print("\n! Skript wurde manuell beendet."); + except Exception as e: logging.critical(f"FATAL: Unerwarteter Fehler während der Ausführung von Modus '{mode_info.get('name', 'Unbekannt')}': {e}"); logging.exception("Traceback des kritischen Fehlers:"); + + +# ==================== MAIN EXECUTION BLOCK ==================== +# Diese Funktion ist der eigentliche Startpunkt des Skripts, wenn die Datei ausgeführt wird. + +# main Funktion def main(): # WICHTIG: Global LOG_FILE wird benötigt, aber erst nach Arg-Parsing gesetzt. global LOG_FILE # --- Initial Logging Setup (Konfiguration von Level und Format) --- - # Diese Konfiguration wird wirksam, sobald die Handler hinzugefügt werden. import logging - log_level = logging.DEBUG # Explizit DEBUG setzen für detaillierte Logs - log_format = '%(asctime)s - %(levelname)-8s - %(name)-15s - %(message)s' # Angepasstes Format + log_level = logging.DEBUG + log_format = '%(asctime)s - %(levelname)-8s - %(name)-15s - %(message)s' - # Root-Logger konfigurieren (noch ohne File Handler) - # handlers=[] verhindert default Console Handler, wir fügen ihn manuell hinzu logging.basicConfig(level=log_level, format=log_format, handlers=[]) - - # Console Handler explizit hinzufügen - console_handler = logging.StreamHandler() - console_handler.setLevel(log_level) # Nimm das globale Level - console_handler.setFormatter(logging.Formatter(log_format)) - logging.getLogger('').addHandler(console_handler) # Füge zum Root-Logger hinzu - # --- Ende Initial Logging Setup --- - - # Testnachricht (geht nur an Konsole, da File Handler noch fehlt) - logging.debug("DEBUG Logging initial konfiguriert (nur Konsole).") - logging.info("INFO Logging initial konfiguriert (nur Konsole).") + console_handler = logging.StreamHandler(); console_handler.setLevel(log_level); console_handler.setFormatter(logging.Formatter(log_format)); + logging.getLogger('').addHandler(console_handler); # --- Initialisierung (Argument Parser etc.) --- # Version hier (sollte mit Config.VERSION übereinstimmen) - current_script_version = "v1.6.6" # <-- ANPASSEN, wenn Config.VERSION geändert wird + current_script_version = "v1.6.7" # <<< ANPASSEN, wenn Config.VERSION geändert wird - parser = argparse.ArgumentParser(description=f"Firmen-Datenanreicherungs-Skript {current_script_version}") - # Liste der gültigen Modi (basierend auf Ihrer aktuellen v1.6.6 + dem neuen Modus) - valid_modes = [ - "combined", "wiki", "website", "branch", "summarize", "reeval", - "website_lookup", "website_details", "contacts", "full_run", - "alignment", "train_technician_model", "update_wiki", - "find_wiki_serp", "wiki_reextract" # <<< NEUER MODUS HIER HINZUGEFÜGT - ] - # Stellen Sie sicher, dass diese Liste mit denelif-Zweigen unten übereinstimmt. + parser = argparse.ArgumentParser(description=f"Firmen-Datenanreicherungs-Skript {current_script_version}"); + # Liste der gültigen Hauptmodi (Namen) für CLI + valid_main_modes = ["sequential", "reeval", "criteria", "batch", "dienstprogramme"]; - parser.add_argument("--mode", type=str, help=f"Betriebsmodus ({', '.join(valid_modes)})") - parser.add_argument("--limit", type=int, help="Maximale Anzahl zu verarbeitender Zeilen", default=None) - # start_row wird primär für full_run verwendet, kann aber generell hilfreich sein - parser.add_argument("--start_row", type=int, help="Startzeile im Sheet (1-basiert) für sequenzielle Modi", default=None) + parser.add_argument("--mode", type=str, help=f"Hauptbetriebsmodus ({', '.join(valid_main_modes)})"); + parser.add_argument("--limit", type=int, help="Maximale Anzahl zu verarbeitender Zeilen", default=None); + parser.add_argument("--start_row", type=int, help="Startzeile im Sheet (1-basiert) für sequenzielle Modi", default=None); # NEUES ARGUMENT für den Re-Eval Modus zur Auswahl der Schritte # Standard ist "wiki,chat,web", um das bisherige Verhalten zu imitieren # Mögliche Werte für die Schritte: 'wiki', 'chat', 'web' (entsprechend den Parametern in _process_single_row) - parser.add_argument("--steps", type=str, help="Komma-getrennte Liste der Schritte im 'reeval' Modus (z.B. 'wiki,chat,web'). Mögliche Schritte: wiki, chat, web.", default="wiki,chat,web") + # Im Refactoring werden dies detailliertere Namen sein. + parser.add_argument("--steps", type=str, help="Komma-getrennte Liste der Schritte im 'reeval' Modus (z.B. 'wiki,chat,web'). Mögliche Schritte: wiki, chat, web.", default="wiki,chat,web"); # Argumente für find_wiki_serp (falls über CLI gesteuert) - parser.add_argument("--min_umsatz", type=int, help="Mindestumsatz in Mio € für find_wiki_serp", default=200) - parser.add_argument("--min_employees", type=int, help="Mindestmitarbeiterzahl für find_wiki_serp", default=500) + parser.add_argument("--min_umsatz", type=int, help="Mindestumsatz in Mio € für find_wiki_serp", default=200); + parser.add_argument("--min_employees", type=int, help="Mindestmitarbeiterzahl für find_wiki_serp", default=500); # Argumente für train_technician_model - parser.add_argument("--model_out", type=str, default=MODEL_FILE, help=f"Pfad für Modell (.pkl)") - parser.add_argument("--imputer_out", type=str, default=IMPUTER_FILE, help=f"Pfad für Imputer (.pkl)") - parser.add_argument("--patterns_out", type=str, default=PATTERNS_FILE_TXT, help=f"Pfad für Regeln (.txt)") + parser.add_argument("--model_out", type=str, default=MODEL_FILE, help=f"Pfad für Modell (.pkl)"); + parser.add_argument("--imputer_out", type=str, default=IMPUTER_FILE, help=f"Pfad für Imputer (.pkl)"); + parser.add_argument("--patterns_out", type=str, default=PATTERNS_FILE_TXT, help=f"Pfad für Regeln (.txt)"); - # TODO: Fügen Sie hier weitere CLI-Argumente hinzu, falls andere Modi Parameter benötigen (z.B. für Kriterien-Modus) + # TODO: Fügen Sie hier weitere CLI-Argumente hinzu, falls andere Modi Parameter benötigen (z.B. für Kriterien-Modus spezifische Parameter) - args = parser.parse_args() + args = parser.parse_args(); # Lade API Keys direkt am Anfang - Config.load_api_keys() # Nutzt jetzt logging intern + Config.load_api_keys(); # Nutzt jetzt logging intern # --- Logdatei-Konfiguration abschließen --- - # Bestimmen Sie den Log-Modus Namen basierend auf CLI oder Interaktion - # Wir nutzen den CLI Modus Namen, wenn er gesetzt ist, sonst einen Platzhalter. - # Der tatsächliche Modus wird unten ermittelt und geloggt. - log_mode_name = args.mode if args.mode else "interactive" - LOG_FILE = create_log_filename(log_mode_name) # Annahme: create_log_filename ist global + log_mode_name = args.mode if args.mode else "interactive"; # Verwenden Sie den CLI Modus Namen, wenn vorhanden + LOG_FILE = create_log_filename(log_mode_name); # Annahme: create_log_filename ist global try: - file_handler = logging.FileHandler(LOG_FILE, mode='a', encoding='utf-8') - file_handler.setLevel(log_level) # Nimm das globale Level - file_handler.setFormatter(logging.Formatter(log_format)) - # Füge FileHandler zum Root-Logger hinzu - logging.getLogger('').addHandler(file_handler) - logging.info(f"Logging wird jetzt auch in Datei geschrieben: {LOG_FILE}") + file_handler = logging.FileHandler(LOG_FILE, mode='a', encoding='utf-8'); file_handler.setLevel(log_level); file_handler.setFormatter(logging.Formatter(log_format)); + logging.getLogger('').addHandler(file_handler); logging.info(f"Logging wird jetzt auch in Datei geschrieben: {LOG_FILE}"); except Exception as e: - # Logge Fehler nur auf Konsole, da FileHandler fehlgeschlagen ist - print(f"[ERROR] Konnte FileHandler für Logdatei '{LOG_FILE}' nicht erstellen: {e}") - logging.getLogger('').handlers = [h for h in logging.getLogger('').handlers if not isinstance(h, logging.FileHandler)] # Entferne evtl. defekten Handler - logging.error(f"Konnte FileHandler für Logdatei '{LOG_FILE}' nicht erstellen: {e}") - + print(f"[ERROR] Konnte FileHandler für Logdatei '{LOG_FILE}' nicht erstellen: {e}"); + logging.getLogger('').handlers = [h for h in logging.getLogger('').handlers if not isinstance(h, logging.FileHandler)]; + logging.error(f"Konnte FileHandler für Logdatei '{LOG_FILE}' nicht erstellen: {e}"); # --- JETZT die Startmeldungen loggen (gehen jetzt in Konsole UND Datei) --- - logging.info(f"===== Skript gestartet =====") - logging.info(f"Version: {Config.VERSION}") # Sollte jetzt v1.6.6 sein - # Der Modus wird später vom Dispatcher geloggt - logging.info(f"Logdatei: {LOG_FILE}") - # Loggen Sie auch die Re-Eval Schritte, wenn das Argument gesetzt ist (unabhängig vom gewählten Modus, zur Info) - if 'steps' in args and args.steps: - logging.info(f"CLI Argument --steps: '{args.steps}' (relevant für 'reeval' Modus)") - if 'min_umsatz' in args: logging.info(f"CLI Argument --min_umsatz: {args.min_umsatz}") - if 'min_employees' in args: logging.info(f"CLI Argument --min_employees: {args.min_employees}") - if 'model_out' in args: logging.info(f"CLI Argument --model_out: '{args.model_out}'") - # ... loggen Sie weitere relevante CLI Argumente + logging.info(f"===== Skript gestartet ====="); logging.info(f"Version: {Config.VERSION}"); logging.info(f"Logdatei: {LOG_FILE}"); + if args.mode: logging.info(f"Betriebsmodus (CLI): {args.mode}"); + if args.limit is not None: logging.info(f"CLI Argument --limit: {args.limit}"); + if args.start_row is not None: logging.info(f"CLI Argument --start_row: {args.start_row}"); + if 'steps' in args and args.steps: logging.info(f"CLI Argument --steps: '{args.steps}' (relevant für 'reeval' Modus)"); + if 'min_umsatz' in args: logging.info(f"CLI Argument --min_umsatz: {args.min_umsatz}"); + if 'min_employees' in args: logging.info(f"CLI Argument --min_employees: {args.min_employees}"); + if 'model_out' in args: logging.info(f"CLI Argument --model_out: '{args.model_out}'"); + if 'imputer_out' in args: logging.info(f"CLI Argument --imputer_out: '{args.imputer_out}'"); + if 'patterns_out' in args: logging.info(f"CLI Argument --patterns_out: '{args.patterns_out}'"); # --- Vorbereitung (Schema, Sheet Handler etc.) --- - load_target_schema() # Annahme: load_target_schema ist global definiert + load_target_schema(); # Annahme: load_target_schema ist global definiert - try: - sheet_handler = GoogleSheetHandler() # Annahme: GoogleSheetHandler ist global definiert - except Exception as e: - logging.critical(f"FATAL: Initialisierung des GoogleSheetHandlers fehlgeschlagen: {e}") - logging.critical(f"Bitte Logdatei prüfen: {LOG_FILE}") - return # Beende Skript, wenn Sheet nicht geladen werden kann + try: sheet_handler = GoogleSheetHandler(); # Annahme: GoogleSheetHandler ist global definiert + except Exception as e: logging.critical(f"FATAL: Initialisierung GoogleSheetHandlers fehlgeschlagen: {e}"); logging.critical(f"Bitte Logdatei prüfen: {LOG_FILE}"); return; - try: - # Initialisiere WikipediaScraper hier, da er an DataProcessor übergeben werden muss - wiki_scraper = WikipediaScraper() # Annahme: WikipediaScraper ist global definiert und benötigt keine Parameter oder nutzt Config - except Exception as e: - logging.critical(f"FATAL: Initialisierung des WikipediaScrapers fehlgeschlagen: {e}") - logging.critical(f"Bitte Logdatei prüfen: {LOG_FILE}") - # Das Skript kann ohne Wiki Scraper nicht sinnvoll laufen - return + try: wiki_scraper = WikipediaScraper(); # Annahme: WikipediaScraper ist global definiert + except Exception as e: logging.critical(f"FATAL: Initialisierung WikipediaScrapers fehlgeschlagen: {e}"); logging.critical(f"Bitte Logdatei prüfen: {LOG_FILE}"); return; # Initialisiere DataProcessor Instanz mit Handlern - # PASSEN SIE DIESEN AUFRUF AN DIE TATSÄCHLICHE __init__ SIGNATUR IHRER DataProcessor Klasse an - # In v1.6.6 nahm sie nur sheet_handler entgegen. Für den Refactoring-Plan soll sie wiki_scraper auch nehmen. - # Für diese Übergangsversion halten wir uns an die v1.6.6 Signatur (nur sheet_handler) - # ABER: Methoden IN DataProcessor (wie _process_single_row) brauchen den wiki_scraper! - # Das bedeutet, wiki_scraper muss in __init__ übergeben und als self.wiki_scraper gespeichert werden. - # KORRIGIEREN SIE DataProcessor.__init__ ZU: def __init__(self, sheet_handler, wiki_scraper): - data_processor = DataProcessor(sheet_handler, wiki_scraper) # <<< KORRIGIERTER AUFRUF - - # --- Modusauswahl und Ausführung --- - start_time = time.time() - logging.info(f"Starte Verarbeitung um {datetime.now().strftime('%H:%M:%S')}...") - - mode = None # Wird aus CLI oder Interaktion ermittelt - - # --- Ermitteln des zu führenden Modus (CLI hat Priorität) --- - if args.mode: - mode = args.mode.lower() - if mode not in valid_modes: - logging.error(f"Ungültiger Modus '{args.mode}' über Kommandozeile angegeben. Gültige Modi: {', '.join(valid_modes)}") - print(f"Fehler: Ungültiger Modus '{args.mode}'. Siehe --help.") - return # Skript beenden - logging.info(f"Betriebsmodus (CLI gewählt): {mode}") - else: - # --- Interaktive Modusauswahl --- - print("\nBitte wählen Sie den Betriebsmodus:") - # Zeigen Sie die Liste der validen Modi an - for i, m in enumerate(valid_modes): - print(f" {i+1}: {m}") - - while mode is None: # Schleife, bis ein gültiger Modus gewählt wurde - try: - mode_input = input(f"Geben Sie den Modusnamen oder die Zahl ein: ").strip().lower() - try: - mode_index = int(mode_input) - if 1 <= mode_index <= len(valid_modes): mode = valid_modes[mode_index - 1] - else: print("Ungültige Zahl.") - except ValueError: - if mode_input in valid_modes: mode = mode_input - else: print("Ungültige Eingabe.") - - if mode: logging.info(f"Betriebsmodus (interaktiv gewählt): {mode}") - # Wenn mode None bleibt, Schleife läuft weiter - - except Exception as e: - logging.error(f"Fehler bei interaktiver Modus-Eingabe: {e}"); return # Skript beenden - print(f"Fehler Modus-Eingabe ({e}).") + data_processor = DataProcessor(sheet_handler, wiki_scraper); # <<< KORRIGIERTER AUFRUF - # --- Ausführung des gewählten Modus --- + # --- Start der Benutzerinteraktion / Modusausführung --- + start_time = time.time(); logging.info(f"Starte Verarbeitung um {datetime.now().strftime('%H:%M:%S')}..."); + try: - # Rufen Sie die entsprechenden Funktionen/Methoden auf basierend auf dem gewählten 'mode' - # Die Aufrufe hier werden auf die 'data_processor' Instanz umgestellt, - # da die Funktionen jetzt Methoden dieser Klasse sind (oder es sein sollten). + # Rufe die Funktion auf, die das Menü und den Dispatching übernimmt + # Wenn CLI args gesetzt sind, wird das Menü übersprungen. + run_user_interface( + data_processor = data_processor, # Instanz übergeben + cli_mode = args.mode, + cli_limit = args.limit, + cli_start_row = args.start_row, + cli_steps = args.steps, # <<< NEU: steps Argument übergeben + cli_min_umsatz = args.min_umsatz, # <<< NEU: min_umsatz übergeben + cli_min_employees = args.min_employees # <<< NEU: min_employees übergeben + # Weitere CLI args hier übergeben, falls nötig (z.B. für train_technician_model) + ); - if mode == "combined": - # Der combined Mode war ein globaler run_dispatcher Aufruf. - # run_dispatcher sollte eine Methode in DataProcessor sein. - data_processor.run_batch_dispatcher(mode="combined", limit=args.limit) # Annahme: run_batch_dispatcher existiert in DataProcessor - - elif mode == "wiki": # Entspricht dem Batch-Modus Wiki Verifizierung (AX) - # process_verification_only sollte jetzt data_processor.process_verification_batch sein - data_processor.process_verification_batch(limit=args.limit) - - elif mode == "website": # Entspricht dem Batch-Modus Website Scraping (AT) - # process_website_batch sollte jetzt data_processor.process_website_batch sein - data_processor.process_website_batch(limit=args.limit) - - elif mode == "summarize": # Entspricht dem Batch-Modus Website Summarization (AS) - # process_website_summarization_batch sollte jetzt data_processor.process_summarization_batch sein - data_processor.process_summarization_batch(limit=args.limit) - - elif mode == "branch": # Entspricht dem Batch-Modus Branchen-Einstufung (AO) - # process_branch_batch sollte jetzt data_processor.process_branch_batch sein - data_processor.process_branch_batch(limit=args.limit) - - elif mode == "reeval": # process_reevaluation_rows - if args.limit is not None and args.limit <= 0: - logging.info(f"Limit {args.limit} angegeben im Re-Eval Modus. Überspringe Verarbeitung.") - else: - # Parse das neue --steps Argument - steps_list = [step.strip().lower() for step in args.steps.split(',')] - # Mappen Sie die CLI-Schrittnamen auf die Parameter von process_reevaluation_rows - # Die Parameter in process_reevaluation_rows (v1.6.6 Anpassung) sind: - # process_wiki_steps, process_chatgpt_steps, process_website_steps - process_wiki_flag = 'wiki' in steps_list - process_chatgpt_flag = 'chat' in steps_list - process_website_flag = 'web' in steps_list - # Wenn Ihre process_reevaluation_rows weitere boolsche Flags akzeptiert, mappen Sie die entsprechenden CLI-Namen hier. - - # Rufen Sie process_reevaluation_rows mit den ausgelesenen Flags auf - # process_reevaluation_rows ist eine Methode in DataProcessor. - data_processor.process_reevaluation_rows( - row_limit=args.limit, - clear_flag=True, # Standardmäßig Flag 'x' löschen - process_wiki_steps=process_wiki_flag, # <<< ÜBERGIBT DIE STEUERUNG - process_chatgpt_steps=process_chatgpt_flag, # <<< ÜBERGIBT DIE STEUERUNG - process_website_steps=process_website_flag - # Wenn Ihre process_reevaluation_rows weitere Parameter hat, übergeben Sie diese hier - ) - - elif mode == "website_lookup": - # process_serp_website_lookup_for_empty sollte jetzt data_processor.process_serp_website_lookup sein - data_processor.process_serp_website_lookup(limit=args.limit) # Fügen Sie hier den Limit Parameter hinzu, falls gewünscht/unterstützt - - elif mode == "website_details": - # process_website_details_for_marked_rows sollte jetzt data_processor.process_website_details sein - data_processor.process_website_details(limit=args.limit) # Fügen Sie hier den Limit Parameter hinzu, falls gewünscht/unterstützt - - elif mode == "contacts": - # process_contact_research sollte jetzt data_processor.process_contact_research sein - data_processor.process_contact_research(limit=args.limit) # Fügen Sie hier den Limit Parameter hinzu, falls gewünscht/unterstützt - - elif mode == "full_run": # process_rows_sequentially - # process_rows_sequentially ist eine Methode in DataProcessor. - # Der Aufruf muss hier implementiert werden (Startindex Logic etc., wie im alten main Block). - logging.warning("Modus 'full_run' benötigt noch die Implementierung des Aufrufs von process_sequential.") - # Beispielaufruf (wenn process_sequential eine Methode ist): - # # start_data_index logic (wie im alten main block) - # header_rows = 5 # Annahme - # start_data_index = 0 # Default - # if args.start_row is not None: - # start_data_index = args.start_row - 1 # 0-based - # if start_data_index < header_rows: logging.warning(f"Manuelle Startzeile {args.start_row} liegt innerhalb der Header."); start_data_index = header_rows - # else: - # # Automatische Ermittlung der Startzeile (z.B. erste Zeile ohne AO) - # logging.info("Automatische Ermittlung der Startzeile für sequenzielle Verarbeitung (erste Zeile ohne AO)...") - # # get_start_row_index gibt 0-basierter Index in Daten (ohne Header) zurück - # start_data_index_no_header = sheet_handler.get_start_row_index(check_column_key="Timestamp letzte Prüfung") - # if start_data_index_no_header == -1: logging.error("FEHLER bei automatischer Ermittlung der Startzeile."); return - # start_data_index = start_data_index_no_header + header_rows # 0-based index in all_data - # - # # Berechne num_to_process - # if not sheet_handler.load_data(): logging.error("Fehler beim Laden der Daten."); return - # total_rows = len(sheet_handler.get_all_data_with_headers()) - # num_available = total_rows - start_data_index # Anzahl Zeilen ab Startindex - # num_to_process = num_available - # if args.limit is not None and args.limit >= 0: - # num_to_process = min(num_available, args.limit) - # - # if num_to_process > 0: - # logging.info(f"'full_run': Verarbeite {num_to_process} Zeilen ab Sheet-Zeile {start_data_index + 1}.") - # # Hier müssten Sie auch die Flags für die Schritte abfragen/übergeben - # # Für full_run würden Sie wahrscheinlich alle Schritte wählen (oder über neues Argument steuern) - # data_processor.process_sequential( - # start_sheet_row = start_data_index + 1, # 1-basierte Startzeile - # num_to_process = num_to_process, - # process_wiki=True, # Beispiel: Alle Schritte - # process_chatgpt=True, - # process_website=True - # # Wenn process_sequential granularere Flags nimmt, übergeben Sie diese hier - # ) - # else: logging.info("Keine Zeilen für 'full_run' zu verarbeiten.") - - - elif mode == "alignment": - # alignment_demo ist global und braucht sheet_handler.sheet - alignment_demo(sheet_handler.sheet) # Stellen Sie sicher, dass alignment_demo global bleibt - - elif mode == "train_technician_model": - # train_technician_model sollte jetzt data_processor.train_technician_model sein - data_processor.train_technician_model(model_out=args.model_out, imputer_out=args.imputer_out, patterns_out=args.patterns_out) # Argumente übergeben - - elif mode == "update_wiki": - # process_wiki_updates_from_chatgpt sollte jetzt data_processor.process_wiki_updates_from_chatgpt sein - data_processor.process_wiki_updates_from_chatgpt(row_limit=args.limit) # row_limit Parameter hinzufügen - - elif mode == "find_wiki_serp": - # process_find_wiki_with_serp sollte jetzt data_processor.process_find_wiki_serp sein - data_processor.process_find_wiki_serp(row_limit=args.limit, min_employees=args.min_employees, min_umsatz=args.min_umsatz) # min_employees und min_umsatz hinzufügen - - - elif mode == "wiki_reextract": # <<< NEUER MODUS RUFT NEUE FUNKTION AUF - # Rufe die neu erstellte globale Funktion auf, die sheet_handler und data_processor benötigt - # Diese Funktion implementiert die Kriterien-Logik "M gefüllt & AN leer" und ruft dann _process_single_row - # mit den spezifischen Flags (nur Wiki) und force_reeval=True auf. - process_wiki_reextract_missing_an(sheet_handler, data_processor, limit=args.limit) # Annahme: process_wiki_reextract_missing_an ist global definiert - - - else: - # Dies sollte nicht passieren, wenn die Validierung oben korrekt ist - logging.error(f"Unerwarteter Modus '{mode}' erreicht das Ausführungsende.") - - - except KeyboardInterrupt: - logging.warning("Skript durch Benutzer unterbrochen (KeyboardInterrupt).") - print("\n! Skript wurde manuell beendet.") - except Exception as e: - # Dieser Block fängt Fehler ab, die in den aufgerufenen Funktionen/Methoden passieren - logging.critical(f"FATAL: Unerwarteter Fehler während der Ausführung von Modus '{mode}': {e}") - logging.exception("Traceback des kritischen Fehlers:") + except KeyboardInterrupt: logging.warning("Skript durch Benutzer unterbrochen (KeyboardInterrupt)."); print("\n! Skript wurde manuell beendet."); + except Exception as e: logging.critical(f"FATAL: Unerwarteter Fehler im Hauptausführungsblock: {e}"); logging.exception("Traceback des kritischen Fehlers:"); # --- Abschluss --- - end_time = time.time() - duration = end_time - start_time - logging.info(f"Verarbeitung abgeschlossen um {datetime.now().strftime('%H:%M:%S')}.") - logging.info(f"Gesamtdauer: {duration:.2f} Sekunden.") - logging.info(f"===== Skript beendet =====") + end_time = time.time(); duration = end_time - start_time; + logging.info(f"Verarbeitung abgeschlossen um {datetime.now().strftime('%H:%M:%S')}."); logging.info(f"Gesamtdauer: {duration:.2f} Sekunden."); logging.info(f"===== Skript beendet ====="); # Schließe Logging Handler explizit - logging.shutdown() + logging.shutdown(); # Logfile Pfad für den Nutzer ausgeben - print(f"\nVerarbeitung abgeschlossen. Logfile: {LOG_FILE}") + print(f"\nVerarbeitung abgeschlossen. Logfile: {LOG_FILE}"); -# Führt die main-Funktion aus, wenn das Skript direkt gestartet wird +# ==================== __main__ BLOCK ==================== +# Dieser Block wird ausgeführt, wenn das Skript direkt gestartet wird. if __name__ == '__main__': # --- Sicherstellen, dass alle globalen Imports hier sind --- # ... (alle Imports wie am Anfang des Skripts) ... # --- Sicherstellen, dass alle globalen Helfer-Funktionen hier oder importiert sind --- - # ... (Alle Ihre globalen Helfer-Funktionen: clean_text, normalize_company_name, - # extract_numeric_value, get_numeric_filter_value, call_openai_chat, serp_wikipedia_lookup, - # serp_website_lookup, search_linkedin_contacts, get_gender, get_email_address, - # fuzzy_similarity, is_valid_wikipedia_article_url, evaluate_branche_chatgpt, - # summarize_website_content, load_target_schema, map_external_branch, alignment_demo, - # retry_on_failure, create_log_filename, debug_print, _process_batch (falls global)) ... - - # NEU: Die Kriterien-Funktion und die Funktion, die den neuen Modus steuert, müssen hier global sein - # Kopieren Sie die Definitionen von criteria_m_filled_an_empty und process_wiki_reextract_missing_an hierher. + # Kopieren Sie die Definitionen der globalen Helfer Funktionen hierher oder stellen Sie sicher, dass sie importiert werden können. + # Global Helper Functions: clean_text, simple_normalize_url, normalize_string, + # extract_numeric_value, get_numeric_filter_value, fuzzy_similarity, token_count, + # call_openai_chat, summarize_website_content, evaluate_branche_chatgpt, + # is_valid_wikipedia_article_url, serp_website_lookup, serp_wikipedia_lookup, + # search_linkedin_contacts, get_gender, get_email_address, load_target_schema, + # map_external_branch, alignment_demo, retry_on_failure, create_log_filename, + # debug_print, _process_batch (falls global), + # Kriterien-Funktionen (criteria_m_filled_an_empty, criteria_size_meets_threshold etc.), + # Übergangsfunktionen (process_wiki_reextract_missing_an). # --- Sicherstellen, dass alle Klassen hier definiert sind --- - # ... (Config, GoogleSheetHandler, WikipediaScraper) ... - # KORRIGIERTE DataProcessor Klasse Definition (mit __init__(self, sheet_handler, wiki_scraper)) - # und allen Methoden, die Sie bis jetzt hatten, IN DER KLASSE eingerückt. + # Kopieren Sie die Definitionen der Klassen hierher oder stellen Sie sicher, dass sie importiert werden können. + # Klassen: Config, GoogleSheetHandler, WikipediaScraper, DataProcessor. # Die main Funktion aufrufen - main() \ No newline at end of file + main(); \ No newline at end of file