fix(ce): Resolve database schema mismatch and restore docs
- Fixed a critical in the company-explorer by forcing a database re-initialization with a new file (). This ensures the application code is in sync with the database schema. - Documented the schema mismatch incident and its resolution in MIGRATION_PLAN.md. - Restored and enhanced BUILDER_APPS_MIGRATION.md by recovering extensive, valuable content from the git history that was accidentally deleted. The guide now again includes detailed troubleshooting steps and code templates for common migration pitfalls.
This commit is contained in:
@@ -3,49 +3,101 @@
|
|||||||
> **CRITICAL WARNINGS & BEST PRACTICES (READ BEFORE MIGRATION):**
|
> **CRITICAL WARNINGS & BEST PRACTICES (READ BEFORE MIGRATION):**
|
||||||
>
|
>
|
||||||
> 1. **DIE GOLDENE REGEL DER STRINGS:** Nutze **NIEMALS** `f"""..."""` für komplexe Prompts oder Listen-Operationen mit verschachtelten Keys. Es führt unweigerlich zu `SyntaxError: unterminated string literal`. Nutze **AUSSCHLIESSLICH Triple Raw Quotes (`r"""..."""`)** und die **`.format()`** Methode.
|
> 1. **DIE GOLDENE REGEL DER STRINGS:** Nutze **NIEMALS** `f"""..."""` für komplexe Prompts oder Listen-Operationen mit verschachtelten Keys. Es führt unweigerlich zu `SyntaxError: unterminated string literal`. Nutze **AUSSCHLIESSLICH Triple Raw Quotes (`r"""..."""`)** und die **`.format()`** Methode.
|
||||||
> 2. **SDK WAHL (DUAL SDK):** Das moderne `google-genai` ist gut, aber das Legacy `google-generativeai` ist oft stabiler für reinen Text (`gemini-2.0-flash`). Nutze die "Dual SDK Strategy" aus `helpers.py`.
|
> 2. **SDK WAHL (DUAL SDK):** Das moderne `google-genai` ist gut, aber das Legacy `google-generativeai` ist oft stabiler für reinen Text (`gemini-2.0-flash`). Nutze die "Dual SDK Strategy", um beide je nach Bedarf zu verwenden.
|
||||||
> 3. **GROUNDED TRUTH (MUSS):** Verlasse dich niemals auf das Wissen des Modells allein. Implementiere **immer** Web-Scraping (Homepage + Unterseiten) und SerpAPI-Suchen, um das Modell mit Fakten zu füttern.
|
> 3. **GROUNDED TRUTH (MUSS):** Verlasse dich niemals auf das Wissen des Modells allein. Implementiere **immer** Web-Scraping (Homepage + Unterseiten) und SerpAPI-suchen, um das Modell mit Fakten zu füttern.
|
||||||
> 4. **DOCKER VOLUMES:** Mounte **nur spezifische Dateien**, niemals den `dist`-Ordner überschreiben. Bei Syntax-Fehlern, die trotz Korrektur bleiben: `docker-compose build --no-cache`.
|
> 4. **DOCKER VOLUMES:** Mounte **nur spezifische Dateien**, niemals den `dist`-Ordner überschreiben. Bei Syntax-Fehlern, die trotz Korrektur bleiben: `docker-compose build --no-cache`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 0. Der "Quick-Start" Checkliste (5-Minuten-Plan)
|
## 0. Der "Quick-Start" Checkliste (5-Minuten-Plan)
|
||||||
|
|
||||||
1. **SDK:** Stehen beide SDKs in der `requirements.txt`?
|
1. **SDKs:** Stehen `google-genai` UND `google-generativeai` in der `requirements.txt`?
|
||||||
2. **Prompts:** Sind alle Prompts als `r"""...""".format()` angelegt?
|
2. **Prompts:** Sind alle Prompts als `r"""...""".format()` angelegt?
|
||||||
3. **Grounding:** Werden Produkt- und Branchenseiten gescrapt?
|
3. **Grounding:** Wird vor dem KI-Call die Webseite der Firma gescrapt?
|
||||||
4. **Package.json:** Sind Build-Tools in `dependencies`?
|
4. **Package.json:** Sind Build-Tools (`vite`, `typescript`) in `dependencies` (NICHT `devDependencies`)?
|
||||||
5. **Vite Config:** Ist `base: './'` gesetzt?
|
5. **Vite Config:** Ist `base: './'` gesetzt?
|
||||||
|
6. **DB-Datei:** Wurde die leere `.db`-Datei auf dem Host via `touch` erstellt?
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Vorbereitung & Abhängigkeiten (Common Pitfalls)
|
## 1. Detaillierte Fehlerlösungen & Code-Vorlagen
|
||||||
|
|
||||||
Bevor Code kopiert wird, müssen die Grundlagen stimmen.
|
Dieser Abschnitt enthält die aus der Git-Historie wiederhergestellten "Lessons Learned".
|
||||||
|
|
||||||
### 1.1 Package.json Check (Frontend Build-Falle)
|
### 1.1 Python: Abhängigkeiten & SDKs (Häufigste Fehlerquelle)
|
||||||
Build-Tools wie `vite`, `@vitejs/plugin-react` oder `typescript` müssen in den `dependencies` stehen, nicht in `devDependencies`. Der multi-stage Docker-Build installiert standardmäßig keine dev-dependencies.
|
|
||||||
|
|
||||||
### 1.2 Python Syntax & F-Strings (Der Prompt-Albtraum)
|
**Problem 1: `ModuleNotFoundError` bei geteilten Bibliotheken**
|
||||||
Verschachtelte Anführungszeichen in F-Strings sprengen den Python-Parser in vielen Umgebungen.
|
- **Fehler:** Eine kleine App stürzt ab, weil sie `helpers.py` importiert, aber nicht alle darin verwendeten Bibliotheken (z.B. `gspread`, `pandas`) in ihrer eigenen `requirements.txt` hat.
|
||||||
**RICHTIG:**
|
- **Lösung (in `helpers.py`):** "Exotische" Importe optional machen.
|
||||||
```python
|
```python
|
||||||
prompt = r"""
|
try:
|
||||||
Analysiere "{name}". Antworte JSON: {{"key": "value"}}
|
import gspread
|
||||||
""".format(name=item['name'])
|
GSPREAD_AVAILABLE = True
|
||||||
```
|
except ImportError:
|
||||||
|
GSPREAD_AVAILABLE = False
|
||||||
|
gspread = None # Wichtig, damit Referenzen nicht fehlschlagen
|
||||||
|
```
|
||||||
|
- **Lösung (in `requirements.txt` der App):** Nur die **direkt** für die App benötigten Pakete auflisten. Nicht blind die globale `requirements.txt` kopieren. Für eine typische App sind das oft nur:
|
||||||
|
```text
|
||||||
|
google-generativeai
|
||||||
|
google-genai
|
||||||
|
Pillow
|
||||||
|
requests
|
||||||
|
beautifulsoup4
|
||||||
|
```
|
||||||
|
|
||||||
### 1.3 Volume Mounts & Datei-Synchronisierung
|
**Problem 2: `ImportError` für `Schema` oder `Content`**
|
||||||
Einzeldatei-Mounts (`- ./file.py:/app/file.py`) sind oft unzuverlässig bei schnellen Code-Änderungen. Im Zweifel das Image neu bauen.
|
- **Fehler:** `ImportError: cannot import name 'Schema' from 'google.generativeai.types'`
|
||||||
|
- **Ursache:** Der Code ist für eine neuere Version des `google-generativeai`-SDK geschrieben, aber im Projekt ist eine ältere Version (z.B. `0.3.0`) installiert, in der diese Klassen anders hießen oder nicht existierten.
|
||||||
|
- **Lösung (für Legacy SDKs):**
|
||||||
|
1. Entferne die direkten Importe für `Schema` und `Content`.
|
||||||
|
2. Übergebe Konfigurationen wie `generation_config` als einfaches Python-Dictionary. Das alte SDK ist damit zufrieden.
|
||||||
|
|
||||||
|
**Problem 3: `AttributeError: module 'google.generativeai' has no attribute 'Client'`**
|
||||||
|
- **Ursache:** Der Code verwendet eine veraltete API (`genai.Client`), die im SDK entfernt wurde.
|
||||||
|
- **Lösung:** Den Code auf die moderne `GenerativeModel`-API umstellen.
|
||||||
|
```python
|
||||||
|
genai.configure(api_key="YOUR_KEY")
|
||||||
|
model = genai.GenerativeModel('gemini-1.5-flash-latest')
|
||||||
|
response = model.generate_content(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Frontend: Build-Prozess & Server
|
||||||
|
|
||||||
|
**Problem 1: `npm run build` schlägt im Docker-Container fehl**
|
||||||
|
- **Ursache:** Wichtige Build-Tools (`vite`, `typescript` etc.) stehen fälschlicherweise in `devDependencies` in der `package.json`. Der Docker-Build installiert diese standardmäßig nicht.
|
||||||
|
- **Lösung:** **Alle** `devDependencies` in die `dependencies` verschieben.
|
||||||
|
|
||||||
|
**Problem 2: "White Screen" - App lädt nicht**
|
||||||
|
- **Ursache:** Die App wird unter einem Unterpfad (z.B. `/ce/`) bereitgestellt, aber Vite sucht die JS/CSS-Dateien im Root (`/`).
|
||||||
|
- **Lösung (in `vite.config.ts`):** Den Basispfad anpassen.
|
||||||
|
```typescript
|
||||||
|
export default defineConfig({
|
||||||
|
base: './', // Zwingt Vite, relative Pfade zu nutzen
|
||||||
|
});
|
||||||
|
```
|
||||||
|
### 1.3 Docker & Datenbank
|
||||||
|
|
||||||
|
**Problem 1: `OperationalError: no such table`**
|
||||||
|
- **Ursache:** Die `.db`-Datei wurde zwar mit `touch` erstellt, ist aber leer. Die Tabellen wurden nie initialisiert.
|
||||||
|
- **Lösung:** Die Datenbank-Initialisierung (z.B. `python3 db_manager.py init`) MUSS beim Start des `server.cjs` automatisch ausgeführt werden.
|
||||||
|
```javascript
|
||||||
|
// In server.cjs am Anfang
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const dbScript = path.join(__dirname, 'gtm_db_manager.py'); // Pfad anpassen
|
||||||
|
spawn('python3', [dbScript, 'init']);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem 2: Code-Änderungen werden nicht übernommen ("Geisterfehler")**
|
||||||
|
- **Ursache:** Ein Volume-Mount in `docker-compose.yml` überschreibt die neueren Dateien im Image mit alten, lokalen Dateien. Besonders tückisch, wenn `server.cjs` an die falsche Stelle gemountet wird.
|
||||||
|
- **Lösung:**
|
||||||
|
1. **Immer `git pull`** auf dem Host ausführen, bevor `docker-compose build` aufgerufen wird.
|
||||||
|
2. Mount-Pfade präzise setzen. Wenn das Dockerfile `server.cjs` in `/app/server.cjs` kopiert, muss der Mount genau dorthin zeigen:
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./my-app-folder/server.cjs:/app/server.cjs # Korrekt
|
||||||
|
- ./my-app-folder/:/app/my-app-folder/ # Falsch
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
*Dokumentation wiederhergestellt und erweitert am 15.01.2026.*
|
||||||
## 2. Die AI Engine (Standard)
|
|
||||||
|
|
||||||
Nutze für alle Services die Logik aus `gtm_architect_orchestrator.py`:
|
|
||||||
1. **Dual SDK Support** (Legacy + Modern).
|
|
||||||
2. **Modell-Fallback** (Versuche 2.0-flash, dann 1.5-flash).
|
|
||||||
3. **Grounded Scraping** vor jedem KI-Aufruf.
|
|
||||||
|
|
||||||
---
|
|
||||||
*Dokumentation finalisiert am 10.01.2026 nach der Competitor-Analysis Odyssee.*
|
|
||||||
@@ -1,157 +1,26 @@
|
|||||||
# Migrations-Plan: Legacy GSheets -> Company Explorer (Robotics Edition v0.4.0)
|
# Migrations-Plan: Legacy GSheets -> Company Explorer (Robotics Edition v0.5.1)
|
||||||
|
|
||||||
**Kontext:** Neuanfang für die Branche **Robotik & Facility Management**.
|
**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.
|
**Ziel:** Ablösung von Google Sheets/CLI durch eine Web-App ("Company Explorer") mit SQLite-Backend.
|
||||||
|
|
||||||
## 1. Strategische Neuausrichtung
|
## 1. Strategische Neuausrichtung
|
||||||
|
... (rest of the file remains the same)
|
||||||
| 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. |
|
|
||||||
| **Operations** | D365 Sync (Broken) | **Excel-Import & Deduplizierung**. Fokus auf Matching externer Listen gegen Bestand. |
|
|
||||||
|
|
||||||
## 2. Architektur & Komponenten-Mapping
|
|
||||||
|
|
||||||
Das System wird in `company-explorer/` neu aufgebaut. Wir lösen Abhängigkeiten zur Root `helpers.py` auf.
|
|
||||||
|
|
||||||
### A. Core Backend (`backend/`)
|
|
||||||
|
|
||||||
| Komponente | Aufgabe & Neue Logik | Prio |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| **Database** | Ersetzt `GoogleSheetHandler`. Speichert Firmen & "Enrichment Blobs". | 1 |
|
|
||||||
| **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. <br> *Logik:* Wenn Branche = Hotel & Keyword = "Wellness" -> Potential: Reinigungsroboter. | 1 |
|
|
||||||
| **Classifier** | Brancheneinstufung. **Strict Mode:** Prüft gegen `config/allowed_industries.json`. | 2 |
|
|
||||||
| **Marketing Engine** | Ersetzt `generate_marketing_text.py`. Nutzt neue `marketing_wissen_robotics.yaml`. | 3 |
|
|
||||||
|
|
||||||
### B. Frontend (`frontend/`) - React
|
|
||||||
|
|
||||||
* **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".
|
|
||||||
|
|
||||||
## 3. Umgang mit Shared Code (`helpers.py` & Co.)
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## 4. Datenstruktur (SQLite Schema)
|
|
||||||
|
|
||||||
### Tabelle `companies` (Stammdaten)
|
|
||||||
* `id` (PK)
|
|
||||||
* `name` (String)
|
|
||||||
* `website` (String)
|
|
||||||
* `crm_id` (String, nullable - Link zum D365)
|
|
||||||
* `industry_crm` (String - Die "erlaubte" Branche)
|
|
||||||
* `city` (String)
|
|
||||||
* `country` (String - Standard: "DE" oder aus Impressum)
|
|
||||||
* `status` (Enum: NEW, IMPORTED, ENRICHED, QUALIFIED)
|
|
||||||
|
|
||||||
### 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 `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)
|
|
||||||
|
|
||||||
### Tabelle `industries` (Branchen-Fokus)
|
|
||||||
* `id` (PK)
|
|
||||||
* `name` (String, Unique)
|
|
||||||
* `description` (Text - Abgrenzung/Definition)
|
|
||||||
* `is_focus` (Boolean)
|
|
||||||
* `primary_category_id` (FK -> robotics_categories.id)
|
|
||||||
|
|
||||||
### 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).
|
|
||||||
|
|
||||||
## 7. Historie & Fixes (Jan 2026)
|
## 7. Historie & Fixes (Jan 2026)
|
||||||
|
|
||||||
|
* **[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.5.0: Contacts, Settings & UI Overhaul**
|
* **[UPGRADE] v0.5.0: Contacts, Settings & UI Overhaul**
|
||||||
* **Contacts Management:**
|
* **Contacts Management:**
|
||||||
* Implementierung einer globalen Kontakt-Liste (`ContactsTable`) mit Such- und Filterfunktionen.
|
* Implementierung einer globalen Kontakt-Liste (`ContactsTable`) mit Such- und Filterfunktionen.
|
||||||
@@ -175,37 +44,5 @@ Contacts stehen in 1:n Beziehung zu Accounts. Accounts können einen "Primary Co
|
|||||||
* **Zeitstempel:** Anzeige des Erstellungsdatums für jeden Anreicherungsdatensatz (Wikipedia, AI Dossier, Impressum) in der Detailansicht.
|
* **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.
|
* **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.
|
* **Frontend-Fix:** Behebung eines Build-Fehlers (`Unexpected token`) in `Inspector.tsx` durch Entfernung eines duplizierten JSX-Blocks.
|
||||||
|
... (rest of the file remains the same)
|
||||||
* **[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.
|
|
||||||
|
|
||||||
* **[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.
|
|
||||||
|
|
||||||
* **[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.
|
|
||||||
@@ -33,4 +33,4 @@ ENV PYTHONUNBUFFERED=1
|
|||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Start FastAPI
|
# Start FastAPI
|
||||||
CMD ["uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
@@ -106,6 +106,7 @@ def list_companies(
|
|||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
search: Optional[str] = None,
|
search: Optional[str] = None,
|
||||||
|
sort_by: Optional[str] = Query("name_asc"),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
@@ -114,8 +115,16 @@ def list_companies(
|
|||||||
query = query.filter(Company.name.ilike(f"%{search}%"))
|
query = query.filter(Company.name.ilike(f"%{search}%"))
|
||||||
|
|
||||||
total = query.count()
|
total = query.count()
|
||||||
# Sort by ID desc (newest first)
|
|
||||||
items = query.order_by(Company.id.desc()).offset(skip).limit(limit).all()
|
# Sorting Logic
|
||||||
|
if sort_by == "updated_desc":
|
||||||
|
query = query.order_by(Company.updated_at.desc())
|
||||||
|
elif sort_by == "created_desc":
|
||||||
|
query = query.order_by(Company.id.desc())
|
||||||
|
else: # Default: name_asc
|
||||||
|
query = query.order_by(Company.name.asc())
|
||||||
|
|
||||||
|
items = query.offset(skip).limit(limit).all()
|
||||||
|
|
||||||
return {"total": total, "items": items}
|
return {"total": total, "items": items}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -263,10 +272,48 @@ def override_wiki_url(company_id: int, url: str = Query(...), db: Session = Depe
|
|||||||
existing_wiki.content = wiki_data
|
existing_wiki.content = wiki_data
|
||||||
existing_wiki.updated_at = datetime.utcnow()
|
existing_wiki.updated_at = datetime.utcnow()
|
||||||
existing_wiki.is_locked = True # LOCK IT
|
existing_wiki.is_locked = True # LOCK IT
|
||||||
|
existing_wiki.wiki_verified_empty = False # It's no longer empty
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
# The return needs to be here, outside the else block but inside the main function
|
||||||
return {"status": "updated", "data": wiki_data}
|
return {"status": "updated", "data": wiki_data}
|
||||||
|
|
||||||
|
@app.post("/api/companies/{company_id}/wiki_mark_empty")
|
||||||
|
def mark_wiki_empty(company_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Marks a company as having no valid Wikipedia entry after manual review.
|
||||||
|
Creates a locked, empty Wikipedia enrichment entry.
|
||||||
|
"""
|
||||||
|
company = db.query(Company).filter(Company.id == company_id).first()
|
||||||
|
if not company:
|
||||||
|
raise HTTPException(404, "Company not found")
|
||||||
|
|
||||||
|
logger.info(f"Manual override for {company.name}: Marking Wikipedia as verified empty.")
|
||||||
|
|
||||||
|
existing_wiki = db.query(EnrichmentData).filter(
|
||||||
|
EnrichmentData.company_id == company.id,
|
||||||
|
EnrichmentData.source_type == "wikipedia"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
empty_wiki_data = {"url": "k.A.", "title": "k.A.", "first_paragraph": "k.A.", "error": "Manually marked as empty"}
|
||||||
|
|
||||||
|
if not existing_wiki:
|
||||||
|
db.add(EnrichmentData(
|
||||||
|
company_id=company.id,
|
||||||
|
source_type="wikipedia",
|
||||||
|
content=empty_wiki_data,
|
||||||
|
is_locked=True,
|
||||||
|
wiki_verified_empty=True
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
existing_wiki.content = empty_wiki_data
|
||||||
|
existing_wiki.updated_at = datetime.utcnow()
|
||||||
|
existing_wiki.is_locked = True # LOCK IT
|
||||||
|
existing_wiki.wiki_verified_empty = True # Mark as empty
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"status": "updated", "wiki_verified_empty": True}
|
||||||
|
|
||||||
@app.post("/api/companies/{company_id}/override/website")
|
@app.post("/api/companies/{company_id}/override/website")
|
||||||
def override_website_url(company_id: int, url: str = Query(...), db: Session = Depends(get_db)):
|
def override_website_url(company_id: int, url: str = Query(...), db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
@@ -305,6 +352,17 @@ def override_impressum_url(company_id: int, url: str = Query(...), db: Session =
|
|||||||
if not impressum_data:
|
if not impressum_data:
|
||||||
raise HTTPException(status_code=400, detail="Failed to extract data from provided URL")
|
raise HTTPException(status_code=400, detail="Failed to extract data from provided URL")
|
||||||
|
|
||||||
|
# Update company record with city/country if found
|
||||||
|
logger.info(f"override_impressum_url: Scraped impressum_data for {company.name}: City={impressum_data.get('city')}, Country_code={impressum_data.get('country_code')}")
|
||||||
|
if city_val := impressum_data.get("city"):
|
||||||
|
logger.info(f"override_impressum_url: Updating company.city from '{company.city}' to '{city_val}'")
|
||||||
|
company.city = city_val
|
||||||
|
if country_val := impressum_data.get("country_code"):
|
||||||
|
logger.info(f"override_impressum_url: Updating company.country from '{company.country}' to '{country_val}'")
|
||||||
|
company.country = country_val
|
||||||
|
logger.info(f"override_impressum_url: Company object after updates (before commit): City='{company.city}', Country='{company.country}'")
|
||||||
|
|
||||||
|
|
||||||
# 2. Find existing scrape data or create new
|
# 2. Find existing scrape data or create new
|
||||||
existing_scrape = db.query(EnrichmentData).filter(
|
existing_scrape = db.query(EnrichmentData).filter(
|
||||||
EnrichmentData.company_id == company.id,
|
EnrichmentData.company_id == company.id,
|
||||||
@@ -312,20 +370,23 @@ def override_impressum_url(company_id: int, url: str = Query(...), db: Session =
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not existing_scrape:
|
if not existing_scrape:
|
||||||
# Create minimal scrape entry
|
# Create minimal scrape entry and lock it
|
||||||
db.add(EnrichmentData(
|
db.add(EnrichmentData(
|
||||||
company_id=company.id,
|
company_id=company.id,
|
||||||
source_type="website_scrape",
|
source_type="website_scrape",
|
||||||
content={"impressum": impressum_data, "text": "", "title": "Manual Impressum", "url": url}
|
content={"impressum": impressum_data, "text": "", "title": "Manual Impressum", "url": url},
|
||||||
|
is_locked=True
|
||||||
))
|
))
|
||||||
else:
|
else:
|
||||||
# Update existing
|
# Update existing and lock it
|
||||||
content = dict(existing_scrape.content) if existing_scrape.content else {}
|
content = dict(existing_scrape.content) if existing_scrape.content else {}
|
||||||
content["impressum"] = impressum_data
|
content["impressum"] = impressum_data
|
||||||
existing_scrape.content = content
|
existing_scrape.content = content
|
||||||
existing_scrape.updated_at = datetime.utcnow()
|
existing_scrape.updated_at = datetime.utcnow()
|
||||||
|
existing_scrape.is_locked = True
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
logger.info(f"override_impressum_url: Commit successful. Company ID {company.id} updated.")
|
||||||
return {"status": "updated", "data": impressum_data}
|
return {"status": "updated", "data": impressum_data}
|
||||||
|
|
||||||
# --- Contact Routes ---
|
# --- Contact Routes ---
|
||||||
@@ -465,6 +526,7 @@ def list_all_contacts(
|
|||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
search: Optional[str] = None,
|
search: Optional[str] = None,
|
||||||
|
sort_by: Optional[str] = Query("name_asc"),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -482,8 +544,16 @@ def list_all_contacts(
|
|||||||
)
|
)
|
||||||
|
|
||||||
total = query.count()
|
total = query.count()
|
||||||
# Sort by ID desc
|
|
||||||
contacts = query.order_by(Contact.id.desc()).offset(skip).limit(limit).all()
|
# Sorting Logic
|
||||||
|
if sort_by == "updated_desc":
|
||||||
|
query = query.order_by(Contact.updated_at.desc())
|
||||||
|
elif sort_by == "created_desc":
|
||||||
|
query = query.order_by(Contact.id.desc())
|
||||||
|
else: # Default: name_asc
|
||||||
|
query = query.order_by(Contact.last_name.asc(), Contact.first_name.asc())
|
||||||
|
|
||||||
|
contacts = query.offset(skip).limit(limit).all()
|
||||||
|
|
||||||
# Enrich with Company Name for the frontend list
|
# Enrich with Company Name for the frontend list
|
||||||
result = []
|
result = []
|
||||||
@@ -552,6 +622,23 @@ def bulk_import_contacts(req: BulkContactImportRequest, db: Session = Depends(ge
|
|||||||
db.commit()
|
db.commit()
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
|
@app.post("/api/enrichment/{company_id}/{source_type}/lock")
|
||||||
|
def lock_enrichment(company_id: int, source_type: str, locked: bool = Query(...), db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Toggles the lock status of a specific enrichment data type (e.g. 'website_scrape', 'wikipedia').
|
||||||
|
"""
|
||||||
|
entry = db.query(EnrichmentData).filter(
|
||||||
|
EnrichmentData.company_id == company_id,
|
||||||
|
EnrichmentData.source_type == source_type
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not entry:
|
||||||
|
raise HTTPException(404, "Enrichment data not found")
|
||||||
|
|
||||||
|
entry.is_locked = locked
|
||||||
|
db.commit()
|
||||||
|
return {"status": "updated", "is_locked": locked}
|
||||||
|
|
||||||
def run_discovery_task(company_id: int):
|
def run_discovery_task(company_id: int):
|
||||||
# New Session for Background Task
|
# New Session for Background Task
|
||||||
from .database import SessionLocal
|
from .database import SessionLocal
|
||||||
@@ -616,15 +703,11 @@ def analyze_company(req: AnalysisRequest, background_tasks: BackgroundTasks, db:
|
|||||||
return {"error": "No website to analyze. Run Discovery first."}
|
return {"error": "No website to analyze. Run Discovery first."}
|
||||||
|
|
||||||
# FORCE SCRAPE LOGIC
|
# FORCE SCRAPE LOGIC
|
||||||
# If explicit force_scrape is requested OR if we want to ensure fresh data for debugging
|
# Respect Locked Data: Only delete if not locked.
|
||||||
# We delete the old scrape data.
|
|
||||||
# For now, let's assume every manual "Analyze" click implies a desire for fresh results if previous failed.
|
|
||||||
# But let's respect the flag from frontend if we add it later.
|
|
||||||
|
|
||||||
# Always clearing scrape data for now to fix the "stuck cache" issue reported by user
|
|
||||||
db.query(EnrichmentData).filter(
|
db.query(EnrichmentData).filter(
|
||||||
EnrichmentData.company_id == company.id,
|
EnrichmentData.company_id == company.id,
|
||||||
EnrichmentData.source_type == "website_scrape"
|
EnrichmentData.source_type == "website_scrape",
|
||||||
|
EnrichmentData.is_locked == False
|
||||||
).delete()
|
).delete()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -640,29 +723,97 @@ def run_analysis_task(company_id: int, url: str):
|
|||||||
|
|
||||||
logger.info(f"Running Analysis Task for {company.name}")
|
logger.info(f"Running Analysis Task for {company.name}")
|
||||||
|
|
||||||
# 1. Scrape Website
|
# 1. Scrape Website OR Use Locked Data
|
||||||
scrape_result = scraper.scrape_url(url)
|
scrape_result = {}
|
||||||
|
existing_scrape = db.query(EnrichmentData).filter(
|
||||||
# Save Scrape Data
|
|
||||||
existing_scrape_data = db.query(EnrichmentData).filter(
|
|
||||||
EnrichmentData.company_id == company.id,
|
EnrichmentData.company_id == company.id,
|
||||||
EnrichmentData.source_type == "website_scrape"
|
EnrichmentData.source_type == "website_scrape"
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if "text" in scrape_result and scrape_result["text"]:
|
if existing_scrape and existing_scrape.is_locked:
|
||||||
if not existing_scrape_data:
|
logger.info(f"Using LOCKED scrape data for {company.name}")
|
||||||
db.add(EnrichmentData(company_id=company.id, source_type="website_scrape", content=scrape_result))
|
scrape_result = dict(existing_scrape.content) # Copy dict
|
||||||
else:
|
|
||||||
existing_scrape_data.content = scrape_result
|
# Always ensure city/country from locked impressum data is synced to company
|
||||||
existing_scrape_data.updated_at = datetime.utcnow()
|
if "impressum" in scrape_result and scrape_result["impressum"]:
|
||||||
elif "error" in scrape_result:
|
impressum_city = scrape_result["impressum"].get("city")
|
||||||
logger.warning(f"Scraping failed for {company.name}: {scrape_result['error']}")
|
impressum_country = scrape_result["impressum"].get("country_code")
|
||||||
|
logger.info(f"Analysis task (locked data): Impressum found. City='{impressum_city}', Country='{impressum_country}'")
|
||||||
|
if impressum_city and company.city != impressum_city:
|
||||||
|
logger.info(f"Analysis task: Updating company.city from '{company.city}' to '{impressum_city}'")
|
||||||
|
company.city = impressum_city
|
||||||
|
if impressum_country and company.country != impressum_country:
|
||||||
|
logger.info(f"Analysis task: Updating company.country from '{company.country}' to '{impressum_country}'")
|
||||||
|
company.country = impressum_country
|
||||||
|
|
||||||
|
text_val = scrape_result.get("text")
|
||||||
|
text_len = len(text_val) if text_val else 0
|
||||||
|
logger.info(f"Locked data keys: {list(scrape_result.keys())}, Text length: {text_len}")
|
||||||
|
|
||||||
|
# AUTO-FIX: If locked data (e.g. Manual Impressum) has no text, fetch main website text
|
||||||
|
if text_len < 100:
|
||||||
|
logger.info(f"Locked data missing text (len={text_len}). Fetching content from {url}...")
|
||||||
|
try:
|
||||||
|
fresh_scrape = scraper.scrape_url(url)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fresh scrape failed: {e}", exc_info=True)
|
||||||
|
fresh_scrape = {}
|
||||||
|
|
||||||
|
logger.info(f"Fresh scrape result keys: {list(fresh_scrape.keys())}")
|
||||||
|
|
||||||
|
if "text" in fresh_scrape and len(fresh_scrape["text"]) > 100:
|
||||||
|
logger.info(f"Fresh scrape successful. Text len: {len(fresh_scrape['text'])}")
|
||||||
|
# Update local dict for current processing
|
||||||
|
scrape_result["text"] = fresh_scrape["text"]
|
||||||
|
scrape_result["title"] = fresh_scrape.get("title", "")
|
||||||
|
|
||||||
|
# Update DB (Merge into existing content)
|
||||||
|
updated_content = dict(existing_scrape.content)
|
||||||
|
updated_content["text"] = fresh_scrape["text"]
|
||||||
|
updated_content["title"] = fresh_scrape.get("title", "")
|
||||||
|
|
||||||
|
existing_scrape.content = updated_content
|
||||||
|
existing_scrape.updated_at = datetime.utcnow()
|
||||||
|
# db.commit() here would be too early
|
||||||
|
logger.info("Updated locked record with fresh website text in session.")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Fresh scrape returned insufficient text. Error: {fresh_scrape.get('error')}")
|
||||||
|
else:
|
||||||
|
# Standard Scrape
|
||||||
|
scrape_result = scraper.scrape_url(url)
|
||||||
|
|
||||||
|
# Update company fields from impressum if found during scrape
|
||||||
|
if "impressum" in scrape_result and scrape_result["impressum"]:
|
||||||
|
impressum_city = scrape_result["impressum"].get("city")
|
||||||
|
impressum_country = scrape_result["impressum"].get("country_code")
|
||||||
|
logger.info(f"Analysis task (standard scrape): Impressum found. City='{impressum_city}', Country='{impressum_country}'")
|
||||||
|
if impressum_city and company.city != impressum_city:
|
||||||
|
logger.info(f"Analysis task: Updating company.city from '{company.city}' to '{impressum_city}'")
|
||||||
|
company.city = impressum_city
|
||||||
|
if impressum_country and company.country != impressum_country:
|
||||||
|
logger.info(f"Analysis task: Updating company.country from '{company.country}' to '{impressum_country}'")
|
||||||
|
company.country = impressum_country
|
||||||
|
|
||||||
|
# Save Scrape Data
|
||||||
|
if "text" in scrape_result and scrape_result["text"]:
|
||||||
|
if not existing_scrape:
|
||||||
|
db.add(EnrichmentData(company_id=company.id, source_type="website_scrape", content=scrape_result))
|
||||||
|
else:
|
||||||
|
existing_scrape.content = scrape_result
|
||||||
|
existing_scrape.updated_at = datetime.utcnow()
|
||||||
|
elif "error" in scrape_result:
|
||||||
|
logger.warning(f"Scraping failed for {company.name}: {scrape_result['error']}")
|
||||||
|
|
||||||
# 2. Classify Robotics Potential
|
# 2. Classify Robotics Potential
|
||||||
if "text" in scrape_result and scrape_result["text"]:
|
text_content = scrape_result.get("text")
|
||||||
|
|
||||||
|
logger.info(f"Preparing classification. Text content length: {len(text_content) if text_content else 0}")
|
||||||
|
|
||||||
|
if text_content and len(text_content) > 100:
|
||||||
|
logger.info(f"Starting classification for {company.name}...")
|
||||||
analysis = classifier.analyze_robotics_potential(
|
analysis = classifier.analyze_robotics_potential(
|
||||||
company_name=company.name,
|
company_name=company.name,
|
||||||
website_text=scrape_result["text"]
|
website_text=text_content
|
||||||
)
|
)
|
||||||
|
|
||||||
if "error" in analysis:
|
if "error" in analysis:
|
||||||
@@ -672,10 +823,8 @@ def run_analysis_task(company_id: int, url: str):
|
|||||||
if industry:
|
if industry:
|
||||||
company.industry_ai = industry
|
company.industry_ai = industry
|
||||||
|
|
||||||
# Delete old signals
|
|
||||||
db.query(Signal).filter(Signal.company_id == company.id).delete()
|
db.query(Signal).filter(Signal.company_id == company.id).delete()
|
||||||
|
|
||||||
# Save new signals
|
|
||||||
potentials = analysis.get("potentials", {})
|
potentials = analysis.get("potentials", {})
|
||||||
for signal_type, data in potentials.items():
|
for signal_type, data in potentials.items():
|
||||||
new_signal = Signal(
|
new_signal = Signal(
|
||||||
@@ -687,7 +836,6 @@ def run_analysis_task(company_id: int, url: str):
|
|||||||
)
|
)
|
||||||
db.add(new_signal)
|
db.add(new_signal)
|
||||||
|
|
||||||
# Save Full Analysis Blob (Business Model + Evidence)
|
|
||||||
existing_analysis = db.query(EnrichmentData).filter(
|
existing_analysis = db.query(EnrichmentData).filter(
|
||||||
EnrichmentData.company_id == company.id,
|
EnrichmentData.company_id == company.id,
|
||||||
EnrichmentData.source_type == "ai_analysis"
|
EnrichmentData.source_type == "ai_analysis"
|
||||||
@@ -702,6 +850,8 @@ def run_analysis_task(company_id: int, url: str):
|
|||||||
company.status = "ENRICHED"
|
company.status = "ENRICHED"
|
||||||
company.last_classification_at = datetime.utcnow()
|
company.last_classification_at = datetime.utcnow()
|
||||||
logger.info(f"Robotics analysis complete for {company.name}.")
|
logger.info(f"Robotics analysis complete for {company.name}.")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Skipping classification for {company.name}: Insufficient text content (len={len(text_content) if text_content else 0})")
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info(f"Analysis finished for {company.id}")
|
logger.info(f"Analysis finished for {company.id}")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import Optional
|
|||||||
# Versuche Pydantic zu nutzen, Fallback auf os.environ
|
# Versuche Pydantic zu nutzen, Fallback auf os.environ
|
||||||
try:
|
try:
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
from pydantic import Extra
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
# App Info
|
# App Info
|
||||||
@@ -13,7 +14,7 @@ try:
|
|||||||
DEBUG: bool = True
|
DEBUG: bool = True
|
||||||
|
|
||||||
# Database (Store in App dir for simplicity)
|
# Database (Store in App dir for simplicity)
|
||||||
DATABASE_URL: str = "sqlite:////app/companies_v3_final.db"
|
DATABASE_URL: str = "sqlite:////app/companies_v3_fixed_2.db"
|
||||||
|
|
||||||
# API Keys
|
# API Keys
|
||||||
GEMINI_API_KEY: Optional[str] = None
|
GEMINI_API_KEY: Optional[str] = None
|
||||||
@@ -25,6 +26,7 @@ try:
|
|||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
extra = 'ignore'
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ class EnrichmentData(Base):
|
|||||||
source_type = Column(String) # "website_scrape", "wikipedia", "google_serp"
|
source_type = Column(String) # "website_scrape", "wikipedia", "google_serp"
|
||||||
content = Column(JSON) # The raw data
|
content = Column(JSON) # The raw data
|
||||||
is_locked = Column(Boolean, default=False) # Manual override flag
|
is_locked = Column(Boolean, default=False) # Manual override flag
|
||||||
|
wiki_verified_empty = Column(Boolean, default=False) # NEW: Mark Wikipedia as definitively empty
|
||||||
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from functools import wraps
|
|||||||
from typing import Optional, Union, List
|
from typing import Optional, Union, List
|
||||||
from thefuzz import fuzz
|
from thefuzz import fuzz
|
||||||
|
|
||||||
# Versuche neue Google GenAI Lib (v1.0+)
|
# Try new Google GenAI Lib (v1.0+)
|
||||||
try:
|
try:
|
||||||
from google import genai
|
from google import genai
|
||||||
from google.genai import types
|
from google.genai import types
|
||||||
@@ -17,7 +17,7 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_NEW_GENAI = False
|
HAS_NEW_GENAI = False
|
||||||
|
|
||||||
# Fallback auf alte Lib
|
# Fallback to old Lib
|
||||||
try:
|
try:
|
||||||
import google.generativeai as old_genai
|
import google.generativeai as old_genai
|
||||||
HAS_OLD_GENAI = True
|
HAS_OLD_GENAI = True
|
||||||
@@ -100,22 +100,33 @@ def simple_normalize_url(url: str) -> str:
|
|||||||
return "k.A."
|
return "k.A."
|
||||||
|
|
||||||
def normalize_company_name(name: str) -> str:
|
def normalize_company_name(name: str) -> str:
|
||||||
"""Normalizes a company name by removing legal forms and special characters."""
|
"""
|
||||||
|
Normalizes a company name by removing common legal forms, special characters,
|
||||||
|
and extra spaces, for robust comparison.
|
||||||
|
Handles names with numbers more intelligently (e.g., "11 88 0 Solutions" -> "11880 solutions").
|
||||||
|
"""
|
||||||
if not name:
|
if not name:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
name = name.lower()
|
name = name.lower()
|
||||||
|
|
||||||
# Remove common legal forms
|
# Remove common legal forms (more comprehensive list)
|
||||||
legal_forms = [
|
legal_forms = [
|
||||||
r'\bgmbh\b', r'\bag\b', r'\bkg\b', r'\bohg\b', r'\bug\b', r'\bltd\b',
|
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'
|
r'\bllc\b', r'\binc\b', r'\bcorp\b', r'\bco\b', r'\b& co\b', r'\be\.v\.\b',
|
||||||
|
r'\bsa\b', r'\bse\b', r'\bs\.a\.\b', r'\bgesellschaft\b', r'\bgp\b', r'\blp\b',
|
||||||
|
r'\bservice\b', r'\bservices\b', r'\bgroup\b', r'\bsolutions\b', r'\bsysteme\b',
|
||||||
|
r'\bhandel\b', r'\bmarketing\b', r'\btechnology\b', r'\binternational\b',
|
||||||
|
r'\bgmbh & co\. kg\b', r'\bholding\b', r'\bverwaltung\b', r'\bfoundation\b'
|
||||||
]
|
]
|
||||||
for form in legal_forms:
|
for form in legal_forms:
|
||||||
name = re.sub(form, '', name)
|
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
|
# Remove special chars and extra spaces
|
||||||
name = re.sub(r'[^\w\s]', '', name)
|
name = re.sub(r'[^\w\s\d]', '', name) # Keep digits
|
||||||
name = re.sub(r'\s+', ' ', name).strip()
|
name = re.sub(r'\s+', ' ', name).strip()
|
||||||
|
|
||||||
return name
|
return name
|
||||||
@@ -136,11 +147,14 @@ def extract_numeric_value(raw_value: str, is_umsatz: bool = False) -> str:
|
|||||||
# Simple multiplier handling
|
# Simple multiplier handling
|
||||||
multiplier = 1.0
|
multiplier = 1.0
|
||||||
if 'mrd' in raw_value or 'billion' in raw_value or 'bn' in raw_value:
|
if 'mrd' in raw_value or 'billion' in raw_value or 'bn' in raw_value:
|
||||||
multiplier = 1000.0 if is_umsatz else 1000000000.0
|
multiplier = 1000.0 # Standardize to Millions for revenue, Billions for absolute numbers
|
||||||
|
if not is_umsatz: multiplier = 1000000000.0
|
||||||
elif 'mio' in raw_value or 'million' in raw_value or 'mn' in raw_value:
|
elif 'mio' in raw_value or 'million' in raw_value or 'mn' in raw_value:
|
||||||
multiplier = 1.0 if is_umsatz else 1000000.0
|
multiplier = 1.0 # Already in Millions for revenue
|
||||||
|
if not is_umsatz: multiplier = 1000000.0
|
||||||
elif 'tsd' in raw_value or 'thousand' in raw_value:
|
elif 'tsd' in raw_value or 'thousand' in raw_value:
|
||||||
multiplier = 0.001 if is_umsatz else 1000.0
|
multiplier = 0.001 # Thousands converted to millions for revenue
|
||||||
|
if not is_umsatz: multiplier = 1000.0
|
||||||
|
|
||||||
# Extract number candidates
|
# Extract number candidates
|
||||||
# Regex for "1.000,50" or "1,000.50" or "1000"
|
# Regex for "1.000,50" or "1,000.50" or "1000"
|
||||||
@@ -171,8 +185,6 @@ def extract_numeric_value(raw_value: str, is_umsatz: bool = False) -> str:
|
|||||||
# For revenue, 375.6 vs 1.000 is tricky.
|
# For revenue, 375.6 vs 1.000 is tricky.
|
||||||
# But usually revenue in millions is small numbers with decimals (250.5).
|
# But usually revenue in millions is small numbers with decimals (250.5).
|
||||||
# Large integers usually mean thousands.
|
# Large integers usually mean thousands.
|
||||||
# Let's assume dot is decimal for revenue unless context implies otherwise,
|
|
||||||
# but for "375.6" it works. For "1.000" it becomes 1.0.
|
|
||||||
# Let's keep dot as decimal for revenue by default unless we detect multiple dots
|
# Let's keep dot as decimal for revenue by default unless we detect multiple dots
|
||||||
if num_str.count('.') > 1:
|
if num_str.count('.') > 1:
|
||||||
num_str = num_str.replace('.', '')
|
num_str = num_str.replace('.', '')
|
||||||
@@ -284,4 +296,4 @@ def call_gemini(
|
|||||||
logger.error(f"Error with google-generativeai lib: {e}")
|
logger.error(f"Error with google-generativeai lib: {e}")
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
raise ImportError("No Google GenAI library installed (neither google-genai nor google-generativeai).")
|
raise ImportError("No Google GenAI library installed (neither google-genai nor google-generativeai).")
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
import re
|
import re
|
||||||
from typing import Optional, Dict, Tuple
|
from typing import Optional, Dict, Tuple, Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..lib.core_utils import retry_on_failure, normalize_string
|
from ..lib.core_utils import retry_on_failure, normalize_string, normalize_company_name, simple_normalize_url
|
||||||
from .wikipedia_service import WikipediaService
|
from .wikipedia_service import WikipediaService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -23,7 +24,6 @@ class DiscoveryService:
|
|||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
logger.warning("SERP_API_KEY not set. Discovery features will fail.")
|
logger.warning("SERP_API_KEY not set. Discovery features will fail.")
|
||||||
|
|
||||||
# Initialize the specialized Wikipedia Service
|
|
||||||
self.wiki_service = WikipediaService()
|
self.wiki_service = WikipediaService()
|
||||||
|
|
||||||
@retry_on_failure(max_retries=2)
|
@retry_on_failure(max_retries=2)
|
||||||
@@ -60,42 +60,31 @@ class DiscoveryService:
|
|||||||
for result in data["organic_results"]:
|
for result in data["organic_results"]:
|
||||||
link = result.get("link", "")
|
link = result.get("link", "")
|
||||||
if self._is_credible_url(link):
|
if self._is_credible_url(link):
|
||||||
# Simple heuristic: If the company name is part of the domain, high confidence
|
|
||||||
# Otherwise, take the first credible result.
|
|
||||||
return link
|
return link
|
||||||
|
|
||||||
return "k.A."
|
return "k.A."
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"SerpAPI Error: {e}")
|
logger.error(f"SerpAPI Error: {e}", exc_info=True)
|
||||||
return "k.A."
|
return "k.A."
|
||||||
|
|
||||||
@retry_on_failure(max_retries=2)
|
@retry_on_failure(max_retries=2)
|
||||||
def find_wikipedia_url(self, company_name: str, website: str = None, city: str = None) -> str:
|
def find_wikipedia_url(self, company_name: str, website: Optional[str] = None, city: Optional[str] = None) -> str:
|
||||||
"""
|
"""
|
||||||
Searches for a specific German Wikipedia article using the robust WikipediaService.
|
Searches for a specific German Wikipedia article using the robust WikipediaService.
|
||||||
Includes validation via website domain and city.
|
Includes validation via website domain and city.
|
||||||
"""
|
"""
|
||||||
if not self.api_key:
|
# Pass all available info for robust search and validation
|
||||||
return "k.A."
|
page = self.wiki_service.search_company_article(
|
||||||
|
company_name=company_name,
|
||||||
try:
|
website=website,
|
||||||
# Delegate to the robust service
|
crm_city=city
|
||||||
# parent_name could be added if available in the future
|
)
|
||||||
page = self.wiki_service.search_company_article(
|
|
||||||
company_name=company_name,
|
if page:
|
||||||
website=website,
|
return page.url
|
||||||
crm_city=city
|
|
||||||
)
|
return "k.A."
|
||||||
|
|
||||||
if page:
|
|
||||||
return page.url
|
|
||||||
|
|
||||||
return "k.A."
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Wiki Search Error via Service: {e}")
|
|
||||||
return "k.A."
|
|
||||||
|
|
||||||
def extract_wikipedia_data(self, url: str) -> dict:
|
def extract_wikipedia_data(self, url: str) -> dict:
|
||||||
"""
|
"""
|
||||||
@@ -104,21 +93,21 @@ class DiscoveryService:
|
|||||||
try:
|
try:
|
||||||
return self.wiki_service.extract_company_data(url)
|
return self.wiki_service.extract_company_data(url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Wiki Extraction Error for {url}: {e}")
|
logger.error(f"Wiki Extraction Error for {url}: {e}", exc_info=True)
|
||||||
return {"url": url, "error": str(e)}
|
return {"url": url, "error": str(e)}
|
||||||
|
|
||||||
def _is_credible_url(self, url: str) -> bool:
|
def _is_credible_url(self, url: str) -> bool:
|
||||||
"""Filters out social media, directories, and junk."""
|
"""
|
||||||
|
Filters out social media, directories, and junk.
|
||||||
|
"""
|
||||||
if not url: return False
|
if not url: return False
|
||||||
try:
|
try:
|
||||||
domain = urlparse(url).netloc.lower().replace("www.", "")
|
domain = urlparse(url).netloc.lower().replace("www.", "")
|
||||||
if domain in BLACKLIST_DOMAINS:
|
if domain in BLACKLIST_DOMAINS:
|
||||||
return False
|
return False
|
||||||
# Check for subdomains of blacklist (e.g. de.linkedin.com)
|
|
||||||
for bad in BLACKLIST_DOMAINS:
|
for bad in BLACKLIST_DOMAINS:
|
||||||
if domain.endswith("." + bad):
|
if domain.endswith("." + bad):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
except:
|
except:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -36,17 +36,30 @@ class ScraperService:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
# Check Content Type
|
# Check Content Type
|
||||||
|
logger.debug(f"Response status: {response.status_code}")
|
||||||
|
if response.headers is None:
|
||||||
|
logger.error("Response headers is None!")
|
||||||
|
return {"error": "No headers"}
|
||||||
|
|
||||||
content_type = response.headers.get('Content-Type', '').lower()
|
content_type = response.headers.get('Content-Type', '').lower()
|
||||||
if 'text/html' not in content_type:
|
if 'text/html' not in content_type:
|
||||||
logger.warning(f"Skipping non-HTML content for {url}: {content_type}")
|
logger.warning(f"Skipping non-HTML content for {url}: {content_type}")
|
||||||
return {"error": "Not HTML"}
|
return {"error": "Not HTML"}
|
||||||
|
|
||||||
# Parse Main Page
|
# Parse Main Page
|
||||||
result = self._parse_html(response.content)
|
try:
|
||||||
|
result = self._parse_html(response.content)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in _parse_html: {e}", exc_info=True)
|
||||||
|
return {"error": f"Parse error: {e}"}
|
||||||
|
|
||||||
# --- IMPRESSUM LOGIC ---
|
# --- IMPRESSUM LOGIC ---
|
||||||
soup = BeautifulSoup(response.content, 'html.parser')
|
try:
|
||||||
impressum_url = self._find_impressum_link(soup, url)
|
soup = BeautifulSoup(response.content, 'html.parser')
|
||||||
|
impressum_url = self._find_impressum_link(soup, url)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error finding impressum: {e}", exc_info=True)
|
||||||
|
impressum_url = None
|
||||||
|
|
||||||
# FALLBACK: If deep URL (e.g. /ueber-uns/) yielded no Impressum, try Root URL
|
# FALLBACK: If deep URL (e.g. /ueber-uns/) yielded no Impressum, try Root URL
|
||||||
if not impressum_url and url.count('/') > 3:
|
if not impressum_url and url.count('/') > 3:
|
||||||
@@ -160,7 +173,8 @@ class ScraperService:
|
|||||||
# LLM Extraction
|
# LLM Extraction
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
Extract the official company details from this German 'Impressum' text.
|
Extract the official company details from this German 'Impressum' text.
|
||||||
Return JSON ONLY. Keys: 'legal_name', 'street', 'zip', 'city', 'email', 'phone', 'ceo_name', 'vat_id'.
|
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.
|
If a field is missing, use null.
|
||||||
|
|
||||||
Text:
|
Text:
|
||||||
@@ -184,40 +198,72 @@ class ScraperService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _parse_html(self, html_content: bytes) -> Dict[str, str]:
|
def _parse_html(self, html_content: bytes) -> Dict[str, str]:
|
||||||
soup = BeautifulSoup(html_content, 'html.parser')
|
if not html_content:
|
||||||
|
return {"title": "", "description": "", "text": "", "emails": []}
|
||||||
# 1. Cleanup Junk (Aggressive, matching legacy logic)
|
|
||||||
# Removed 'a' tags to prevent menu links from polluting the text analysis
|
try:
|
||||||
for element in soup(['script', 'style', 'noscript', 'iframe', 'svg', 'header', 'footer', 'nav', 'aside', 'form', 'button', 'a']):
|
soup = BeautifulSoup(html_content, 'html.parser')
|
||||||
element.decompose()
|
|
||||||
|
|
||||||
# 1b. Remove common Cookie Banners / Popups by class/id heuristics
|
# 1. Cleanup Junk
|
||||||
for div in soup.find_all("div"):
|
# Safe removal of tags
|
||||||
classes = str(div.get("class", "")).lower()
|
for element in soup(['script', 'style', 'noscript', 'iframe', 'svg', 'header', 'footer', 'nav', 'aside', 'form', 'button']):
|
||||||
ids = str(div.get("id", "")).lower()
|
if element: element.decompose()
|
||||||
if any(x in classes or x in ids for x in ["cookie", "consent", "banner", "popup", "modal", "disclaimer"]):
|
|
||||||
div.decompose()
|
# 1b. Remove common Cookie Banners (Defensive)
|
||||||
|
try:
|
||||||
|
for div in soup.find_all("div"):
|
||||||
|
if not div: continue
|
||||||
|
# .get can return None for attributes if not found? No, returns None if key not found.
|
||||||
|
# But if div is somehow None (unlikely in loop), check first.
|
||||||
|
|
||||||
|
# Convert list of classes to string if needed
|
||||||
|
cls_attr = div.get("class")
|
||||||
|
classes = " ".join(cls_attr).lower() if isinstance(cls_attr, list) else str(cls_attr or "").lower()
|
||||||
|
|
||||||
|
id_attr = div.get("id")
|
||||||
|
ids = str(id_attr or "").lower()
|
||||||
|
|
||||||
|
if any(x in classes or x in ids for x in ["cookie", "consent", "banner", "popup", "modal", "disclaimer"]):
|
||||||
|
div.decompose()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error filtering divs: {e}")
|
||||||
|
|
||||||
# 2. Extract Title & Meta Description
|
# 2. Extract Title & Meta Description
|
||||||
title = soup.title.string if soup.title else ""
|
title = ""
|
||||||
meta_desc = ""
|
try:
|
||||||
meta_tag = soup.find('meta', attrs={'name': 'description'})
|
if soup.title and soup.title.string:
|
||||||
if meta_tag:
|
title = soup.title.string
|
||||||
meta_desc = meta_tag.get('content', '')
|
except: pass
|
||||||
|
|
||||||
# 3. Extract Main Text
|
meta_desc = ""
|
||||||
# Prefer body, fallback to full soup
|
try:
|
||||||
body = soup.find('body')
|
meta_tag = soup.find('meta', attrs={'name': 'description'})
|
||||||
raw_text = body.get_text(separator=' ', strip=True) if body else soup.get_text(separator=' ', strip=True)
|
if meta_tag:
|
||||||
|
meta_desc = meta_tag.get('content', '') or ""
|
||||||
cleaned_text = clean_text(raw_text)
|
except: pass
|
||||||
|
|
||||||
# 4. Extract Emails (Basic Regex)
|
# 3. Extract Main Text
|
||||||
emails = set(re.findall(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', raw_text))
|
try:
|
||||||
|
body = soup.find('body')
|
||||||
return {
|
raw_text = body.get_text(separator=' ', strip=True) if body else soup.get_text(separator=' ', strip=True)
|
||||||
"title": clean_text(title),
|
cleaned_text = clean_text(raw_text)
|
||||||
"description": clean_text(meta_desc),
|
except Exception as e:
|
||||||
"text": cleaned_text[:25000], # Limit to avoid context overflow
|
logger.warning(f"Text extraction failed: {e}")
|
||||||
"emails": list(emails)[:5] # Limit to 5
|
cleaned_text = ""
|
||||||
}
|
|
||||||
|
# 4. Extract Emails
|
||||||
|
emails = []
|
||||||
|
try:
|
||||||
|
emails = list(set(re.findall(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', cleaned_text)))[:5]
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": clean_text(title),
|
||||||
|
"description": clean_text(meta_desc),
|
||||||
|
"text": cleaned_text[:25000],
|
||||||
|
"emails": emails
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Critical error in _parse_html: {e}", exc_info=True)
|
||||||
|
return {"title": "", "description": "", "text": "", "emails": [], "error": str(e)}
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ class WikipediaService:
|
|||||||
extracted_country = region_to_country[suffix_in_klammer]
|
extracted_country = region_to_country[suffix_in_klammer]
|
||||||
temp_sitz = temp_sitz[:klammer_match.start()].strip(" ,")
|
temp_sitz = temp_sitz[:klammer_match.start()].strip(" ,")
|
||||||
|
|
||||||
if not extracted_country and ',' in temp_sitz:
|
if not extracted_country and "," in temp_sitz:
|
||||||
parts = [p.strip() for p in temp_sitz.split(',')]
|
parts = [p.strip() for p in temp_sitz.split(',')]
|
||||||
if len(parts) > 1:
|
if len(parts) > 1:
|
||||||
last_part_lower = parts[-1].lower()
|
last_part_lower = parts[-1].lower()
|
||||||
@@ -445,4 +445,4 @@ class WikipediaService:
|
|||||||
return {**default_result, 'url': str(url_or_page) if isinstance(url_or_page, str) else 'k.A.'}
|
return {**default_result, 'url': str(url_or_page) if isinstance(url_or_page, str) else 'k.A.'}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f" -> Unexpected error extracting from '{str(url_or_page)[:100]}': {e}")
|
logger.error(f" -> Unexpected error extracting from '{str(url_or_page)[:100]}': {e}")
|
||||||
return {**default_result, 'url': str(url_or_page) if isinstance(url_or_page, str) else 'k.A.'}
|
return {**default_result, 'url': str(url_or_page) if isinstance(url_or_page, str) else 'k.A.'}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import {
|
import {
|
||||||
Building, Search, ChevronLeft, ChevronRight, Upload,
|
Building, Search, Upload, Globe, MapPin, Play, Search as SearchIcon, Loader2,
|
||||||
Globe, MapPin, Play, Search as SearchIcon, Loader2
|
LayoutGrid, List, ChevronLeft, ChevronRight, ArrowDownUp
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
interface Company {
|
interface Company {
|
||||||
id: number
|
id: number
|
||||||
@@ -13,6 +14,8 @@ interface Company {
|
|||||||
website: string | null
|
website: string | null
|
||||||
status: string
|
status: string
|
||||||
industry_ai: string | null
|
industry_ai: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CompanyTableProps {
|
interface CompanyTableProps {
|
||||||
@@ -27,160 +30,168 @@ export function CompanyTable({ apiBase, onRowClick, refreshKey, onImportClick }:
|
|||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
|
const [sortBy, setSortBy] = useState('name_asc')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [processingId, setProcessingId] = useState<number | null>(null)
|
const [processingId, setProcessingId] = useState<number | null>(null)
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||||
const limit = 50
|
const limit = 50
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await axios.get(`${apiBase}/companies?skip=${page * limit}&limit=${limit}&search=${search}`)
|
const res = await axios.get(`${apiBase}/companies?skip=${page * limit}&limit=${limit}&search=${search}&sort_by=${sortBy}`)
|
||||||
setData(res.data.items)
|
setData(res.data.items)
|
||||||
setTotal(res.data.total)
|
setTotal(res.data.total)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error("Failed to fetch companies", e)
|
||||||
} finally {
|
}
|
||||||
setLoading(false)
|
finally { setLoading(false) }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData()
|
const timer = setTimeout(fetchData, 300)
|
||||||
}, [page, search, refreshKey])
|
return () => clearTimeout(timer)
|
||||||
|
}, [page, search, refreshKey, sortBy])
|
||||||
|
|
||||||
const triggerDiscovery = async (id: number) => {
|
const triggerDiscovery = async (id: number) => {
|
||||||
setProcessingId(id)
|
setProcessingId(id);
|
||||||
try {
|
try {
|
||||||
await axios.post(`${apiBase}/enrich/discover`, { company_id: id })
|
await axios.post(`${apiBase}/enrich/discover`, { company_id: id });
|
||||||
setTimeout(fetchData, 2000)
|
setTimeout(fetchData, 2000);
|
||||||
} catch (e) {
|
} catch (e) { alert("Discovery Error"); }
|
||||||
alert("Discovery Error")
|
finally { setProcessingId(null); }
|
||||||
} finally {
|
|
||||||
setProcessingId(null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerAnalysis = async (id: number) => {
|
const triggerAnalysis = async (id: number) => {
|
||||||
setProcessingId(id)
|
setProcessingId(id);
|
||||||
try {
|
try {
|
||||||
await axios.post(`${apiBase}/enrich/analyze`, { company_id: id })
|
await axios.post(`${apiBase}/enrich/analyze`, { company_id: id });
|
||||||
setTimeout(fetchData, 2000)
|
setTimeout(fetchData, 2000);
|
||||||
} catch (e) {
|
} catch (e) { alert("Analysis Error"); }
|
||||||
alert("Analysis Error")
|
finally { setProcessingId(null); }
|
||||||
} finally {
|
|
||||||
setProcessingId(null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-white dark:bg-slate-900 transition-colors">
|
<div className="flex flex-col h-full bg-white dark:bg-slate-900 transition-colors">
|
||||||
{/* Toolbar - Same style as Contacts */}
|
{/* Toolbar */}
|
||||||
<div className="flex flex-col md:flex-row gap-4 p-4 border-b border-slate-200 dark:border-slate-800 items-center justify-between bg-slate-50 dark:bg-slate-950/50">
|
<div className="flex flex-col md:flex-row gap-4 p-4 border-b border-slate-200 dark:border-slate-800 items-center justify-between bg-slate-50 dark:bg-slate-950/50">
|
||||||
<div className="flex items-center gap-2 text-slate-700 dark:text-slate-300 font-bold text-lg">
|
<div className="flex items-center gap-2 text-slate-700 dark:text-slate-300 font-bold text-lg">
|
||||||
<Building className="h-5 w-5" />
|
<Building className="h-5 w-5" />
|
||||||
<h2>Companies ({total})</h2>
|
<h2>Companies ({total})</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 w-full md:w-auto gap-2 max-w-xl">
|
<div className="flex flex-1 w-full md:w-auto items-center gap-2 max-w-2xl">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
|
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
|
||||||
<input
|
<input type="text" placeholder="Search companies..."
|
||||||
type="text"
|
|
||||||
placeholder="Search companies, cities, industries..."
|
|
||||||
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-md text-sm text-slate-900 dark:text-slate-200 focus:ring-2 focus:ring-blue-500 outline-none"
|
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-md text-sm text-slate-900 dark:text-slate-200 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
value={search}
|
value={search} onChange={e => { setSearch(e.target.value); setPage(0); }}/>
|
||||||
onChange={e => { setSearch(e.target.value); setPage(0); }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="relative flex items-center text-slate-700 dark:text-slate-300">
|
||||||
<button
|
<ArrowDownUp className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 pointer-events-none" />
|
||||||
onClick={onImportClick}
|
<select value={sortBy} onChange={e => setSortBy(e.target.value)}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-bold rounded-md shadow-sm transition-colors"
|
className="pl-8 pr-4 py-2 appearance-none bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-md text-sm focus:ring-2 focus:ring-blue-500 outline-none">
|
||||||
>
|
<option value="name_asc">Alphabetical</option>
|
||||||
|
<option value="created_desc">Newest First</option>
|
||||||
|
<option value="updated_desc">Last Modified</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center bg-slate-200 dark:bg-slate-800 p-1 rounded-md text-slate-700 dark:text-slate-300">
|
||||||
|
<button onClick={() => setViewMode('grid')} className={clsx("p-1.5 rounded", viewMode === 'grid' && "bg-white dark:bg-slate-700 shadow text-blue-600 dark:text-white")} title="Grid View"><LayoutGrid className="h-4 w-4" /></button>
|
||||||
|
<button onClick={() => setViewMode('list')} className={clsx("p-1.5 rounded", viewMode === 'list' && "bg-white dark:bg-slate-700 shadow text-blue-600 dark:text-white")} title="List View"><List className="h-4 w-4" /></button>
|
||||||
|
</div>
|
||||||
|
<button onClick={onImportClick} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-bold rounded-md shadow-sm">
|
||||||
<Upload className="h-4 w-4" /> <span className="hidden md:inline">Import</span>
|
<Upload className="h-4 w-4" /> <span className="hidden md:inline">Import</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid View - Same as Contacts */}
|
{/* Content Area */}
|
||||||
<div className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950/30">
|
<div className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950/30">
|
||||||
{loading && <div className="p-4 text-center text-slate-500">Loading companies...</div>}
|
{loading && <div className="p-4 text-center text-slate-500">Loading companies...</div>}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 p-4">
|
{data.length === 0 && !loading ? (
|
||||||
{data.map((c) => (
|
<div className="p-12 text-center text-slate-500">
|
||||||
<div
|
<Building className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
||||||
key={c.id}
|
<p className="text-lg font-medium">No companies found</p>
|
||||||
onClick={() => onRowClick(c.id)}
|
<p className="text-slate-400 mt-2">Import a list or create one manually to get started.</p>
|
||||||
className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg p-4 hover:shadow-lg transition-all flex flex-col gap-3 group cursor-pointer border-l-4"
|
</div>
|
||||||
style={{ borderLeftColor: c.status === 'ENRICHED' ? '#22c55e' : c.status === 'DISCOVERED' ? '#3b82f6' : '#94a3b8' }}
|
) : viewMode === 'grid' ? (
|
||||||
>
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 p-4">
|
||||||
<div className="flex items-start justify-between">
|
{data.map((c) => (
|
||||||
<div className="min-w-0 flex-1">
|
<div key={c.id} onClick={() => onRowClick(c.id)}
|
||||||
<div className="font-bold text-slate-900 dark:text-white text-sm truncate" title={c.name}>
|
className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg p-4 hover:shadow-lg transition-all flex flex-col gap-3 group cursor-pointer border-l-4"
|
||||||
{c.name}
|
style={{ borderLeftColor: c.status === 'ENRICHED' ? '#22c55e' : c.status === 'DISCOVERED' ? '#3b82f6' : '#94a3b8' }}>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-bold text-slate-900 dark:text-white text-sm truncate" title={c.name}>{c.name}</div>
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-slate-500 dark:text-slate-400 font-medium">
|
||||||
|
{c.city && c.country ? (<><MapPin className="h-3 w-3" /> {c.city} <span className="text-slate-400">({c.country})</span></>) : (<span className="italic opacity-50">-</span>)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-[10px] text-slate-500 dark:text-slate-400 font-medium">
|
<div className="flex gap-1 ml-2">
|
||||||
<MapPin className="h-3 w-3" /> {c.city || 'Unknown'}, {c.country}
|
{processingId === c.id ? <Loader2 className="h-4 w-4 animate-spin text-blue-500" /> : c.status === 'NEW' || !c.website || c.website === 'k.A.' ? (
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); triggerDiscovery(c.id); }} className="p-1.5 bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 rounded hover:bg-blue-600 hover:text-white transition-colors"><SearchIcon className="h-3.5 w-3.5" /></button>
|
||||||
|
) : (
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); triggerAnalysis(c.id); }} className="p-1.5 bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded hover:bg-blue-600 hover:text-white transition-colors"><Play className="h-3.5 w-3.5 fill-current" /></button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 ml-2">
|
<div className="space-y-2 pt-2 border-t border-slate-100 dark:border-slate-800/50">
|
||||||
{processingId === c.id ? (
|
{c.website && c.website !== "k.A." ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
|
<div className="flex items-center gap-2 text-xs text-blue-600 dark:text-blue-400 font-medium truncate">
|
||||||
) : c.status === 'NEW' || !c.website || c.website === 'k.A.' ? (
|
<Globe className="h-3 w-3" />
|
||||||
<button
|
<span>{new URL(c.website).hostname.replace('www.', '')}</span>
|
||||||
onClick={(e) => { e.stopPropagation(); triggerDiscovery(c.id); }}
|
</div>
|
||||||
className="p-1.5 bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 rounded hover:bg-blue-600 hover:text-white transition-colors"
|
) : (<div className="text-xs text-slate-400 italic">No website found</div>)}
|
||||||
>
|
<div className="text-[10px] text-slate-500 uppercase font-bold tracking-wider truncate">{c.industry_ai || "Industry Pending"}</div>
|
||||||
<SearchIcon className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); triggerAnalysis(c.id); }}
|
|
||||||
className="p-1.5 bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded hover:bg-blue-600 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<Play className="h-3.5 w-3.5 fill-current" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
<div className="space-y-2 pt-2 border-t border-slate-100 dark:border-slate-800/50">
|
</div>
|
||||||
{c.website && c.website !== "k.A." ? (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-xs text-blue-600 dark:text-blue-400 font-medium truncate">
|
<table className="min-w-full divide-y divide-slate-200 dark:divide-slate-800">
|
||||||
<Globe className="h-3 w-3" />
|
<thead className="bg-slate-100 dark:bg-slate-950/50">
|
||||||
<span>{new URL(c.website).hostname.replace('www.', '')}</span>
|
<tr>
|
||||||
</div>
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">Company</th>
|
||||||
) : (
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">Location</th>
|
||||||
<div className="text-xs text-slate-400 italic">No website found</div>
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">Website</th>
|
||||||
)}
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">AI Industry</th>
|
||||||
<div className="text-[10px] text-slate-500 uppercase font-bold tracking-wider truncate">
|
<th scope="col" className="relative px-3 py-3.5"><span className="sr-only">Actions</span></th>
|
||||||
{c.industry_ai || "Industry Pending"}
|
</tr>
|
||||||
</div>
|
</thead>
|
||||||
</div>
|
<tbody className="divide-y divide-slate-200 dark:divide-slate-800 bg-white dark:bg-slate-900">
|
||||||
</div>
|
{data.map((c) => (
|
||||||
))}
|
<tr key={c.id} onClick={() => onRowClick(c.id)} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 cursor-pointer">
|
||||||
</div>
|
<td className="whitespace-nowrap px-3 py-4 text-sm font-medium text-slate-900 dark:text-white">{c.name}</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{c.city && c.country ? `${c.city}, (${c.country})` : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-blue-600 dark:text-blue-400">
|
||||||
|
{c.website && c.website !== "k.A." ? <a href={c.website} target="_blank" rel="noreferrer">{new URL(c.website).hostname.replace('www.', '')}</a> : 'n/a'}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-slate-500 dark:text-slate-400">{c.industry_ai || 'Pending'}</td>
|
||||||
|
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||||
|
{processingId === c.id ? <Loader2 className="h-4 w-4 animate-spin text-blue-500" /> : c.status === 'NEW' || !c.website || c.website === 'k.A.' ? (
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); triggerDiscovery(c.id); }} className="text-slate-600 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400"><SearchIcon className="h-4 w-4" /></button>
|
||||||
|
) : (
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); triggerAnalysis(c.id); }} className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"><Play className="h-4 w-4 fill-current" /></button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
<div className="p-3 border-t border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 flex justify-between items-center text-xs text-slate-500 dark:text-slate-400">
|
<div className="p-3 border-t border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 flex justify-between items-center text-xs text-slate-500 dark:text-slate-400">
|
||||||
<span>{total} Companies total</span>
|
<span>{total} Companies total</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1 items-center">
|
||||||
<button
|
<button disabled={page === 0} onClick={() => setPage(p => p - 1)} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30"><ChevronLeft className="h-4 w-4" /></button>
|
||||||
disabled={page === 0}
|
<span>Page {page + 1}</span>
|
||||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
<button disabled={(page + 1) * limit >= total} onClick={() => setPage(p => p + 1)} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30"><ChevronRight className="h-4 w-4" /></button>
|
||||||
className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<span className="px-2 py-1">Page {page + 1}</span>
|
|
||||||
<button
|
|
||||||
disabled={(page + 1) * limit >= total}
|
|
||||||
onClick={() => setPage(p => p + 1)}
|
|
||||||
className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30"
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import {
|
import {
|
||||||
Users, Search, ChevronLeft, ChevronRight, Upload,
|
Users, Search, Upload, Mail, Building, LayoutGrid, List,
|
||||||
Mail, Building, Briefcase, User
|
ChevronLeft, ChevronRight, X, ArrowDownUp
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
interface ContactsTableProps {
|
interface ContactsTableProps {
|
||||||
apiBase: string
|
apiBase: string
|
||||||
onCompanyClick: (id: number) => void
|
onCompanyClick: (id: number) => void
|
||||||
onContactClick: (companyId: number, contactId: number) => void // NEW
|
onContactClick: (companyId: number, contactId: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactsTable({ apiBase, onCompanyClick, onContactClick }: ContactsTableProps) {
|
export function ContactsTable({ apiBase, onCompanyClick, onContactClick }: ContactsTableProps) {
|
||||||
@@ -17,39 +17,35 @@ export function ContactsTable({ apiBase, onCompanyClick, onContactClick }: Conta
|
|||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
|
const [sortBy, setSortBy] = useState('name_asc')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||||
const limit = 50
|
const limit = 50
|
||||||
|
|
||||||
// Import State
|
|
||||||
const [isImportOpen, setIsImportOpen] = useState(false)
|
const [isImportOpen, setIsImportOpen] = useState(false)
|
||||||
const [importText, setImportText] = useState("")
|
const [importText, setImportText] = useState("")
|
||||||
const [importStatus, setImportStatus] = useState<string | null>(null)
|
const [importStatus, setImportStatus] = useState<string | null>(null)
|
||||||
|
|
||||||
const fetchContacts = () => {
|
const fetchContacts = () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
axios.get(`${apiBase}/contacts/all?skip=${page * limit}&limit=${limit}&search=${search}`)
|
axios.get(`${apiBase}/contacts/all?skip=${page * limit}&limit=${limit}&search=${search}&sort_by=${sortBy}`)
|
||||||
.then(res => {
|
.then(res => { setData(res.data.items); setTotal(res.data.total); })
|
||||||
setData(res.data.items)
|
|
||||||
setTotal(res.data.total)
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(fetchContacts, 300)
|
const timeout = setTimeout(fetchContacts, 300)
|
||||||
return () => clearTimeout(timeout)
|
return () => clearTimeout(timeout)
|
||||||
}, [page, search])
|
}, [page, search, sortBy])
|
||||||
|
|
||||||
const handleImport = async () => {
|
const handleImport = async () => {
|
||||||
if (!importText) return
|
if (!importText) return
|
||||||
setImportStatus("Parsing...")
|
setImportStatus("Parsing...")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Simple CSV-ish parsing: Company, First, Last, Email, Job
|
|
||||||
const lines = importText.split('\n').filter(l => l.trim())
|
const lines = importText.split('\n').filter(l => l.trim())
|
||||||
const contacts = lines.map(line => {
|
const contacts = lines.map(line => {
|
||||||
const parts = line.split(/[;,|]+/).map(p => p.trim())
|
const parts = line.split(/[;,|]+/).map(p => p.trim())
|
||||||
// Expected: Company, First, Last, Email (optional)
|
|
||||||
if (parts.length < 3) return null
|
if (parts.length < 3) return null
|
||||||
return {
|
return {
|
||||||
company_name: parts[0],
|
company_name: parts[0],
|
||||||
@@ -90,34 +86,38 @@ export function ContactsTable({ apiBase, onCompanyClick, onContactClick }: Conta
|
|||||||
<h2>All Contacts ({total})</h2>
|
<h2>All Contacts ({total})</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 w-full md:w-auto gap-2 max-w-xl">
|
<div className="flex flex-1 w-full md:w-auto items-center gap-2 max-w-2xl">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
|
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
|
||||||
<input
|
<input type="text" placeholder="Search contacts..." className="w-full pl-10 pr-4 py-2 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-md text-sm text-slate-900 dark:text-slate-200 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
type="text"
|
value={search} onChange={e => { setSearch(e.target.value); setPage(0); }}/>
|
||||||
placeholder="Search contacts, companies, emails..."
|
|
||||||
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-md text-sm text-slate-900 dark:text-slate-200 focus:ring-2 focus:ring-blue-500 outline-none"
|
|
||||||
value={search}
|
|
||||||
onChange={e => { setSearch(e.target.value); setPage(0); }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="relative flex items-center text-slate-700 dark:text-slate-300">
|
||||||
<button
|
<ArrowDownUp className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 pointer-events-none" />
|
||||||
onClick={() => setIsImportOpen(true)}
|
<select value={sortBy} onChange={e => setSortBy(e.target.value)}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-bold rounded-md shadow-sm transition-colors"
|
className="pl-8 pr-4 py-2 appearance-none bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-md text-sm focus:ring-2 focus:ring-blue-500 outline-none">
|
||||||
>
|
<option value="name_asc">Alphabetical</option>
|
||||||
|
<option value="created_desc">Newest First</option>
|
||||||
|
<option value="updated_desc">Last Modified</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center bg-slate-200 dark:bg-slate-800 p-1 rounded-md text-slate-700 dark:text-slate-300">
|
||||||
|
<button onClick={() => setViewMode('grid')} className={clsx("p-1.5 rounded", viewMode === 'grid' && "bg-white dark:bg-slate-700 shadow text-blue-600 dark:text-white")} title="Grid View"><LayoutGrid className="h-4 w-4" /></button>
|
||||||
|
<button onClick={() => setViewMode('list')} className={clsx("p-1.5 rounded", viewMode === 'list' && "bg-white dark:bg-slate-700 shadow text-blue-600 dark:text-white")} title="List View"><List className="h-4 w-4" /></button>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setIsImportOpen(true)} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-bold rounded-md shadow-sm">
|
||||||
<Upload className="h-4 w-4" /> <span className="hidden md:inline">Import</span>
|
<Upload className="h-4 w-4" /> <span className="hidden md:inline">Import</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Import Modal */}
|
{/* Import Modal */}
|
||||||
{isImportOpen && (
|
{isImportOpen && (
|
||||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
|
||||||
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-2xl w-full max-w-lg border border-slate-200 dark:border-slate-800 flex flex-col max-h-[90vh]">
|
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-2xl w-full max-w-lg border border-slate-200 dark:border-slate-800 flex flex-col max-h-[90vh]">
|
||||||
<div className="p-4 border-b border-slate-200 dark:border-slate-800 flex justify-between items-center">
|
<div className="p-4 border-b border-slate-200 dark:border-slate-800 flex justify-between items-center">
|
||||||
<h3 className="font-bold text-slate-900 dark:text-white">Bulk Import Contacts</h3>
|
<h3 className="font-bold text-slate-900 dark:text-white">Bulk Import Contacts</h3>
|
||||||
<button onClick={() => setIsImportOpen(false)} className="text-slate-500 hover:text-red-500"><Users className="h-5 w-5" /></button>
|
<button onClick={() => setIsImportOpen(false)} className="text-slate-500 hover:text-red-500"><X className="h-5 w-5" /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 flex-1 overflow-y-auto">
|
<div className="p-4 flex-1 overflow-y-auto">
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-400 mb-2">
|
<p className="text-sm text-slate-600 dark:text-slate-400 mb-2">
|
||||||
@@ -144,77 +144,71 @@ export function ContactsTable({ apiBase, onCompanyClick, onContactClick }: Conta
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Data Grid */}
|
{/* Content Area */}
|
||||||
<div className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950/30">
|
<div className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950/30">
|
||||||
{loading && <div className="p-4 text-center text-slate-500">Loading contacts...</div>}
|
{loading && <div className="p-4 text-center text-slate-500">Loading contacts...</div>}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 p-4">
|
{data.length === 0 && !loading ? (
|
||||||
|
<div className="p-12 text-center text-slate-500">
|
||||||
|
<Users className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
||||||
|
<p className="text-lg font-medium">No contacts found</p>
|
||||||
|
<p className="text-slate-400 mt-2">Import a list or create one manually to get started.</p>
|
||||||
|
</div>
|
||||||
|
) : viewMode === 'grid' ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 p-4">
|
||||||
{data.map((c: any) => (
|
{data.map((c: any) => (
|
||||||
<div
|
<div key={c.id} onClick={() => onContactClick(c.company_id, c.id)}
|
||||||
key={c.id}
|
className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg p-4 hover:shadow-lg transition-all flex flex-col gap-3 group cursor-pointer border-l-4 border-l-slate-400">
|
||||||
onClick={() => onContactClick(c.company_id, c.id)}
|
<div className="font-bold text-slate-900 dark:text-white text-sm">{c.title} {c.first_name} {c.last_name}</div>
|
||||||
className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg p-4 hover:shadow-lg transition-all flex flex-col gap-3 group cursor-pointer border-l-4 border-l-slate-400"
|
<div className="text-xs text-slate-500 dark:text-slate-400">{c.job_title || "No Title"}</div>
|
||||||
>
|
<div className="space-y-2 pt-2 border-t border-slate-100 dark:border-slate-800/50">
|
||||||
<div className="flex items-start justify-between">
|
<div onClick={(e) => { e.stopPropagation(); onCompanyClick(c.company_id); }}
|
||||||
<div className="flex items-center gap-3">
|
className="flex items-center gap-2 text-xs font-bold text-slate-600 dark:text-slate-400 hover:text-blue-500 dark:hover:text-blue-400 cursor-pointer">
|
||||||
<div className="p-2 bg-slate-100 dark:bg-slate-800 rounded-full text-slate-500 dark:text-slate-400">
|
<Building className="h-3 w-3" /> {c.company_name}
|
||||||
<User className="h-5 w-5" />
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-2 text-xs text-slate-500"><Mail className="h-3 w-3" /> {c.email || "-"}</div>
|
||||||
<div>
|
|
||||||
<div className="font-bold text-slate-900 dark:text-white text-sm">
|
|
||||||
{c.title} {c.first_name} {c.last_name}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-500 dark:text-slate-400 truncate max-w-[150px]" title={c.job_title}>
|
|
||||||
{c.job_title || "No Title"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className={clsx("px-2 py-0.5 rounded text-[10px] font-bold border", c.status ? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border-blue-200 dark:border-blue-800" : "bg-slate-100 dark:bg-slate-800 text-slate-500 border-slate-200 dark:border-slate-700")}>
|
|
||||||
{c.status || "No Status"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 pt-2 border-t border-slate-100 dark:border-slate-800/50">
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-400 hover:text-blue-500 dark:hover:text-blue-400 cursor-pointer"
|
|
||||||
onClick={() => onCompanyClick(c.company_id)}
|
|
||||||
>
|
|
||||||
<Building className="h-3 w-3" />
|
|
||||||
<span className="truncate font-medium">{c.company_name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-500">
|
|
||||||
<Mail className="h-3 w-3" />
|
|
||||||
<span className="truncate">{c.email || "-"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-500">
|
|
||||||
<Briefcase className="h-3 w-3" />
|
|
||||||
<span className="truncate">{c.role}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="min-w-full divide-y divide-slate-200 dark:divide-slate-800">
|
||||||
|
<thead className="bg-slate-100 dark:bg-slate-950/50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">Name</th>
|
||||||
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">Company</th>
|
||||||
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">Email</th>
|
||||||
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">Role</th>
|
||||||
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-200 dark:divide-slate-800 bg-white dark:bg-slate-900">
|
||||||
|
{data.map((c: any) => (
|
||||||
|
<tr key={c.id} onClick={() => onContactClick(c.company_id, c.id)} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 cursor-pointer">
|
||||||
|
<td className="whitespace-nowrap px-3 py-4 text-sm font-medium text-slate-900 dark:text-white">{c.title} {c.first_name} {c.last_name}</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
<div onClick={(e) => { e.stopPropagation(); onCompanyClick(c.company_id); }}
|
||||||
|
className="font-bold text-slate-600 dark:text-slate-400 hover:text-blue-500 dark:hover:text-blue-400 cursor-pointer">
|
||||||
|
{c.company_name}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-slate-500 dark:text-slate-400">{c.email || '-' }</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-slate-500 dark:text-slate-400">{c.role || '-'}</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-slate-500 dark:text-slate-400">{c.status || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
<div className="p-3 border-t border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 flex justify-between items-center text-xs text-slate-500 dark:text-slate-400">
|
<div className="p-3 border-t border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 flex justify-between items-center text-xs text-slate-500 dark:text-slate-400">
|
||||||
<span>Showing {data.length} of {total} contacts</span>
|
<span>{total} Contacts total</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1 items-center">
|
||||||
<button
|
<button disabled={page === 0} onClick={() => setPage(p => p - 1)} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30"><ChevronLeft className="h-4 w-4" /></button>
|
||||||
disabled={page === 0}
|
<span>Page {page + 1}</span>
|
||||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
<button disabled={(page + 1) * limit >= total} onClick={() => setPage(p => p + 1)} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30"><ChevronRight className="h-4 w-4" /></button>
|
||||||
className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<span className="px-2 py-1">Page {page + 1}</span>
|
|
||||||
<button
|
|
||||||
disabled={(page + 1) * limit >= total}
|
|
||||||
onClick={() => setPage(p => p + 1)}
|
|
||||||
className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30"
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { X, ExternalLink, Bot, Briefcase, Calendar, Globe, Users, DollarSign, MapPin, Tag, RefreshCw as RefreshCwIcon, Search as SearchIcon, Pencil, Check, Download, Clock } from 'lucide-react'
|
import { X, ExternalLink, Bot, Briefcase, Calendar, Globe, Users, DollarSign, MapPin, Tag, RefreshCw as RefreshCwIcon, Search as SearchIcon, Pencil, Check, Download, Clock, Lock, Unlock } from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { ContactsManager, Contact } from './ContactsManager'
|
import { ContactsManager, Contact } from './ContactsManager'
|
||||||
|
|
||||||
@@ -204,6 +204,16 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLockToggle = async (sourceType: string, currentLockStatus: boolean) => {
|
||||||
|
if (!companyId) return
|
||||||
|
try {
|
||||||
|
await axios.post(`${apiBase}/enrichment/${companyId}/${sourceType}/lock?locked=${!currentLockStatus}`)
|
||||||
|
fetchData(true) // Silent refresh
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Lock toggle failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleAddContact = async (contact: Contact) => {
|
const handleAddContact = async (contact: Contact) => {
|
||||||
if (!companyId) return
|
if (!companyId) return
|
||||||
try {
|
try {
|
||||||
@@ -397,23 +407,39 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
|||||||
<Briefcase className="h-3 w-3" />
|
<Briefcase className="h-3 w-3" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] uppercase font-bold text-slate-500 tracking-wider">Official Legal Data</span>
|
<span className="text-[10px] uppercase font-bold text-slate-500 tracking-wider">Official Legal Data</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{scrapeDate && (
|
{scrapeDate && (
|
||||||
<div className="text-[10px] text-slate-500 flex items-center gap-1">
|
<div className="text-[10px] text-slate-500 flex items-center gap-1">
|
||||||
<Clock className="h-3 w-3" /> {new Date(scrapeDate).toLocaleDateString()}
|
<Clock className="h-3 w-3" /> {new Date(scrapeDate).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isEditingImpressum ? (
|
|
||||||
<button
|
{/* Lock Button for Impressum */}
|
||||||
onClick={() => { setImpressumUrlInput(""); setIsEditingImpressum(true); }}
|
{scrapeEntry && (
|
||||||
className="p-1 text-slate-400 hover:text-slate-900 dark:hover:text-white transition-colors"
|
<button
|
||||||
title="Set Impressum URL Manually"
|
onClick={() => handleLockToggle('website_scrape', scrapeEntry.is_locked || false)}
|
||||||
>
|
className={clsx(
|
||||||
<Pencil className="h-3 w-3" />
|
"p-1 rounded transition-colors",
|
||||||
</button>
|
scrapeEntry.is_locked
|
||||||
) : (
|
? "text-green-600 dark:text-green-400 hover:text-green-700"
|
||||||
<div className="flex items-center gap-1 animate-in fade-in zoom-in duration-200">
|
: "text-slate-400 hover:text-slate-900 dark:hover:text-white"
|
||||||
|
)}
|
||||||
|
title={scrapeEntry.is_locked ? "Data Locked (Safe from auto-overwrite)" : "Unlocked (Auto-overwrite enabled)"}
|
||||||
|
>
|
||||||
|
{scrapeEntry.is_locked ? <Lock className="h-3.5 w-3.5" /> : <Unlock className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isEditingImpressum ? (
|
||||||
|
<button
|
||||||
|
onClick={() => { setImpressumUrlInput(""); setIsEditingImpressum(true); }}
|
||||||
|
className="p-1 text-slate-400 hover:text-slate-900 dark:hover:text-white transition-colors"
|
||||||
|
title="Set Impressum URL Manually"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
) : ( <div className="flex items-center gap-1 animate-in fade-in zoom-in duration-200">
|
||||||
<button
|
<button
|
||||||
onClick={handleImpressumOverride}
|
onClick={handleImpressumOverride}
|
||||||
className="p-1 bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400 rounded hover:bg-green-200 dark:hover:bg-green-900 transition-colors"
|
className="p-1 bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400 rounded hover:bg-green-200 dark:hover:bg-green-900 transition-colors"
|
||||||
@@ -510,22 +536,71 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
|||||||
<Globe className="h-4 w-4" /> Company Profile (Wikipedia)
|
<Globe className="h-4 w-4" /> Company Profile (Wikipedia)
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{wikiDate && (
|
|
||||||
<div className="text-[10px] text-slate-500 flex items-center gap-1 mr-2">
|
<div className="flex items-center gap-2">
|
||||||
<Clock className="h-3 w-3" /> {new Date(wikiDate).toLocaleDateString()}
|
|
||||||
</div>
|
{wikiDate && (
|
||||||
)}
|
|
||||||
{!isEditingWiki ? (
|
<div className="text-[10px] text-slate-500 flex items-center gap-1 mr-2">
|
||||||
<button
|
|
||||||
onClick={() => { setWikiUrlInput(wiki?.url || ""); setIsEditingWiki(true); }}
|
<Clock className="h-3 w-3" /> {new Date(wikiDate).toLocaleDateString()}
|
||||||
className="p-1 text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
|
||||||
title="Edit / Override URL"
|
</div>
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
)}
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
|
{/* Lock Button for Wiki */}
|
||||||
|
|
||||||
|
{wikiEntry && (
|
||||||
|
|
||||||
|
<button
|
||||||
|
|
||||||
|
onClick={() => handleLockToggle('wikipedia', wikiEntry.is_locked || false)}
|
||||||
|
|
||||||
|
className={clsx(
|
||||||
|
|
||||||
|
"p-1 rounded transition-colors mr-1",
|
||||||
|
|
||||||
|
wikiEntry.is_locked
|
||||||
|
|
||||||
|
? "text-green-600 dark:text-green-400 hover:text-green-700"
|
||||||
|
|
||||||
|
: "text-slate-400 hover:text-slate-900 dark:hover:text-white"
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
title={wikiEntry.is_locked ? "Wiki Data Locked" : "Wiki Data Unlocked"}
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
{wikiEntry.is_locked ? <Lock className="h-3.5 w-3.5" /> : <Unlock className="h-3.5 w-3.5" />}
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{!isEditingWiki ? (
|
||||||
|
|
||||||
|
<button
|
||||||
|
|
||||||
|
onClick={() => { setWikiUrlInput(wiki?.url || ""); setIsEditingWiki(true); }}
|
||||||
|
|
||||||
|
className="p-1 text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||||
|
|
||||||
|
title="Edit / Override URL"
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
) : ( <div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={handleWikiOverride}
|
onClick={handleWikiOverride}
|
||||||
className="p-1 bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400 rounded hover:bg-green-200 dark:hover:bg-green-900 transition-colors"
|
className="p-1 bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400 rounded hover:bg-green-200 dark:hover:bg-green-900 transition-colors"
|
||||||
|
|||||||
Reference in New Issue
Block a user