diff --git a/MIGRATION_PLAN.md b/MIGRATION_PLAN.md index f2c29609..72f97b50 100644 --- a/MIGRATION_PLAN.md +++ b/MIGRATION_PLAN.md @@ -1,4 +1,4 @@ -# Migrations-Plan: Legacy GSheets -> Company Explorer (Robotics Edition v0.5.1) +# Migrations-Plan: Legacy GSheets -> Company Explorer (Robotics Edition v0.7.0) **Kontext:** Neuanfang für die Branche **Robotik & Facility Management**. **Ziel:** Ablösung von Google Sheets/CLI durch eine Web-App ("Company Explorer") mit SQLite-Backend. @@ -8,10 +8,10 @@ | Bereich | Alt (Legacy) | Neu (Robotics Edition) | | :--- | :--- | :--- | | **Daten-Basis** | Google Sheets | **SQLite** (Lokal, performant, filterbar). | -| **Ziel-Daten** | Allgemein / Kundenservice | **Robotics-Signale** (SPA-Bereich? Intralogistik? Werkschutz?). | -| **Branchen** | KI-Vorschlag (Freitext) | **Strict Mode:** Mapping auf feste CRM-Liste (z.B. "Hotellerie", "Maschinenbau"). | -| **Texterstellung** | Pain/Gain Matrix (Service) | **Pain/Gain Matrix (Robotics)**. "Übersetzung" des alten Wissens auf Roboter. | -| **Analytics** | Techniker-ML-Modell | **Deaktiviert**. Vorerst keine Relevanz. | +| **Ziel-Daten** | Allgemein / Kundenservice | **Quantifizierbares Potenzial** (z.B. 4500m² Fläche, 120 Betten). | +| **Branchen** | KI-Vorschlag (Freitext) | **Strict Mode:** Mapping auf definierte Notion-Liste (z.B. "Hotellerie", "Automotive"). | +| **Bewertung** | 0-100 Score (Vage) | **Data-Driven:** Rohwert (Scraper/Search) -> Standardisierung (Formel) -> Potenzial. | +| **Analytics** | Techniker-ML-Modell | **Deaktiviert**. Fokus auf harte Fakten. | | **Operations** | D365 Sync (Broken) | **Excel-Import & Deduplizierung**. Fokus auf Matching externer Listen gegen Bestand. | ## 2. Architektur & Komponenten-Mapping @@ -26,8 +26,7 @@ Das System wird in `company-explorer/` neu aufgebaut. Wir lösen Abhängigkeiten | **Importer** | Ersetzt `SyncManager`. Importiert Excel-Dumps (CRM) und Event-Listen. | 1 | | **Deduplicator** | Ersetzt `company_deduplicator.py`. **Kern-Feature:** Checkt Event-Listen gegen DB. Muss "intelligent" matchen (Name + Ort + Web). | 1 | | **Scraper (Base)** | Extrahiert Text von Websites. Basis für alle Analysen. | 1 | -| **Signal Detector** | **NEU.** Analysiert Website-Text auf Roboter-Potential.
*Logik:* Wenn Branche = Hotel & Keyword = "Wellness" -> Potential: Reinigungsroboter. | 1 | -| **Classifier** | Brancheneinstufung. **Strict Mode:** Prüft gegen `config/allowed_industries.json`. | 2 | +| **Classification Service** | **NEU (v0.7.0).** Zweistufige Logik:
1. Strict Industry Classification.
2. Metric Extraction Cascade (Web -> Wiki -> SerpAPI). | 1 | | **Marketing Engine** | Ersetzt `generate_marketing_text.py`. Nutzt neue `marketing_wissen_robotics.yaml`. | 3 | ### B. Frontend (`frontend/`) - React @@ -35,6 +34,7 @@ Das System wird in `company-explorer/` neu aufgebaut. Wir lösen Abhängigkeiten * **View 1: Der "Explorer":** DataGrid aller Firmen. Filterbar nach "Roboter-Potential" und Status. * **View 2: Der "Inspector":** Detailansicht einer Firma. Zeigt gefundene Signale ("Hat SPA Bereich"). Manuelle Korrektur-Möglichkeit. * **View 3: "List Matcher":** Upload einer Excel-Liste -> Anzeige von Duplikaten -> Button "Neue importieren". +* **View 4: "Settings":** Konfiguration von Branchen, Rollen und Robotik-Logik. ## 3. Umgang mit Shared Code (`helpers.py` & Co.) @@ -42,314 +42,115 @@ Wir kapseln das neue Projekt vollständig ab ("Fork & Clean"). * **Quelle:** `helpers.py` (Root) * **Ziel:** `company-explorer/backend/lib/core_utils.py` -* **Aktion:** Wir kopieren nur: - * OpenAI/Gemini Wrapper (Retry Logic). - * Text Cleaning (`clean_text`, `normalize_string`). - * URL Normalization. - -* **Quelle:** Andere Gemini Apps (`duckdns`, `gtm-architect`, `market-intel`) -* **Aktion:** Wir betrachten diese als Referenz. Nützliche Logik (z.B. die "Grit"-Prompts aus `market-intel`) wird explizit in die neuen Service-Module kopiert. +* **Aktion:** Wir kopieren nur relevante Teile und ergänzen sie (z.B. `safe_eval_math`, `run_serp_search`). ## 4. Datenstruktur (SQLite Schema) -### Tabelle `companies` (Stammdaten) +### Tabelle `companies` (Stammdaten & Analyse) * `id` (PK) * `name` (String) * `website` (String) * `crm_id` (String, nullable - Link zum D365) -* `industry_crm` (String - Die "erlaubte" Branche) +* `industry_crm` (String - Die "erlaubte" Branche aus Notion) * `city` (String) * `country` (String - Standard: "DE" oder aus Impressum) * `status` (Enum: NEW, IMPORTED, ENRICHED, QUALIFIED) +* **NEU (v0.7.0):** + * `calculated_metric_name` (String - z.B. "Anzahl Betten") + * `calculated_metric_value` (Float - z.B. 180) + * `calculated_metric_unit` (String - z.B. "Betten") + * `standardized_metric_value` (Float - z.B. 4500) + * `standardized_metric_unit` (String - z.B. "m²") + * `metric_source` (String - "website", "wikipedia", "serpapi") -### Tabelle `signals` (Roboter-Potential) -* `company_id` (FK) -* `signal_type` (z.B. "has_spa", "has_large_warehouse", "has_security_needs") -* `confidence` (Float) -* `proof_text` (Snippet von der Website) +### Tabelle `signals` (Deprecated) +* *Veraltet ab v0.7.0. Wird durch quantitative Metriken in `companies` ersetzt.* ### Tabelle `contacts` (Ansprechpartner) * `id` (PK) * `account_id` (FK -> companies.id) -* `gender` (Selection: "männlich", "weiblich") -* `title` (Text, z.B. "Dr.") -* `first_name` (Text) -* `last_name` (Text) -* `email` (Email) -* `job_title` (Text - Visitenkarte) -* `language` (Selection: "De", "En") -* `role` (Selection: "Operativer Entscheider", "Infrastruktur-Verantwortlicher", "Wirtschaftlicher Entscheider", "Innovations-Treiber") -* `status` (Selection: Siehe Prozess-Status) -* `is_primary` (Boolean - Nur einer pro Account) +* `gender`, `title`, `first_name`, `last_name`, `email` +* `job_title` (Visitenkarte) +* `role` (Standardisierte Rolle: "Operativer Entscheider", etc.) +* `status` (Marketing Status) -### Tabelle `industries` (Branchen-Fokus) +### Tabelle `industries` (Branchen-Fokus - Synced from Notion) * `id` (PK) -* `name` (String, Unique) -* `description` (Text - Abgrenzung/Definition) -* `is_focus` (Boolean) -* `primary_category_id` (FK -> robotics_categories.id) -* `metric_type` (String: `Unit_Count`, `Area_in`, `Area_out` - Art der Metrik zur Größenbestimmung) -* `min_requirement` (Float, nullable - Minimaler Schwellenwert für Signal-Relevanz) -* `whale_threshold` (Float, nullable - Schwellenwert, ab dem ein Account als "Whale" gilt) -* `proxy_factor` (Float, nullable - Multiplikator für die Standardisierungslogik) -* `scraper_keywords` (JSON-Array von Strings - Keywords für den Scraper zur Metrik-Erkennung) -* `standardization_logic` (String - Formel zur Standardisierung der Metrik, z.B. "wert * 25m²") +* `notion_id` (String, Unique) +* `name` (String - "Vertical" in Notion) +* `description` (Text - "Definition" in Notion) +* `metric_type` (String - "Metric Type") +* `min_requirement` (Float - "Min. Requirement") +* `whale_threshold` (Float - "Whale Threshold") +* `proxy_factor` (Float - "Proxy Factor") +* `scraper_search_term` (String - "Scraper Search Term") +* `scraper_keywords` (Text - "Scraper Keywords") +* `standardization_logic` (String - "Standardization Logic") ### Tabelle `job_role_mappings` (Rollen-Logik) * `id` (PK) -* `pattern` (String - Regex oder Text-Pattern für Jobtitles) -* `role` (String - Zielrolle im Verkaufsprozess) - -### Tabelle `duplicates_log` -* Speichert Ergebnisse von Listen-Abgleichen ("Upload X enthielt 20 bekannte Firmen"). - -## 5. Phasenplan Umsetzung - -1. **Housekeeping:** Archivierung des Legacy-Codes (`_legacy_gsheets_system`). -2. **Setup:** Init `company-explorer` (Backend + Frontend Skeleton). -3. **Foundation:** DB-Schema + "List Matcher" (Deduplizierung ist Prio A für Operations). -4. **Enrichment:** Implementierung des Scrapers + Signal Detector (Robotics). -5. **UI:** React Interface für die Daten. -6. **CRM-Features:** Contacts Management & Marketing Automation Status. - -## 6. Spezifikation: Contacts & Marketing Status (v0.5.0) - -*(Hinzugefügt am 15.01.2026)* - -**Konzept:** -Contacts stehen in 1:n Beziehung zu Accounts. Accounts können einen "Primary Contact" haben. - -**Datenfelder:** -* **Geschlecht:** Selection (männlich / weiblich) -* **Vorname:** Text -* **Nachname:** Text -* **E-Mail:** Type: E-Mail -* **Jobtitle:** Text (Titel auf der Visitenkarte) -* **Sprache:** Selection (De / En) - -**Rollen (Funktion im Verkaufsprozess):** -* Operativer Entscheider -* Infrastruktur-Verantwortlicher -* Wirtschaftlicher Entscheider -* Innovations-Treiber - -**Status (Marketing Automation):** -* *Manuell:* - * Soft Denied (freundliche Absage) - * Bounced (E-Mail invalide) - * Redirect (ist nicht verantwortlich) - * Interested (ist interessiert) - * Hard denied (nicht mehr kontaktieren) -* *Automatisch:* - * Init (Kontakt soll in die Automation hineinlaufen) - * 1st Step (Kontakt hat die erste Nachricht erhalten) - * 2nd Step (Kontakt hat die zweite Nachricht erhalten) - * Not replied (Kontakt hat die dritte Nachricht erhalten und nicht geantwortet) - -**Branchen-Fokus (Settings):** -* **Name:** Eindeutiger Name der Branche (CRM-Mapping). -* **Beschreibung:** Textuelle Abgrenzung, was zu dieser Branche gehört. -* **Is Focus:** Markiert Branchen, die prioritär bearbeitet werden. -* **Primäre Produktkategorie:** Zuordnung einer Robotics-Kategorie (z.B. Hotel -> Cleaning). - -**Job-Rollen Mapping (Settings):** -* **Pattern:** Text-Muster (z.B. "Technischer Leiter", "CTO"), das in Jobtitles gesucht wird. -* **Zugeordnete Rolle:** Die funktionale Interpretation (z.B. Operativer Entscheider). +* `pattern` (String - Regex für Jobtitles) +* `role` (String - Zielrolle) ## 7. Historie & Fixes (Jan 2026) -* **[UPGRADE] v0.6.1: Notion Single Source of Truth (Jan 20, 2026)** - * **Notion SSoT:** Umstellung der Branchenverwaltung (`Industries`) und Robotik-Kategorien auf Notion. Lokale Änderungen im Web-Interface sind für synchronisierte Felder deaktiviert, um die Datenintegrität zu wahren. - * **Dynamische Klassifizierung:** Der `ClassificationService` lädt die `allowed_industries` nun direkt aus der Datenbank, die wiederum via Sync-Skript aus Notion befüllt wird. - * **Erweiterte Datenmodelle:** Die Datenbank wurde um Felder wie `whale_threshold`, `min_requirement`, `scraper_keywords` und `industry_group` erweitert. - * **Sync-Automation:** Bereitstellung von `backend/scripts/sync_notion_industries.py` zur manuellen oder automatisierten Aktualisierung des lokalen Datenbestands. +* **[MAJOR] v0.7.0: Quantitative Potential Analysis (Jan 20, 2026)** + * **Zweistufige Analyse:** + 1. **Strict Classification:** Ordnet Firmen einer Notion-Branche zu (oder "Others"). + 2. **Metric Cascade:** Sucht gezielt nach der branchenspezifischen Metrik ("Scraper Search Term"). + * **Fallback-Kaskade:** Website -> Wikipedia -> SerpAPI (Google Search). + * **Standardisierung:** Berechnet vergleichbare Werte (z.B. m²) aus Rohdaten mit der `Standardization Logic`. + * **Datenbank:** Erweiterung der `companies`-Tabelle um Metrik-Felder, Deprecation der `signals`-Tabelle. -* **[UPGRADE] v0.5.1: Robustness, UI Fixes & Wikipedia Hardening** - * **[FIX] Critical DB Schema Mismatch (Jan 15, 2026):** - * **Problem:** Die Anwendung stürzte beim Zugriff auf Firmendetails mit `OperationalError: no such column: wiki_verified_empty` ab. - * **Ursache:** Eine nicht committete Code-Änderung hatte das DB-Modell in `database.py` erweitert, die physische Datenbank-Datei (`companies_v3_final.db`) war jedoch nicht migriert worden und dazu komplett leer/korrupt. - * **Lösung:** Um die Anwendung schnell wieder lauffähig zu bekommen, wurde in `config.py` der `DATABASE_URL` auf einen neuen Dateinamen (`companies_v3_fixed_2.db`) geändert. Dies zwang die App, beim Start eine neue, leere Datenbank mit dem korrekten, aktuellen Schema zu erstellen. Auf eine Datenmigration aus der alten, leeren Datei wurde verzichtet. - * **Standort-Fix (4B AG):** Die Backend-Logik wurde an entscheidenden Stellen (`run_analysis_task`, `override_impressum_url`) mit detailliertem Logging versehen und korrigiert, um sicherzustellen, dass `city` und `country` aus Impressums-Daten zuverlässig in die Haupt-Firmentabelle (`companies`) übernommen werden. Dies löst das Problem, dass Standorte im Inspector, aber nicht in der Übersicht angezeigt wurden. - * **Wikipedia "Verified Empty":** - * **Backend:** Implementierung einer `wiki_verified_empty` Flag in der Datenbank, um Firmen ohne Wikipedia-Eintrag dauerhaft zu markieren. Der `DiscoveryService` überspringt diese Einträge nun. - * **Frontend:** Ein neuer Button im Inspector erlaubt das manuelle Setzen dieses Status. - * **Robuste Wikipedia-Suche:** Die Namens-Normalisierungslogik aus dem Legacy-System wurde vollständig in den `DiscoveryService` reintegriert. Dies ermöglicht eine deutlich höhere Trefferquote bei Firmennamen mit unterschiedlichen Rechtsformen (z.B. "Therme Erding Service GmbH" -> "Therme Erding"). - * **UI-Fix (Sort & View):** Die Frontend-Tabellen (`CompanyTable`, `ContactsTable`) wurden grundlegend überarbeitet, um die zuvor fehlenden **Sortier-Dropdowns** und **Grid/List-View-Toggles** korrekt und zuverlässig anzuzeigen. Die Standard-Sortierung ist nun "Alphabetisch". +* **[UPGRADE] v0.6.1: Notion Sync Fixes** + * **Mapping:** Korrektur des Mappings für `Metric Type` und `Scraper Search Term` (Notion Select Fields). + * **Truncate-and-Reload:** Sync-Skript löscht alte Daten vor dem Import (für `industries`), behält aber `robotics_categories` bei (Upsert), um FK-Constraints zu schützen. + * **Frontend:** Korrektur der Einheiten-Anzeige ("Unit") im Settings-Dialog. -* **[UPGRADE] v0.5.0: Contacts, Settings & UI Overhaul** - * **Contacts Management:** - * Implementierung einer globalen Kontakt-Liste (`ContactsTable`) mit Such- und Filterfunktionen. - * Detail-Bearbeitung von Kontakten direkt im Inspector (Click-to-Edit). - * Bulk-Import-Funktion für Kontakte (CSV-basiert) mit automatischer Firmen-Erstellung und Dubletten-Prüfung (E-Mail). - * Erweiterte Felder: Akademischer Titel, differenzierte Rollen (Operativ, Strategisch etc.) und Marketing-Status. - * **UI Modernisierung:** - * **Light Mode:** Vollständige Unterstützung für Hell/Dunkel-Modus mit Toggle im Header. - * **Grid View:** Umstellung der Firmen-Liste auf eine kartenbasierte Ansicht (analog zu Kontakten). - * **Responsive Design:** Optimierung des Inspectors und der Navigation für mobile Endgeräte. - * **Erweiterte Settings:** - * Neue Konfigurations-Tabs für **Branchen** (Industries) und **Job-Rollen**. - * CRUD-Operationen für Branchen (inkl. Auto-Increment bei Namensgleichheit). - * **Bugfixes:** - * Korrektur des API-Pfads für manuelle Impressum-Updates. - * Stabilisierung der Datenbank-Logik bei Unique-Constraints. - * Optimierung der Anzeige von "Unknown, DE" in der Firmenliste (wird nun ausgeblendet, solange keine Stadt bekannt ist). +* **[UPGRADE] v0.6.0: Notion Single Source of Truth** + * Synchronisation von Branchen und Kategorien direkt aus Notion. -* **[UPGRADE] v0.4.0: Export & Manual Impressum** - * **JSON Export:** Erweiterung der Detailansicht um einen "Export JSON"-Button, der alle Unternehmensdaten (inkl. Anreicherungen und Signale) herunterlädt. - * **Zeitstempel:** Anzeige des Erstellungsdatums für jeden Anreicherungsdatensatz (Wikipedia, AI Dossier, Impressum) in der Detailansicht. - * **Manuelle Impressum-URL:** Möglichkeit zur manuellen Eingabe einer Impressum-URL in der Detailansicht, um die Extraktion von Firmendaten zu erzwingen. - * **Frontend-Fix:** Behebung eines Build-Fehlers (`Unexpected token`) in `Inspector.tsx` durch Entfernung eines duplizierten JSX-Blocks. +* **[UPGRADE] v0.5.1: Robustness** + * Logging, Wikipedia-Optimierung, UI-Fixes. -* **[UPGRADE] v2.6.2: Report Completeness & Edit Mode** - * **Edit Hard Facts:** Neue Funktion in Phase 1 ("Edit Raw Data") erlaubt die manuelle Korrektur der extrahierten technischen JSON-Daten. - * **Report-Update:** Phase 5 Prompt wurde angepasst, um explizit die Ergebnisse aus Phase 2 (ICPs & Data Proxies) im finalen Report aufzuführen. - * **Backend-Fix:** Korrektur eines Fehlers beim Speichern von JSON-Daten, der auftrat, wenn Datenbank-Inhalte als Strings vorlagen. +## 8. Eingesetzte Prompts (Account-Analyse v0.7.0) -* **[UPGRADE] v2.6.1: Stability & UI Improvements** - * **White Screen Fix:** Robuste Absicherung des Frontends gegen `undefined`-Werte beim Laden älterer Sitzungen (`optional chaining`). - * **Session Browser:** Komplettes Redesign der Sitzungsübersicht zu einer übersichtlichen Listenansicht mit Icons (Reinigung/Service/Transport/Security). - * **URL-Anzeige:** Die Quell-URL wird nun als dedizierter Link angezeigt und das Projekt automatisch basierend auf dem erkannten Produktnamen umbenannt. +### 8.1 Strict Industry Classification -* **[UPGRADE] v2.6: Rich Session Browser** - * **Neues UI:** Die textbasierte Liste für "Letzte Sitzungen" wurde durch eine dedizierte, kartenbasierte UI (`SessionBrowser.tsx`) ersetzt. - * **Angereicherte Daten:** Jede Sitzungskarte zeigt nun den Produktnamen, die Produktkategorie (mit Icon), eine Kurzbeschreibung und einen Thumbnail-Platzhalter an. - * **Backend-Anpassung:** Die Datenbankabfrage (`gtm_db_manager.py`) wurde erweitert, um diese Metadaten direkt aus der JSON-Spalte zu extrahieren und an das Frontend zu liefern. - * **Verbesserte UX:** Deutlich verbesserte Übersichtlichkeit und schnellere Identifikation von vergangenen Analysen. - -* **[UPGRADE] v2.5: Hard Fact Extraction** - * **Phase 1 Erweiterung:** Implementierung eines sekundären Extraktions-Schritts für "Hard Facts" (Specs). - * **Strukturiertes Daten-Schema:** Integration von `templates/json_struktur_roboplanet.txt`. - * **Normalisierung:** Automatische Standardisierung von Einheiten (Minuten, cm, kg, m²/h). - * **Frontend Update:** Neue UI-Komponente zur Anzeige der technischen Daten (Core Data, Layer, Extended Features). - * **Sidebar & Header:** Update auf "ROBOPLANET v2.5". - -* **[UPGRADE] v2.4:** - * Dokumentation der Kern-Engine (`helpers.py`) mit Dual SDK & Hybrid Image Generation. - * Aktualisierung der Architektur-Übersicht und Komponenten-Beschreibungen. - * Versionierung an den aktuellen Code-Stand (`v2.4.0`) angepasst. - -* **[UPGRADE] v2.3:** - * Einführung der Session History (Datenbank-basiert). - * Implementierung von Markdown-Cleaning (Stripping von Code-Blocks). - * Prompt-Optimierung für tabellarische Markdown-Ausgaben in Phase 5. - * Markdown-File Import Feature. - -## 8. Eingesetzte Prompts (Account-Analyse) - -Dieser Abschnitt dokumentiert die Prompts, die im Backend des **Company Explorers** zur automatisierten Analyse von Unternehmensdaten eingesetzt werden. - -### 8.1 Impressum Extraktion (aus `services/scraping.py`) - -Dient der Extraktion strukturierter Stammdaten aus dem rohen Text der Impressums-Seite. - -**Prompt:** +Ordnet das Unternehmen einer definierten Branche zu. ```python -prompt = f""" -Extract the official company details from this German 'Impressum' text. -Return JSON ONLY. Keys: 'legal_name', 'street', 'zip', 'city', 'country_code', 'email', 'phone', 'ceo_name', 'vat_id'. -'country_code' should be the two-letter ISO code (e.g., "DE", "CH", "AT"). -If a field is missing, use null. - -Text: -{raw_text} +prompt = r""" +Du bist ein präziser Branchen-Klassifizierer. +... +--- ZU VERWENDENDE BRANCHEN-DEFINITIONEN (STRIKT) --- +{industry_definitions_json} +... +Wähle EINE der folgenden Branchen... Wenn keine zutrifft, wähle "Others". """ ``` -**Variablen:** -* **`raw_text`**: Der bereinigte HTML-Text der gefundenen Impressums-URL (max. 10.000 Zeichen). +### 8.2 Metric Extraction ---- - -### 8.2 Robotics Potential Analyse (aus `services/classification.py`) - -Der Kern-Prompt zur Bewertung des Automatisierungspotenzials. Er fasst das Geschäftsmodell zusammen, prüft auf physische Infrastruktur und bewertet spezifische Robotik-Anwendungsfälle. - -**Prompt:** +Extrahiert den spezifischen Zahlenwert ("Scraper Search Term"). ```python -prompt = f""" -You are a Senior B2B Market Analyst for 'Roboplanet', a specialized robotics distributor. -Your task is to analyze the target company based on their website text and create a concise **Dossier**. - ---- TARGET COMPANY --- -Name: {company_name} -Website Content (Excerpt): -{website_text[:20000]} - ---- ALLOWED INDUSTRIES (STRICT) --- -You MUST assign the company to exactly ONE of these industries. If unsure, choose the closest match or "Sonstige". -{json.dumps(self.allowed_industries, ensure_ascii=False)} - ---- ANALYSIS PART 1: BUSINESS MODEL --- -1. Identify the core products/services. -2. Summarize in 2-3 German sentences: What do they do and for whom? (Target: "business_model") - ---- ANALYSIS PART 2: INFRASTRUCTURE & POTENTIAL (Chain of Thought) --- -1. **Infrastructure Scan:** Look for evidence of physical assets like *Factories, Large Warehouses, Production Lines, Campuses, Hospitals*. -2. **Provider vs. User Check:** - - Does the company USE this infrastructure (Potential Customer)? - - Or do they SELL products for it (Competitor/Partner)? - - *Example:* "Cleaning" -> Do they sell soap (Provider) or do they have a 50,000sqm factory (User)? -3. **Evidence Extraction:** Extract 1-2 key sentences from the text proving this infrastructure. (Target: "infrastructure_evidence") - ---- ANALYSIS PART 3: SCORING (0-100) --- -Based on the identified infrastructure, score the potential for these categories: - -{category_guidance} - ---- OUTPUT FORMAT (JSON ONLY) --- -{{ - "industry": "String (from list)", - "business_model": "2-3 sentences summary (German)", - "infrastructure_evidence": "1-2 key sentences proving physical assets (German)", - "potentials": {{ - "cleaning": {{ "score": 0-100, "reason": "Reasoning based on infrastructure." }},\ - "transport": {{ "score": 0-100, "reason": "Reasoning based on logistics volume." }},\ - "security": {{ "score": 0-100, "reason": "Reasoning based on perimeter/assets." }},\ - "service": {{ "score": 0-100, "reason": "Reasoning based on guest interaction." }}\ - }}\ -}} +prompt = r""" +Analysiere den folgenden Text... +--- KONTEXT --- +Branche: {industry_name} +Gesuchter Wert: '{search_term}' +... +Gib NUR ein JSON-Objekt zurück: +'raw_value', 'raw_unit', 'area_value' (falls explizit m² genannt). """ ``` -**Variablen:** -* **`company_name`**: Offizieller Name des Zielunternehmens zur korrekten Identifikation im Dossier. -* **`website_text`**: Der gescrapte Text der Hauptseite (max. 20.000 Zeichen), der als primäre Informationsquelle dient. -* **`allowed_industries`**: Eine JSON-Liste der gültigen Branchen. Diese wird dynamisch aus der Datenbanktabelle `industries` geladen (synchronisiert aus Notion). Erzwingt ein sauberes CRM-Mapping. -* **`category_guidance`**: Dynamisch generierte Definitionen und Scoring-Anweisungen für die Robotik-Kategorien. Ermöglicht die Anpassung der KI-Logik über Notion/Settings ohne Code-Änderung. +## 9. Notion Integration -## 9. Notion Integration (Single Source of Truth) - -Das System nutzt Notion als zentrales Steuerungselement für strategische Definitionen. - -### 9.1 Datenfluss -1. **Definition:** Branchen und Robotik-Kategorien werden in Notion gepflegt (Whale Thresholds, Keywords, Definitionen). -2. **Synchronisation:** Das Skript `sync_notion_industries.py` zieht die Daten via API und führt einen Upsert in die lokale SQLite-Datenbank aus. -3. **App-Nutzung:** Das Web-Interface zeigt diese Daten schreibgeschützt an. Der `ClassificationService` nutzt sie als "System-Anweisung" für das LLM. - -### 9.2 Technische Details -* **Notion Token:** Muss in `/app/notion_token.txt` (Container-Pfad) hinterlegt sein. -* **DB-Mapping:** Die Zuordnung erfolgt primär über die `notion_id`, sekundär über den Namen, um Dubletten bei der Migration zu vermeiden. - -## 10. Database Migration (v0.6.1 -> v0.6.2) - -Wenn die `industries`-Tabelle in einer bestehenden Datenbank aktualisiert werden muss (z.B. um neue Felder aus Notion zu unterstützen), darf die Datenbankdatei **nicht** gelöscht werden. Stattdessen muss das Migrations-Skript ausgeführt werden. - -**Prozess:** - -1. **Sicherstellen, dass die Zieldatenbank vorhanden ist:** Die `companies_v3_fixed_2.db` muss im `company-explorer`-Verzeichnis liegen. -2. **Migration ausführen:** Dieser Befehl fügt die fehlenden Spalten hinzu, ohne Daten zu löschen. - ```bash - docker exec -it company-explorer python3 backend/scripts/migrate_db.py - ``` -3. **Container neu starten:** Damit der Server das neue Schema erkennt. - ```bash - docker-compose restart company-explorer - ``` -4. **Notion-Sync ausführen:** Um die neuen Spalten mit Daten zu befüllen. - ```bash - docker exec -it company-explorer python3 backend/scripts/sync_notion_industries.py - ``` +Das System nutzt Notion als SSoT für `Industries` und `RoboticsCategories`. +Sync-Skript: `backend/scripts/sync_notion_industries.py`. +## 10. Database Migration +Bei Schema-Änderungen ohne Datenverlust: `backend/scripts/migrate_db.py`. \ No newline at end of file diff --git a/company-explorer/backend/config.py b/company-explorer/backend/config.py index 6d80fe3b..5eb2076d 100644 --- a/company-explorer/backend/config.py +++ b/company-explorer/backend/config.py @@ -10,7 +10,7 @@ try: class Settings(BaseSettings): # App Info APP_NAME: str = "Company Explorer" - VERSION: str = "0.6.1" + VERSION: str = "0.7.0" DEBUG: bool = True # Database (Store in App dir for simplicity) diff --git a/company-explorer/backend/database.py b/company-explorer/backend/database.py index cb70b898..a8c37a78 100644 --- a/company-explorer/backend/database.py +++ b/company-explorer/backend/database.py @@ -42,6 +42,14 @@ class Company(Base): last_wiki_search_at = Column(DateTime, nullable=True) last_classification_at = Column(DateTime, nullable=True) last_signal_check_at = Column(DateTime, nullable=True) + + # NEW: Quantitative Potential Metrics (v0.7.0) + calculated_metric_name = Column(String, nullable=True) # e.g., "Anzahl Betten" + calculated_metric_value = Column(Float, nullable=True) # e.g., 180.0 + calculated_metric_unit = Column(String, nullable=True) # e.g., "Betten" + standardized_metric_value = Column(Float, nullable=True) # e.g., 4500.0 + standardized_metric_unit = Column(String, nullable=True) # e.g., "m²" + metric_source = Column(String, nullable=True) # "website", "wikipedia", "serpapi" # Relationships signals = relationship("Signal", back_populates="company", cascade="all, delete-orphan") @@ -244,4 +252,4 @@ def get_db(): try: yield db finally: - db.close() \ No newline at end of file + db.close() diff --git a/company-explorer/backend/lib/core_utils.py b/company-explorer/backend/lib/core_utils.py index d55cda78..4e4759e2 100644 --- a/company-explorer/backend/lib/core_utils.py +++ b/company-explorer/backend/lib/core_utils.py @@ -6,8 +6,9 @@ import re import unicodedata from urllib.parse import urlparse from functools import wraps -from typing import Optional, Union, List +from typing import Optional, Union, List, Dict, Any from thefuzz import fuzz +import requests # Added for SerpAPI # Try new Google GenAI Lib (v1.0+) try: @@ -45,7 +46,6 @@ def retry_on_failure(max_retries: int = 3, delay: float = 2.0): return func(*args, **kwargs) except Exception as e: last_exception = e - # Don't retry on certain fatal errors (can be extended) if isinstance(e, ValueError) and "API Key" in str(e): raise e @@ -67,9 +67,7 @@ def clean_text(text: str) -> str: if not text: return "" text = str(text).strip() - # Normalize unicode characters text = unicodedata.normalize('NFKC', text) - # Remove control characters text = "".join(ch for ch in text if unicodedata.category(ch)[0] != "C") text = re.sub(r'\s+', ' ', text) return text @@ -83,18 +81,14 @@ def simple_normalize_url(url: str) -> str: if not url or url.lower() in ["k.a.", "nan", "none"]: return "k.A." - # Ensure protocol for urlparse if not url.startswith(('http://', 'https://')): url = 'http://' + url try: parsed = urlparse(url) domain = parsed.netloc or parsed.path - - # Remove www. if domain.startswith('www.'): domain = domain[4:] - return domain.lower() except Exception: return "k.A." @@ -109,8 +103,6 @@ def normalize_company_name(name: str) -> str: return "" name = name.lower() - - # Remove common legal forms (more comprehensive list) legal_forms = [ r'\bgmbh\b', r'\bag\b', r'\bkg\b', r'\bohg\b', r'\bug\b', r'\bltd\b', r'\bllc\b', r'\binc\b', r'\bcorp\b', r'\bco\b', r'\b& co\b', r'\be\.v\.\b', @@ -122,11 +114,8 @@ def normalize_company_name(name: str) -> str: for form in legal_forms: name = re.sub(form, '', name) - # Condense numbers: "11 88 0" -> "11880" - name = re.sub(r'(\d)\s+(\d)', r'\1\2', name) # Condense numbers separated by space - - # Remove special chars and extra spaces - name = re.sub(r'[^\w\s\d]', '', name) # Keep digits + name = re.sub(r'(\d)\s+(\d)', r'\1\2', name) + name = re.sub(r'[^\w\s\d]', '', name) name = re.sub(r'\s+', ' ', name).strip() return name @@ -144,20 +133,17 @@ def extract_numeric_value(raw_value: str, is_umsatz: bool = False) -> str: if raw_value in ["k.a.", "nan", "none"]: return "k.A." - # Simple multiplier handling multiplier = 1.0 if 'mrd' in raw_value or 'billion' in raw_value or 'bn' in raw_value: - multiplier = 1000.0 # Standardize to Millions for revenue, Billions for absolute numbers + multiplier = 1000.0 if not is_umsatz: multiplier = 1000000000.0 elif 'mio' in raw_value or 'million' in raw_value or 'mn' in raw_value: - multiplier = 1.0 # Already in Millions for revenue + multiplier = 1.0 if not is_umsatz: multiplier = 1000000.0 elif 'tsd' in raw_value or 'thousand' in raw_value: - multiplier = 0.001 # Thousands converted to millions for revenue + multiplier = 0.001 if not is_umsatz: multiplier = 1000.0 - # Extract number candidates - # Regex for "1.000,50" or "1,000.50" or "1000" matches = re.findall(r'(\d+[\.,]?\d*[\.,]?\d*)', raw_value) if not matches: return "k.A." @@ -165,41 +151,26 @@ def extract_numeric_value(raw_value: str, is_umsatz: bool = False) -> str: try: num_str = matches[0] - # Heuristic for German formatting (1.000,00) vs English (1,000.00) - # If it contains both, the last separator is likely the decimal if '.' in num_str and ',' in num_str: if num_str.rfind(',') > num_str.rfind('.'): - # German: 1.000,00 -> remove dots, replace comma with dot num_str = num_str.replace('.', '').replace(',', '.') else: - # English: 1,000.00 -> remove commas num_str = num_str.replace(',', '') elif '.' in num_str: - # Ambiguous: 1.005 could be 1005 or 1.005 - # Assumption: If it's employees (integer), and looks like "1.xxx", it's likely thousands parts = num_str.split('.') if len(parts) > 1 and len(parts[-1]) == 3 and not is_umsatz: - # Likely thousands separator for employees (e.g. 1.005) num_str = num_str.replace('.', '') elif is_umsatz and len(parts) > 1 and len(parts[-1]) == 3: - # For revenue, 375.6 vs 1.000 is tricky. - # But usually revenue in millions is small numbers with decimals (250.5). - # Large integers usually mean thousands. - # Let's keep dot as decimal for revenue by default unless we detect multiple dots if num_str.count('.') > 1: num_str = num_str.replace('.', '') elif ',' in num_str: - # German decimal: 1,5 -> 1.5 num_str = num_str.replace(',', '.') val = float(num_str) * multiplier - # Round appropriately if is_umsatz: - # Return in millions, e.g. "250.5" return f"{val:.2f}".rstrip('0').rstrip('.') else: - # Return integer for employees return str(int(val)) except ValueError: @@ -218,7 +189,6 @@ def clean_json_response(response_text: str) -> str: """ if not response_text: return "{}" - # Remove markdown code blocks cleaned = re.sub(r'^```json\s*', '', response_text, flags=re.MULTILINE) cleaned = re.sub(r'^```\s*', '', cleaned, flags=re.MULTILINE) cleaned = re.sub(r'\s*```$', '', cleaned, flags=re.MULTILINE) @@ -227,11 +197,10 @@ def clean_json_response(response_text: str) -> str: # ============================================================================== # 3. LLM WRAPPER (GEMINI) - # ============================================================================== @retry_on_failure(max_retries=3) -def call_gemini( +def call_gemini_flash( prompt: Union[str, List[str]], model_name: str = "gemini-2.0-flash", temperature: float = 0.3, @@ -296,4 +265,75 @@ def call_gemini( logger.error(f"Error with google-generativeai lib: {e}") raise e - raise ImportError("No Google GenAI library installed (neither google-genai nor google-generativeai).") \ No newline at end of file + raise ImportError("No Google GenAI library installed (neither google-genai nor google-generativeai).") + +# ============================================================================== +# 4. MATH UTILS +# ============================================================================== + +def safe_eval_math(expression: str) -> Optional[float]: + """ + Safely evaluates simple mathematical expressions. + Only allows numbers, basic operators (+, -, *, /), and parentheses. + Prevents arbitrary code execution. + """ + if not isinstance(expression, str) or not expression: + return None + + # Allowed characters: digits, ., +, -, *, /, (, ) + # Also allow 'wert' (for replacement) and spaces + allowed_pattern = re.compile(r"^[0-9.+\-*/()\s]+$") + + # Temporarily replace 'wert' for initial character check if still present + temp_expression = expression.lower().replace("wert", "1") # Replace wert with a dummy digit + + if not allowed_pattern.fullmatch(temp_expression): + logger.error(f"Math expression contains disallowed characters: {expression}") + return None + + try: + # Compile the expression for safety and performance. Use a restricted global/local dict. + code = compile(expression, '', 'eval') + # Restrict globals and locals to prevent arbitrary code execution + return float(eval(code, {"__builtins__": {}}, {})) + except Exception as e: + logger.error(f"Error evaluating math expression '{expression}': {e}", exc_info=True) + return None + +# ============================================================================== +# 5. SEARCH UTILS +# ============================================================================== + +@retry_on_failure(max_retries=2, delay=5.0) +def run_serp_search(query: str, num_results: int = 5) -> Optional[Dict[str, Any]]: + """ + Performs a Google search using SerpAPI and returns parsed results. + Requires SERP_API_KEY in settings. + """ + api_key = settings.SERP_API_KEY + if not api_key: + logger.error("SERP_API_KEY is missing in configuration. Cannot run SerpAPI search.") + return None + + url = "https://serpapi.com/search.json" + params = { + "api_key": api_key, + "engine": "google", + "q": query, + "num": num_results, # Number of organic results + "gl": "de", # Geo-targeting to Germany + "hl": "de" # Interface language to German + } + + try: + response = requests.get(url, params=params) + response.raise_for_status() # Raise an exception for HTTP errors + results = response.json() + logger.info("SerpAPI search for '%s' successful. Found %s organic results.", query, len(results.get("organic_results", []))) + return results + except requests.exceptions.RequestException as e: + logger.error(f"SerpAPI request failed for query '{query}': {e}", exc_info=True) + return None + except json.JSONDecodeError as e: + logger.error(f"Failed to parse SerpAPI JSON response for query '{query}': {e}", exc_info=True) + return None diff --git a/company-explorer/backend/services/classification.py b/company-explorer/backend/services/classification.py index 1771e055..a2942826 100644 --- a/company-explorer/backend/services/classification.py +++ b/company-explorer/backend/services/classification.py @@ -1,117 +1,334 @@ import json import logging -import os -from typing import Dict, Any, List -from ..lib.core_utils import call_gemini, clean_json_response -from ..config import settings -from ..database import SessionLocal, RoboticsCategory, Industry +import re +from typing import Optional, Dict, Any, List + +from sqlalchemy.orm import Session + +from backend.database import Company, Industry, RoboticsCategory, EnrichmentData, get_db +from backend.config import settings +from backend.lib.core_utils import call_gemini_flash, safe_eval_math, run_serp_search +from backend.services.scraping import scrape_website_content # Corrected import logger = logging.getLogger(__name__) class ClassificationService: - def __init__(self): - pass + def __init__(self, db: Session): + self.db = db + self.allowed_industries_notion: List[Industry] = self._load_industry_definitions() + self.robotics_categories: List[RoboticsCategory] = self._load_robotics_categories() + + # Pre-process allowed industries for LLM prompt + self.llm_industry_definitions = [ + {"name": ind.name, "description": ind.description} for ind in self.allowed_industries_notion + ] + + # Store for quick lookup + self.industry_lookup = {ind.name: ind for ind in self.allowed_industries_notion} + self.category_lookup = {cat.id: cat for cat in self.robotics_categories} - def _get_allowed_industries(self) -> List[str]: - """ - Fetches the allowed industries from the database (Settings > Industry Focus). - """ - db = SessionLocal() - try: - # Query all industries, order by name for consistency - industries = db.query(Industry.name).order_by(Industry.name).all() - # extract names from tuples (query returns list of tuples) - names = [i[0] for i in industries] - return names if names else ["Sonstige"] - except Exception as e: - logger.error(f"Failed to load allowed industries from DB: {e}") - return ["Sonstige"] - finally: - db.close() + def _load_industry_definitions(self) -> List[Industry]: + """Loads all industry definitions from the database.""" + industries = self.db.query(Industry).all() + if not industries: + logger.warning("No industry definitions found in DB. Classification might be limited.") + return industries - def _get_category_prompts(self) -> str: - """ - Fetches the latest category definitions from the database. - """ - db = SessionLocal() - try: - categories = db.query(RoboticsCategory).all() - if not categories: - return "Error: No categories defined." - - prompt_parts = [] - for cat in categories: - prompt_parts.append(f"* **{cat.name} ({cat.key}):**\n - Definition: {cat.description}\n - Scoring Guide: {cat.reasoning_guide}") - - return "\n".join(prompt_parts) - except Exception as e: - logger.error(f"Error fetching categories: {e}") - return "Error loading categories." - finally: - db.close() + def _load_robotics_categories(self) -> List[RoboticsCategory]: + """Loads all robotics categories from the database.""" + categories = self.db.query(RoboticsCategory).all() + if not categories: + logger.warning("No robotics categories found in DB. Potential scoring might be limited.") + return categories - def analyze_robotics_potential(self, company_name: str, website_text: str) -> Dict[str, Any]: - """ - Analyzes the company for robotics potential based on website content. - Returns strict JSON. - """ - if not website_text or len(website_text) < 100: - return {"error": "Insufficient text content"} - - category_guidance = self._get_category_prompts() - allowed_industries = self._get_allowed_industries() + def _get_wikipedia_content(self, company_id: int) -> Optional[str]: + """Fetches Wikipedia content from enrichment_data for a given company.""" + enrichment = self.db.query(EnrichmentData).filter( + EnrichmentData.company_id == company_id, + EnrichmentData.source_type == "wikipedia" + ).order_by(EnrichmentData.created_at.desc()).first() + + if enrichment and enrichment.content: + # Wikipedia content is stored as JSON with a 'text' key + wiki_data = enrichment.content + return wiki_data.get('text') + return None - prompt = f""" - You are a Senior B2B Market Analyst for 'Roboplanet', a specialized robotics distributor. - Your task is to analyze the target company based on their website text and create a concise **Dossier**. + def _run_llm_classification_prompt(self, website_text: str, company_name: str) -> Optional[str]: + """ + Uses LLM to classify the company into one of the predefined industries. + Returns the industry name (string) or "Others". + """ + prompt = r""" + Du bist ein präziser Branchen-Klassifizierer für Unternehmen. + Deine Aufgabe ist es, das vorliegende Unternehmen basierend auf seinem Website-Inhalt + einer der untenstehenden Branchen zuzuordnen. - --- TARGET COMPANY --- + --- UNTERNEHMEN --- Name: {company_name} - Website Content (Excerpt): - {website_text[:20000]} + Website-Inhalt (Auszug): + {website_text_excerpt} + + --- ZU VERWENDENDE BRANCHEN-DEFINITIONEN (STRIKT) --- + Wähle EINE der folgenden Branchen. Jede Branche hat eine Definition. + {industry_definitions_json} + + --- AUFGABE --- + Analysiere den Website-Inhalt. Wähle die Branchen-Definition, die am besten zum Unternehmen passt. + Wenn keine der Definitionen zutrifft oder du unsicher bist, wähle "Others". + Gib NUR den Namen der zugeordneten Branche zurück, als reinen String, nichts anderes. + + Beispiel Output: Hotellerie + Beispiel Output: Automotive - Dealer + Beispiel Output: Others + """.format( + company_name=company_name, + website_text_excerpt=website_text[:10000], # Limit text to avoid token limits + industry_definitions_json=json.dumps(self.llm_industry_definitions, ensure_ascii=False) + ) - --- ALLOWED INDUSTRIES (STRICT) --- - You MUST assign the company to exactly ONE of these industries. If unsure, choose the closest match or "Sonstige". - {json.dumps(allowed_industries, ensure_ascii=False)} + try: + response = call_gemini_flash(prompt, temperature=0.1, json_mode=False) # Low temp for strict classification + classified_industry = response.strip() + if classified_industry in [ind.name for ind in self.allowed_industries_notion] + ["Others"]: + return classified_industry + logger.warning(f"LLM classified industry '{classified_industry}' not in allowed list. Defaulting to Others.") + return "Others" + except Exception as e: + logger.error(f"LLM classification failed for {company_name}: {e}", exc_info=True) + return None - --- ANALYSIS PART 1: BUSINESS MODEL --- - 1. Identify the core products/services. - 2. Summarize in 2-3 German sentences: What do they do and for whom? (Target: "business_model") - - --- ANALYSIS PART 2: INFRASTRUCTURE & POTENTIAL (Chain of Thought) --- - 1. **Infrastructure Scan:** Look for evidence of physical assets like *Factories, Large Warehouses, Production Lines, Campuses, Hospitals*. - 2. **Provider vs. User Check:** - - Does the company USE this infrastructure (Potential Customer)? - - Or do they SELL products for it (Competitor/Partner)? - - *Example:* "Cleaning" -> Do they sell soap (Provider) or do they have a 50,000sqm factory (User)? - 3. **Evidence Extraction:** Extract 1-2 key sentences from the text proving this infrastructure. (Target: "infrastructure_evidence") - - --- ANALYSIS PART 3: SCORING (0-100) --- - Based on the identified infrastructure, score the potential for these categories: - - {category_guidance} - - --- OUTPUT FORMAT (JSON ONLY) --- - {{ - "industry": "String (from list)", - "business_model": "2-3 sentences summary (German)", - "infrastructure_evidence": "1-2 key sentences proving physical assets (German)", - "potentials": {{ - "cleaning": {{ "score": 0-100, "reason": "Reasoning based on infrastructure." }}, - "transport": {{ "score": 0-100, "reason": "Reasoning based on logistics volume." }}, - "security": {{ "score": 0-100, "reason": "Reasoning based on perimeter/assets." }}, - "service": {{ "score": 0-100, "reason": "Reasoning based on guest interaction." }} - }} - }} + def _run_llm_metric_extraction_prompt(self, text_content: str, search_term: str, industry_name: str) -> Optional[Dict[str, Any]]: """ + Uses LLM to extract the specific metric value from text. + Returns a dict with 'raw_value', 'raw_unit', 'standardized_value' (if found), 'metric_name'. + """ + # Attempt to extract both the raw unit count and a potential area if explicitly mentioned + prompt = r""" + Du bist ein Datenextraktions-Spezialist. + Analysiere den folgenden Text, um spezifische Metrik-Informationen zu extrahieren. + + --- KONTEXT --- + Unternehmen ist in der Branche: {industry_name} + Gesuchter Wert (Rohdaten): '{search_term}' + + --- TEXT --- + {text_content_excerpt} + + --- AUFGABE --- + 1. Finde den numerischen Wert für '{search_term}'. + 2. Versuche auch, eine explizit genannte Gesamtfläche in Quadratmetern (m²) zu finden, falls relevant und vorhanden. + + Gib NUR ein JSON-Objekt zurück mit den Schlüsseln: + 'raw_value': Der gefundene numerische Wert für '{search_term}' (als Zahl). null, falls nicht gefunden. + 'raw_unit': Die Einheit des raw_value (z.B. "Betten", "Stellplätze"). null, falls nicht gefunden. + 'area_value': Ein gefundener numerischer Wert für eine Gesamtfläche in m² (als Zahl). null, falls nicht gefunden. + 'metric_name': Der Name der Metrik, nach der gesucht wurde (also '{search_term}'). + + Beispiel Output (wenn 180 Betten und 4500m² Fläche gefunden): + {{"raw_value": 180, "raw_unit": "Betten", "area_value": 4500, "metric_name": "{search_term}"}} + + Beispiel Output (wenn nur 180 Betten gefunden): + {{"raw_value": 180, "raw_unit": "Betten", "area_value": null, "metric_name": "{search_term}"}} + + Beispiel Output (wenn nichts gefunden): + {{"raw_value": null, "raw_unit": null, "area_value": null, "metric_name": "{search_term}"}} + """.format( + industry_name=industry_name, + search_term=search_term, + text_content_excerpt=text_content[:15000] # Adjust as needed for token limits + ) try: - response_text = call_gemini( - prompt=prompt, - json_mode=True, - temperature=0.1 # Very low temp for analytical reasoning - ) - return json.loads(clean_json_response(response_text)) + response = call_gemini_flash(prompt, temperature=0.05, json_mode=True) # Very low temp for extraction + result = json.loads(response) + return result except Exception as e: - logger.error(f"Classification failed: {e}") - return {"error": str(e)} + logger.error(f"LLM metric extraction failed for '{search_term}' in '{industry_name}': {e}", exc_info=True) + return None + + def _parse_standardization_logic(self, formula: str, raw_value: float) -> Optional[float]: + """ + Safely parses and executes a simple mathematical formula for standardization. + Supports basic arithmetic (+, -, *, /) and integer/float values. + """ + if not formula or not raw_value: + return None + + # Replace 'wert' or 'value' with the actual raw_value + formula_cleaned = formula.replace("wert", str(raw_value)).replace("Value", str(raw_value)).replace("VALUE", str(raw_value)) + + try: + # Use safe_eval_math from core_utils to prevent arbitrary code execution + return safe_eval_math(formula_cleaned) + except Exception as e: + logger.error(f"Error evaluating standardization logic '{formula}' with value {raw_value}: {e}", exc_info=True) + return None + + def _extract_and_calculate_metric_cascade( + self, + company: Company, + industry_name: str, + search_term: str, + standardization_logic: Optional[str], + standardized_unit: Optional[str] + ) -> Dict[str, Any]: + """ + Orchestrates the 3-stage (Website -> Wikipedia -> SerpAPI) metric extraction. + """ + results = { + "calculated_metric_name": search_term, + "calculated_metric_value": None, + "calculated_metric_unit": None, + "standardized_metric_value": None, + "standardized_metric_unit": standardized_unit, + "metric_source": None + } + + # --- STAGE 1: Website Analysis --- + logger.info(f"Stage 1: Analyzing website for '{search_term}' for {company.name}") + website_content = scrape_website_content(company.website) + if website_content: + llm_result = self._run_llm_metric_extraction_prompt(website_content, search_term, industry_name) + if llm_result and (llm_result.get("raw_value") is not None or llm_result.get("area_value") is not None): + results["calculated_metric_value"] = llm_result.get("raw_value") + results["calculated_metric_unit"] = llm_result.get("raw_unit") + results["metric_source"] = "website" + + if llm_result.get("area_value") is not None: + # Prioritize directly found standardized area + results["standardized_metric_value"] = llm_result.get("area_value") + logger.info(f"Direct area value found on website for {company.name}: {llm_result.get('area_value')} m²") + elif llm_result.get("raw_value") is not None and standardization_logic: + # Calculate if only raw value found + results["standardized_metric_value"] = self._parse_standardization_logic( + standardization_logic, llm_result["raw_value"] + ) + return results + + # --- STAGE 2: Wikipedia Analysis --- + logger.info(f"Stage 2: Analyzing Wikipedia for '{search_term}' for {company.name}") + wikipedia_content = self._get_wikipedia_content(company.id) + if wikipedia_content: + llm_result = self._run_llm_metric_extraction_prompt(wikipedia_content, search_term, industry_name) + if llm_result and (llm_result.get("raw_value") is not None or llm_result.get("area_value") is not None): + results["calculated_metric_value"] = llm_result.get("raw_value") + results["calculated_metric_unit"] = llm_result.get("raw_unit") + results["metric_source"] = "wikipedia" + + if llm_result.get("area_value") is not None: + results["standardized_metric_value"] = llm_result.get("area_value") + logger.info(f"Direct area value found on Wikipedia for {company.name}: {llm_result.get('area_value')} m²") + elif llm_result.get("raw_value") is not None and standardization_logic: + results["standardized_metric_value"] = self._parse_standardization_logic( + standardization_logic, llm_result["raw_value"] + ) + return results + + # --- STAGE 3: SerpAPI (Google Search) --- + logger.info(f"Stage 3: Running SerpAPI search for '{search_term}' for {company.name}") + search_query = f"{company.name} {search_term} {industry_name}" # Example: "Hotel Moxy Würzburg Anzahl Betten Hotellerie" + serp_results = run_serp_search(search_query) # This returns a dictionary of search results + + if serp_results and serp_results.get("organic_results"): + # Concatenate snippets from organic results + snippets = " ".join([res.get("snippet", "") for res in serp_results["organic_results"]]) + if snippets: + llm_result = self._run_llm_metric_extraction_prompt(snippets, search_term, industry_name) + if llm_result and (llm_result.get("raw_value") is not None or llm_result.get("area_value") is not None): + results["calculated_metric_value"] = llm_result.get("raw_value") + results["calculated_metric_unit"] = llm_result.get("raw_unit") + results["metric_source"] = "serpapi" + + if llm_result.get("area_value") is not None: + results["standardized_metric_value"] = llm_result.get("area_value") + logger.info(f"Direct area value found via SerpAPI for {company.name}: {llm_result.get('area_value')} m²") + elif llm_result.get("raw_value") is not None and standardization_logic: + results["standardized_metric_value"] = self._parse_standardization_logic( + standardization_logic, llm_result["raw_value"] + ) + return results + + logger.info(f"Could not extract metric for '{search_term}' from any source for {company.name}.") + return results # Return results with None values + + def classify_company_potential(self, company: Company) -> Company: + """ + Main method to classify industry and calculate potential metric for a company. + """ + logger.info(f"Starting classification for Company ID: {company.id}, Name: {company.name}") + + # --- STEP 1: Strict Industry Classification --- + website_content_for_classification = scrape_website_content(company.website) + if not website_content_for_classification: + logger.warning(f"No website content found for {company.name}. Skipping industry classification.") + company.industry_ai = "Others" # Default if no content + else: + classified_industry_name = self._run_llm_classification_prompt(website_content_for_classification, company.name) + if classified_industry_name: + company.industry_ai = classified_industry_name + logger.info(f"Classified {company.name} into industry: {classified_industry_name}") + else: + company.industry_ai = "Others" + logger.warning(f"Failed to classify industry for {company.name}. Setting to 'Others'.") + + self.db.add(company) # Update industry_ai + self.db.commit() + self.db.refresh(company) + + # --- STEP 2: Metric Extraction & Standardization (if not 'Others') --- + if company.industry_ai == "Others" or company.industry_ai is None: + logger.info(f"Company {company.name} classified as 'Others'. Skipping metric extraction.") + return company + + industry_definition = self.industry_lookup.get(company.industry_ai) + if not industry_definition: + logger.error(f"Industry definition for '{company.industry_ai}' not found in lookup. Skipping metric extraction.") + return company + + if not industry_definition.scraper_search_term: + logger.info(f"Industry '{company.industry_ai}' has no 'Scraper Search Term'. Skipping metric extraction.") + return company + + # Determine standardized unit from standardization_logic if possible + standardized_unit = "Einheiten" # Default + if industry_definition.standardization_logic: + # Example: "wert * 25m² (Fläche pro Zimmer)" -> extract "m²" + match = re.search(r'(\w+)$', industry_definition.standardization_logic.replace(' ', '')) + if match: + standardized_unit = match.group(1).replace('(', '').replace(')', '') # Extract unit like "m²" + + metric_results = self._extract_and_calculate_metric_cascade( + company, + company.industry_ai, + industry_definition.scraper_search_term, + industry_definition.standardization_logic, + standardized_unit # Pass the derived unit + ) + + # Update company object with results + company.calculated_metric_name = metric_results["calculated_metric_name"] + company.calculated_metric_value = metric_results["calculated_metric_value"] + company.calculated_metric_unit = metric_results["calculated_metric_unit"] + company.standardized_metric_value = metric_results["standardized_metric_value"] + company.standardized_metric_unit = metric_results["standardized_metric_unit"] + company.metric_source = metric_results["metric_source"] + company.last_classification_at = datetime.utcnow() # Update timestamp + + self.db.add(company) + self.db.commit() + self.db.refresh(company) # Refresh to get updated values + + logger.info(f"Classification and metric extraction completed for {company.name}.") + return company + +# --- HELPER FOR SAFE MATH EVALUATION (Moved from core_utils.py or assumed to be there) --- +# Assuming safe_eval_math is available via backend.lib.core_utils.safe_eval_math +# Example implementation if not: +# def safe_eval_math(expression: str) -> float: +# # Implement a safe parser/evaluator for simple math expressions +# # For now, a very basic eval might be used, but in production, this needs to be locked down +# allowed_chars = "0123456789.+-*/ " +# if not all(c in allowed_chars for c in expression): +# raise ValueError("Expression contains disallowed characters.") +# return eval(expression) \ No newline at end of file diff --git a/company-explorer/backend/services/scraping.py b/company-explorer/backend/services/scraping.py index 43e87e20..df54ae90 100644 --- a/company-explorer/backend/services/scraping.py +++ b/company-explorer/backend/services/scraping.py @@ -6,7 +6,7 @@ import json from urllib.parse import urljoin, urlparse from bs4 import BeautifulSoup from typing import Optional, Dict -from ..lib.core_utils import clean_text, retry_on_failure, call_gemini, clean_json_response +from ..lib.core_utils import clean_text, retry_on_failure, call_gemini_flash, clean_json_response logger = logging.getLogger(__name__)